diff --git a/client/src/routes/admin/SpawnAtlas.jsx b/client/src/routes/admin/SpawnAtlas.jsx
index 10f084e..68e459b 100644
--- a/client/src/routes/admin/SpawnAtlas.jsx
+++ b/client/src/routes/admin/SpawnAtlas.jsx
@@ -159,11 +159,14 @@ export default function SpawnAtlas() {
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
- fresh.path === ''
- ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
- : fresh.treeReadable
- ? 'Saved. The tree is readable — import when you are ready.'
- : 'Saved, but the tree could not be read from here. Check the mount and permissions.',
+ fresh.source === 'bridge'
+ ? 'Saved, but not in use: this site reads the atlas from the linked shard. The path takes'
+ + ' over only if uo-link is disabled.'
+ : fresh.path === ''
+ ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
+ : fresh.treeReadable
+ ? 'Saved. The tree is readable — import when you are ready.'
+ : 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
@@ -185,9 +188,11 @@ export default function SpawnAtlas() {
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
- It refreshes itself on every server start; everything here is for the times you don’t want
- to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
- shard state, and stays complete while the shard is down.
+ Where those files come from depends on whether a shard is linked: with uo-link configured
+ the shard serves them over the bridge and importing is something you do here, when a map
+ changes. Without one, the site reads a local tree and re-imports itself on every server
+ start. Either way the atlas is shard content rather than shard state, so what is
+ loaded keeps serving in full while the shard is down.
@@ -218,10 +223,23 @@ export default function SpawnAtlas() {
{counts.champions?.toLocaleString() ?? '—'}
>
)}
-
- {!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
+
+ {status?.source === 'bridge'
+ ? 'The shard, over uo-link'
+ : status?.configured
+ ? status.path
+ : 'None — no shard linked and no path set'}
-
+
+ {!status?.configured
+ ? 'No source'
+ : status.treeReadable
+ ? 'Yes'
+ : status.source === 'bridge'
+ ? 'No — the shard did not answer, or Bridge.TreeEnabled is off'
+ : 'No'}
+
+
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
@@ -231,9 +249,13 @@ export default function SpawnAtlas() {
ServUO tree
- Where the website reads the shard’s spawn files from — the same host, a bind mount or a
+ A local ServUO tree the website can read directly — the same host, a bind mount or a
shared volume. This setting wins over the SERVUO_PATH deploy default, so the
- mount can move without a redeploy. Leave it blank to turn the atlas off.
+ mount can move without a redeploy.
+ {status?.source === 'bridge'
+ ? ' It is not in use right now: this site has a shard linked, and the shard serves its' +
+ ' own files over the bridge. Unlink or disable uo-link to fall back to a path.'
+ : ' Leave it blank to turn the atlas off.'}
- Applies a map change without restarting. An unchanged tree costs nothing — the source files
- are hashed first and skipped when they match. A refresh that would remove a facet still
- comes back here for approval rather than being applied.
+ Applies a map change without restarting — and on a linked shard it is the only thing that
+ does, because boot deliberately never calls the shard for this. An unchanged source costs
+ almost nothing: the file list and its hashes are read first (about 32 KB over the bridge)
+ and no file is transferred when they match. A refresh that would remove a facet still comes
+ back here for approval rather than being applied.
(source.kind === 'bridge' ? 'the shard bridge' : source.root)
+
/**
* Where the ServUO tree lives.
*
@@ -175,18 +214,29 @@ const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
// An explicit override wins outright — it is a one-off "use this tree", and it
// must not be silently overruled by the configured path the way an env default
- // would be.
- const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
- if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
+ // would be, nor by the bridge.
+ const source = await sourceFor(pathOverride)
+ const root = source.root
+ const where = describe(source)
+
+ if (source.kind === 'fs' && root === '') {
+ return { status: 'skipped', reason: 'no ServUO path configured' }
+ }
let hashes
try {
- hashes = hashSources(root)
+ hashes = await spawnAtlasSource.hashFrom(source)
} catch (err) {
- if (err instanceof AtlasSourceError) {
- return { status: 'unavailable', reason: err.message, code: err.code, path: root }
+ if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
+ return {
+ status: 'unavailable',
+ source: source.kind,
+ reason: err.message,
+ code: err.code,
+ path: where,
+ }
}
- return { status: 'failed', reason: err.message, path: root }
+ return { status: 'failed', source: source.kind, reason: err.message, path: where }
}
const meta = await db.getMeta().catch(() => null)
@@ -199,7 +249,7 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
// whatever an older build derived — a corrected parse would ship and never
// reach the data.
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
- return { status: 'unchanged', path: root }
+ return { status: 'unchanged', source: source.kind, path: where }
}
// A rejected refresh must not re-prompt on every boot. It stays rejected until
@@ -207,14 +257,28 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
// decision.
const pending = await db.getPending().catch(() => null)
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
- return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
+ return {
+ status: 'unchanged',
+ source: source.kind,
+ path: where,
+ reason: 'refresh previously rejected',
+ }
}
let atlas
try {
- atlas = buildAtlas(root)
+ atlas = await spawnAtlasSource.buildFrom(source)
} catch (err) {
- return { status: 'failed', reason: err.message, path: root }
+ if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
+ return {
+ status: 'unavailable',
+ source: source.kind,
+ reason: err.message,
+ code: err.code,
+ path: where,
+ }
+ }
+ return { status: 'failed', source: source.kind, reason: err.message, path: where }
}
const currentFacets = await db.getFacets().catch(() => [])
@@ -227,7 +291,8 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
if (removedFacets.length > 0 && !approve) {
const summary = {
hashes,
- path: root,
+ source: source.kind,
+ path: where,
currentFacets,
incomingFacets,
removedFacets,
@@ -242,9 +307,16 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
try {
const counts = await applyAtlas(atlas)
- return { status: 'imported', path: root, counts, addedFacets, removedFacets }
+ return {
+ status: 'imported',
+ source: source.kind,
+ path: where,
+ counts,
+ addedFacets,
+ removedFacets,
+ }
} catch (err) {
- return { status: 'failed', reason: err.message, path: root }
+ return { status: 'failed', source: source.kind, reason: err.message, path: where }
}
}
@@ -267,7 +339,9 @@ async function rejectPending() {
/** Everything the admin panel needs to describe atlas state. */
async function status({ path: pathOverride = '' } = {}) {
- const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
+ const source = await sourceFor(pathOverride)
+ const root = source.root
+ const configured = source.kind === 'bridge' || root !== ''
const [meta, pending, facets] = await Promise.all([
db.getMeta().catch(() => null),
db.getPending().catch(() => null),
@@ -276,9 +350,13 @@ async function status({ path: pathOverride = '' } = {}) {
let treeReadable = false
let drift = null
- if (root !== '') {
+ if (configured) {
try {
- const hashes = hashSources(root)
+ // On the bridge this is the MANIFEST, not the tree: 141 rows and ~32 KB,
+ // with no file bytes crossing the wire to answer "has anything changed".
+ // It is still a shard round trip on an admin page load, which is why it is
+ // here and not on the boot path (§17.7).
+ const hashes = await spawnAtlasSource.hashFrom(source)
treeReadable = true
const loaded = meta?.source
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
@@ -292,8 +370,9 @@ async function status({ path: pathOverride = '' } = {}) {
}
return {
- configured: root !== '',
- path: root,
+ configured,
+ source: source.kind,
+ path: describe(source),
treeReadable,
drift,
facets,
@@ -309,6 +388,18 @@ async function status({ path: pathOverride = '' } = {}) {
*/
async function refreshOnBoot() {
try {
+ // **On the bridge it imports nothing**, deliberately, and by the same
+ // reasoning as the cliloc table (§17.7). A local tree hashes in ~120 ms and
+ // skips; asking the shard would put a sidecar round trip in the boot sequence
+ // to answer a question whose answer is "no" on every restart that did not
+ // follow a map edit — and editing spawn files is an operator action, so
+ // importing became one: Admin → Shard → Import. Whatever atlas is loaded
+ // keeps serving until then.
+ if ((await sourceFor()).kind === 'bridge') {
+ log.info('spawn atlas comes from the shard; import is admin-triggered (Admin → Shard)')
+ return { status: 'skipped', source: 'bridge', reason: 'the shard is the atlas source' }
+ }
+
const result = await refresh()
switch (result.status) {
case 'imported':
diff --git a/server/router/admin/shard.router.js b/server/router/admin/shard.router.js
index 8b35ab8..4592a64 100644
--- a/server/router/admin/shard.router.js
+++ b/server/router/admin/shard.router.js
@@ -251,8 +251,8 @@ shardRouter.get(
shardRouter.get(
'/atlas',
// #swagger.tags = ['Admin · Shard']
- // #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
- // #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
+ // #swagger.summary = 'Spawn atlas status: source, drift, counts, pending review (admin only)'
+ // #swagger.description = 'Which source the atlas is built from — the linked shard over uo-link, or a local ServUO tree — whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. On the bridge, reading drift costs one shard round trip for the file manifest (hashes, no bytes). The public /atlas/meta route reports the game world only; this detail is here.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -262,8 +262,8 @@ shardRouter.get(
shardRouter.post(
'/atlas/import',
// #swagger.tags = ['Admin · Shard']
- // #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
- // #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
+ // #swagger.summary = 'Re-import the spawn atlas from its source (admin only)'
+ // #swagger.description = 'Applies a map change without a restart — and on a linked shard it is the only thing that does, because boot never calls the shard for this. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable source answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
diff --git a/server/scripts/importSpawnAtlas.js b/server/scripts/importSpawnAtlas.js
index b9ecbb8..a17d5c8 100644
--- a/server/scripts/importSpawnAtlas.js
+++ b/server/scripts/importSpawnAtlas.js
@@ -49,11 +49,14 @@ function describe(result) {
switch (result.status) {
case 'skipped':
return (
- 'No ServUO path configured — nothing to import.\n' +
- 'Set one with SERVUO_PATH, the admin panel, or --servuo .\n'
+ 'No atlas source — nothing to import.\n' +
+ 'Either link a shard (Admin → Shard) or set a tree path with SERVUO_PATH, ' +
+ 'the admin panel, or --servuo .\n'
)
case 'unavailable':
- return `ServUO tree unavailable: ${result.reason}\n`
+ return result.source === 'bridge'
+ ? `The shard could not serve its configuration tree: ${result.reason}\n`
+ : `ServUO tree unavailable: ${result.reason}\n`
case 'unchanged':
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
case 'needsReview': {
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 9c4e24a..b4141c7 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -454,12 +454,18 @@ module.exports = {
},
UoAtlasStatus: {
type: 'object',
- description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
+ description: 'Admin view of atlas state: which source the tree comes from, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
properties: {
configured: { type: 'boolean', example: true },
- path: { type: 'string', example: '/srv/servuo' },
+ source: {
+ type: 'string',
+ enum: ['bridge', 'fs'],
+ description: '`bridge`: the shard serves its own configuration files over uo-link (protocol 8 phase 7, the normal case once a shard is linked). `fs`: a ServUO tree the website can read directly — development and same-host installs, and the only source where boot re-imports by itself.',
+ example: 'bridge',
+ },
+ path: { type: 'string', description: 'The local tree path, or `the shard bridge` when that is the source.', example: 'the shard bridge' },
treeReadable: { type: 'boolean', example: true },
- drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false },
+ drift: { type: 'boolean', nullable: true, description: 'True when the source file hashes differ from the loaded atlas. NULL when the source could not be read. On the bridge this is answered from the shard\'s file MANIFEST — hashes only, no file bytes.', example: false },
facets: { type: 'array', items: { type: 'string' } },
importedAt: { type: 'string', format: 'date-time', nullable: true },
counts: { type: 'object', nullable: true, additionalProperties: true },
@@ -481,7 +487,15 @@ module.exports = {
example: 'imported',
},
reason: { type: 'string', nullable: true },
- path: { type: 'string', nullable: true },
+ source: {
+ type: 'string',
+ enum: ['bridge', 'fs'],
+ nullable: true,
+ description: 'Which end this attempt read from. Absent only on `skipped`, where there was no source at all.',
+ example: 'bridge',
+ },
+ path: { type: 'string', nullable: true, description: 'The local tree path, or `the shard bridge`.' },
+ code: { type: 'string', nullable: true, description: 'On `unavailable`: NO_PATH, NOT_FOUND, NO_REGIONS or NO_SPAWNS from a local tree; DISABLED, SOURCE_CHANGED, INCOMPLETE, MALFORMED, BUSY, SHARD_DOWN or TOO_LARGE from the bridge.' },
counts: { type: 'object', nullable: true, additionalProperties: true },
addedFacets: { type: 'array', items: { type: 'string' } },
removedFacets: { type: 'array', items: { type: 'string' } },
diff --git a/server/test/assetBridge.test.js b/server/test/assetBridge.test.js
index 7b47a39..672c26b 100644
--- a/server/test/assetBridge.test.js
+++ b/server/test/assetBridge.test.js
@@ -205,7 +205,7 @@ test('a short page that did not end the catalogue is refused', async (t) => {
stub({ manifest: [manifestPage([row(12)], { more: false, cut: 'limit' })] })
t.after(restore)
- await assert.rejects(() => bridge.readManifest(), /stopped sending assets/)
+ await assert.rejects(() => bridge.readManifest(), /stopped sending asset rows/)
})
test('a cursor that does not advance is refused rather than looped on', async (t) => {
diff --git a/server/test/atlasSourceSelection.test.js b/server/test/atlasSourceSelection.test.js
new file mode 100644
index 0000000..cf8a8b4
--- /dev/null
+++ b/server/test/atlasSourceSelection.test.js
@@ -0,0 +1,156 @@
+// Which atlas source runs, and what boot does with the answer
+// (docs/link/v8.md §10, §17.7; docs/website/SPAWN_ATLAS.md).
+//
+// The model is the only place that decides between a local ServUO tree and the
+// shard bridge, so these drive it with the shard, the database and the
+// filesystem all stubbed. Nothing here reaches a real sidecar or a real tree.
+//
+// The rule under test is the one the cliloc pipeline settled first and this
+// inherits: the bridge wins whenever uo-link is configured and enabled, a local
+// path is what a site with no shard link uses, and an explicit path is an
+// instruction that overrules both.
+
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const atlas = require('../model/shardAtlas/shardAtlas.model')
+const db = require('../model/shardAtlas/shardAtlas.db')
+const source = require('../utils/spawnAtlasSource')
+const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
+const { ctx } = require('./_setup')
+
+const saved = {
+ getMeta: db.getMeta,
+ getPending: db.getPending,
+ getFacets: db.getFacets,
+ hashFrom: source.hashFrom,
+ buildFrom: source.buildFrom,
+ getSafe: uoLinkConfig.getSafe,
+ settingsGet: ctx.settings.get,
+}
+
+function restore() {
+ db.getMeta = saved.getMeta
+ db.getPending = saved.getPending
+ db.getFacets = saved.getFacets
+ source.hashFrom = saved.hashFrom
+ source.buildFrom = saved.buildFrom
+ uoLinkConfig.getSafe = saved.getSafe
+ ctx.settings.get = saved.settingsGet
+}
+
+/** Whatever source the model chose, captured rather than read. */
+function rig({ linked = true, treePath = '', meta = null } = {}) {
+ const asked = { hash: [], build: [] }
+
+ uoLinkConfig.getSafe = async () => ({
+ enabled: linked,
+ baseUrl: linked ? 'http://127.0.0.1:8099' : null,
+ })
+ ctx.settings.get = async () => treePath
+
+ db.getMeta = async () => meta
+ db.getPending = async () => null
+ db.getFacets = async () => []
+
+ source.hashFrom = async (descriptor) => {
+ asked.hash.push(descriptor)
+ return { 'Data/Regions.xml': 'aa' }
+ }
+ source.buildFrom = async (descriptor) => {
+ asked.build.push(descriptor)
+ throw new Error('the test stops before a build')
+ }
+
+ return asked
+}
+
+test('a linked shard is the atlas source, and the configured path is not consulted', async () => {
+ const asked = rig({ linked: true, treePath: '/srv/servuo' })
+
+ const status = await atlas.status()
+
+ assert.equal(status.source, 'bridge')
+ assert.equal(status.path, 'the shard bridge')
+ assert.equal(status.configured, true)
+ assert.deepEqual(asked.hash[0], { kind: 'bridge', root: '' })
+
+ restore()
+})
+
+test('with no shard linked the configured tree is the source', async () => {
+ const asked = rig({ linked: false, treePath: '/srv/servuo' })
+
+ const status = await atlas.status()
+
+ assert.equal(status.source, 'fs')
+ assert.equal(status.path, '/srv/servuo')
+ assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
+
+ restore()
+})
+
+test('an explicit path overrules the bridge — it is an instruction, not a default', async () => {
+ const asked = rig({ linked: true, treePath: '/srv/servuo' })
+
+ await atlas.status({ path: '/tmp/other-tree' })
+
+ assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/tmp/other-tree' })
+
+ restore()
+})
+
+test('no shard and no path is "nothing configured", not an error', async () => {
+ rig({ linked: false, treePath: '' })
+
+ const status = await atlas.status()
+ assert.equal(status.configured, false)
+
+ const result = await atlas.refresh()
+ assert.equal(result.status, 'skipped')
+
+ restore()
+})
+
+test('boot does not call the shard; it says where the import lives instead', async () => {
+ // §17.7's rule, and the reason it is not free: an install whose atlas comes
+ // over the bridge has NO automatic refresh at all, so the skip has to be
+ // deliberate and visible rather than a path that quietly does nothing.
+ const asked = rig({ linked: true, treePath: '/srv/servuo' })
+
+ const result = await atlas.refreshOnBoot()
+
+ assert.equal(result.status, 'skipped')
+ assert.equal(result.source, 'bridge')
+ assert.equal(asked.hash.length, 0, 'boot made no shard call at all')
+ assert.equal(asked.build.length, 0)
+
+ restore()
+})
+
+test('boot still refreshes by itself from a local tree', async () => {
+ const asked = rig({ linked: false, treePath: '/srv/servuo' })
+
+ await atlas.refreshOnBoot()
+
+ assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
+
+ restore()
+})
+
+test('a source that cannot be read is reported, with which end could not read it', async () => {
+ rig({ linked: true, treePath: '' })
+
+ source.hashFrom = async () => {
+ const { TreeBridgeError } = require('../utils/treeBridge')
+ throw new TreeBridgeError('the shard is not serving its tree', 'DISABLED')
+ }
+
+ const result = await atlas.refresh()
+
+ assert.equal(result.status, 'unavailable')
+ assert.equal(result.source, 'bridge')
+ assert.equal(result.code, 'DISABLED')
+
+ restore()
+})
diff --git a/server/test/treeBridge.test.js b/server/test/treeBridge.test.js
new file mode 100644
index 0000000..40cc505
--- /dev/null
+++ b/server/test/treeBridge.test.js
@@ -0,0 +1,413 @@
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const zlib = require('zlib')
+const crypto = require('crypto')
+
+const { test, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Installs the `ctx` core would have handed over — treeBridge takes a logger
+// from it at call time, so a test that skips this dies on the first log line.
+require('./_setup')
+
+const uoLinkClient = require('../utils/uoLinkClient')
+const treeBridge = require('../utils/treeBridge')
+const { buildFrom, readFrom } = require('../utils/spawnAtlasSource')
+
+// The atlas source walk over the bridge (docs/link/v8.md §10 — protocol 8,
+// phase 7), driven against a stub that behaves the way `BridgeTree.cs` does.
+//
+// The test that matters most is the LAST one: the same synthetic tree, read off
+// a disk and read over the bridge, must produce a byte-identical atlas. Every
+// other test here is one specific way a walk can end in something that LOOKS
+// imported — which is the failure mode this whole family is shaped around, since
+// XML is forgiving enough that a tree reassembled wrong still parses and simply
+// has fewer spawns in it.
+
+const saved = {}
+
+function restore() {
+ for (const [name, fn] of Object.entries(saved)) {
+ if (fn) uoLinkClient[name] = fn
+ }
+}
+
+after(restore)
+
+const ok = (data) => ({ ok: true, status: 200, data })
+const fail = (status, data) => ({ ok: false, status, data })
+const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
+
+// ── A stub shard ───────────────────────────────────────────────────────────
+//
+// Chunks and gzips exactly as the overlay does, so the reader under test is
+// exercised against the wire shape rather than against a convenience.
+
+function serveTree(files, { chunkBytes = 64, catalog = 'cafebabe12345678', tweak = {} } = {}) {
+ saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
+ saved.fetchAssets = saved.fetchAssets ?? uoLinkClient.fetchAssets
+
+ const chunksOf = (bytes) => Math.max(1, Math.ceil(bytes.length / chunkBytes))
+
+ const rows = files.map(([label, bytes]) => ({
+ key: `tree/${label}`,
+ label,
+ bytes: bytes.length,
+ mtime: 1700000000000,
+ chunks: chunksOf(bytes),
+ sha256: sha(bytes),
+ }))
+
+ const byLabel = new Map(files)
+ const calls = { manifest: 0, fetch: 0 }
+
+ uoLinkClient.getAssetManifest = async ({ family, cursor } = {}) => {
+ calls.manifest++
+ assert.equal(family, 'tree', 'the walk must name its family')
+ assert.equal(cursor ?? null, null, 'this stub answers in one page')
+ if (tweak.manifestReply) return tweak.manifestReply(rows, catalog)
+ return ok({
+ kind: 'assets.manifest.ok',
+ family: 'tree',
+ catalog,
+ chunkBytes,
+ total: rows.length,
+ rows,
+ more: false,
+ cut: 'end',
+ })
+ }
+
+ uoLinkClient.fetchAssets = async ({ keys, catalog: asked } = {}) => {
+ calls.fetch++
+ assert.equal(asked, catalog, 'a fetch must assert the catalog it was listed under')
+
+ const out = []
+
+ for (const key of keys) {
+ const slash = key.lastIndexOf('/')
+ const label = key.slice('tree/'.length, slash)
+ const chunk = Number(key.slice(slash + 2))
+ const bytes = byLabel.get(label)
+
+ if (!bytes) {
+ out.push({ key, status: 'absent', reason: 'no such file' })
+ continue
+ }
+
+ const raw = bytes.subarray(chunk * chunkBytes, (chunk + 1) * chunkBytes)
+
+ out.push({
+ key,
+ status: 'ok',
+ label,
+ chunk,
+ chunks: chunksOf(bytes),
+ offset: chunk * chunkBytes,
+ bytes: raw.length,
+ sha256: sha(raw),
+ gzip: zlib.gzipSync(raw).toString('base64'),
+ })
+ }
+
+ if (tweak.fetchRows) tweak.fetchRows(out)
+
+ return ok({
+ kind: 'assets.fetch.ok',
+ family: 'tree',
+ catalog,
+ rows: out,
+ more: false,
+ cut: 'end',
+ ...(tweak.fetchEnvelope || {}),
+ })
+ }
+
+ return calls
+}
+
+const FILES = [
+ ['Data/Regions.xml', Buffer.from(' ', 'utf8')],
+ ['Spawns/Sosaria.xml', Buffer.from('' + 'x'.repeat(400) + ' ', 'utf8')],
+]
+
+// ── The walk ───────────────────────────────────────────────────────────────
+
+test('a chunked, gzipped tree reassembles to the exact bytes the shard holds', async () => {
+ const calls = serveTree(FILES)
+
+ const { files } = await treeBridge.readSources()
+
+ assert.equal(files.length, 2)
+ assert.equal(calls.manifest, 1, 'one manifest call')
+
+ for (const [label, bytes] of FILES) {
+ const got = files.find((f) => f.label === label)
+ assert.ok(got, `${label} came back`)
+ assert.equal(got.text, bytes.toString('utf8'))
+ assert.equal(got.bytes, bytes.length)
+ assert.equal(got.sha256, sha(bytes))
+ }
+
+ restore()
+})
+
+test('chunks are placed by their declared index, not by the order they arrive in', async () => {
+ // The rows come back in the order they were asked for today. A reader that
+ // appended them would agree with this test until the day something reorders a
+ // page — and then produce a file that still parses and is quietly wrong.
+ serveTree(FILES, { tweak: { fetchRows: (rows) => rows.reverse() } })
+
+ const { files } = await treeBridge.readSources()
+ const spawns = files.find((f) => f.label === 'Spawns/Sosaria.xml')
+
+ assert.equal(spawns.text, FILES[1][1].toString('utf8'))
+
+ restore()
+})
+
+test('a chunk the shard refuses fails the import rather than shortening a file', async () => {
+ serveTree(FILES, {
+ tweak: {
+ fetchRows: (rows) => {
+ rows[rows.length - 1] = { key: rows[rows.length - 1].key, status: 'absent', reason: 'gone' }
+ },
+ },
+ })
+
+ await assert.rejects(() => treeBridge.readSources(), /refused .*absent: gone/)
+ restore()
+})
+
+test('a missing chunk is named, with which one and out of how many', async () => {
+ serveTree(FILES, { tweak: { fetchRows: (rows) => rows.splice(2, 1) } })
+
+ await assert.rejects(() => treeBridge.readSources(), /missing chunk 1 of/)
+ restore()
+})
+
+test('a chunk that does not match its own hash is refused', async () => {
+ serveTree(FILES, {
+ tweak: {
+ fetchRows: (rows) => {
+ rows[1].gzip = zlib.gzipSync(Buffer.from('not what was hashed')).toString('base64')
+ rows[1].bytes = 19
+ },
+ },
+ })
+
+ await assert.rejects(() => treeBridge.readSources(), /does not match its own hash/)
+ restore()
+})
+
+test('a file whose reassembly does not match its manifest hash is refused', async () => {
+ // Every chunk is individually honest and the whole is not — which is what a
+ // dropped or duplicated chunk looks like from here.
+ serveTree(FILES, {
+ tweak: {
+ manifestReply: (rows, catalog) =>
+ ok({
+ kind: 'assets.manifest.ok',
+ family: 'tree',
+ catalog,
+ chunkBytes: 64,
+ total: rows.length,
+ rows: rows.map((r) => ({ ...r, sha256: r.sha256.replace(/^./, '0') })),
+ more: false,
+ cut: 'end',
+ }),
+ },
+ })
+
+ await assert.rejects(() => treeBridge.readSources(), /does not match the hash its manifest row carried/)
+ restore()
+})
+
+test('a tree that moves mid-read is refused rather than stitched together', async () => {
+ serveTree(FILES, { tweak: { fetchEnvelope: { catalog: 'deadbeefdeadbeef' } } })
+
+ await assert.rejects(() => treeBridge.readSources(), {
+ code: 'SOURCE_CHANGED',
+ })
+ restore()
+})
+
+test('a short page that did not end the walk is refused', async () => {
+ serveTree(FILES, { tweak: { fetchEnvelope: { more: false, cut: 'budget' } } })
+
+ await assert.rejects(() => treeBridge.readSources(), { code: 'INCOMPLETE' })
+ restore()
+})
+
+test('403 names the tree switch, not the asset switch', async () => {
+ // The two consents are different settings with different fixes, and sending an
+ // operator to Bridge.AssetsEnabled when the answer is Bridge.TreeEnabled costs
+ // them an afternoon.
+ saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
+ uoLinkClient.getAssetManifest = async () => fail(403, { reason: 'not served' })
+
+ await assert.rejects(() => treeBridge.readSources(), {
+ code: 'DISABLED',
+ message: /Bridge\.TreeEnabled/,
+ })
+ restore()
+})
+
+test('the manifest alone answers the drift gate, with no file bytes at all', async () => {
+ const calls = serveTree(FILES)
+
+ const listing = await treeBridge.manifest()
+ const fingerprint = treeBridge.fingerprintOf(listing.files)
+
+ assert.equal(calls.fetch, 0, 'nothing was fetched to answer "has anything changed"')
+ assert.deepEqual(Object.keys(fingerprint).sort(), [
+ 'Data/Regions.xml',
+ 'Spawns/Sosaria.xml',
+ ])
+ assert.equal(fingerprint['Spawns/Sosaria.xml'], sha(FILES[1][1]))
+
+ restore()
+})
+
+test('a zero-byte file crosses as one chunk carrying a real gzip stream', async () => {
+ // Stock ServUO 57.4 ships TWO empty decoration files, so this is the ordinary
+ // case rather than an edge one — and it is the case .NET gets wrong on its own:
+ // `GZipStream` writes the gzip header lazily, so zero bytes in produces zero
+ // bytes out, which is not a gzip stream at all. The overlay answers with a
+ // literal empty member; a reader that accepted an empty payload instead would
+ // have hidden the bug rather than caught it.
+ const empty = Buffer.alloc(0)
+
+ serveTree([
+ ['Data/Regions.xml', Buffer.from(' ', 'utf8')],
+ ['Data/Decoration/nothing.cfg', empty],
+ ])
+
+ const { files } = await treeBridge.readSources()
+ const blank = files.find((f) => f.label === 'Data/Decoration/nothing.cfg')
+
+ assert.equal(blank.bytes, 0)
+ assert.equal(blank.text, '')
+ assert.equal(blank.sha256, sha(empty))
+
+ restore()
+})
+
+test('an empty payload for a chunk is refused, whatever the row declares', async () => {
+ serveTree(FILES, { tweak: { fetchRows: (rows) => { rows[0].gzip = '' } } })
+
+ await assert.rejects(() => treeBridge.readSources(), /Could not decompress/)
+ restore()
+})
+
+test('a tree-only shard tells the client-file readers so, rather than looking empty', async () => {
+ // Phase 7 opened `assets.sources` to a shard that serves ONLY its configuration
+ // tree, so a 200 from it stopped meaning "the client files are on offer". Both
+ // client-file readers have to say DISABLED rather than read the empty file list
+ // as "your UO client has no cliloc.enu", which sends an operator to their client
+ // install for a setting that lives on their shard.
+ const clilocBridge = require('../utils/clilocBridge')
+ const assetBridge2 = require('../utils/assetBridge')
+
+ saved.getAssetSources = saved.getAssetSources ?? uoLinkClient.getAssetSources
+ uoLinkClient.getAssetSources = async () =>
+ ok({
+ kind: 'assets.sources.ok',
+ extractorVersion: 3,
+ assetsEnabled: false,
+ treeEnabled: true,
+ imaging: { ok: true },
+ families: ['tree'],
+ files: [],
+ more: false,
+ cut: 'end',
+ complete: true,
+ })
+
+ await assert.rejects(() => clilocBridge.fingerprint(), {
+ code: 'DISABLED',
+ message: /Bridge\.AssetsEnabled/,
+ })
+ await assert.rejects(() => assetBridge2.sourceFingerprint(), {
+ code: 'DISABLED',
+ message: /Bridge\.AssetsEnabled/,
+ })
+
+ restore()
+})
+
+// ── The parity test ────────────────────────────────────────────────────────
+
+test('the same tree read off a disk and read over the bridge builds the same atlas', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-parity-'))
+
+ const tree = [
+ [
+ 'Data/Regions.xml',
+ ''
+ + 'Yew Sosaria '
+ + ' '
+ + ' ',
+ ],
+ [
+ 'Data/Locations/Sosaria.xml',
+ 'Yew Bank '
+ + '150 150 0 ',
+ ],
+ [
+ 'Spawns/Sosaria.xml',
+ ''
+ + Array.from({ length: 40 }, (_, i) =>
+ `Lizardman ').join('')
+ + ' ',
+ ],
+ ['Config/ChampionSpawns.xml', ' '],
+ ['Data/Decoration/top.cfg', 'Brazier 0x0E31\n100 100 0\n'],
+ ['Data/Decoration/Deep/nested.cfg', 'LargeCrate 0x0E3C\n500 500 0\n'],
+ ]
+
+ for (const [label, text] of tree) {
+ const file = path.join(root, label.replace(/\//g, path.sep))
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ fs.writeFileSync(file, text, 'utf8')
+ }
+
+ const fromDisk = await buildFrom({ kind: 'fs', root })
+
+ // A chunk size small enough that the spawn file alone is dozens of chunks,
+ // because a one-chunk-per-file test proves nothing about reassembly.
+ serveTree(tree.map(([label, text]) => [label, Buffer.from(text, 'utf8')]), { chunkBytes: 37 })
+
+ const fromBridge = await buildFrom({ kind: 'bridge' })
+
+ // `generatedAt` is a timestamp and the only field that legitimately differs.
+ delete fromDisk.meta.generatedAt
+ delete fromBridge.meta.generatedAt
+
+ // **Serialised, not deepEqual.** `deepEqual` ignores object key order, and key
+ // order is precisely what differed between the two readers on a real tree —
+ // which a live walk caught and this test, written first, did not.
+ assert.equal(JSON.stringify(fromBridge), JSON.stringify(fromDisk))
+ assert.deepEqual(fromBridge, fromDisk)
+
+ // And the source fingerprints agree, which is what makes switching backends on
+ // an existing install NOT look like a change to the drift gate.
+ assert.deepEqual(fromBridge.meta.source, fromDisk.meta.source)
+
+ restore()
+ fs.rmSync(root, { recursive: true, force: true })
+})
+
+test('readFrom hands both backends back in one shape', async () => {
+ serveTree(FILES)
+
+ const bridged = await readFrom({ kind: 'bridge' })
+
+ assert.deepEqual(Object.keys(bridged), ['files'])
+ assert.deepEqual(Object.keys(bridged.files[0]).sort(), ['bytes', 'label', 'sha256', 'text'])
+
+ restore()
+})
diff --git a/server/utils/assetBridge.js b/server/utils/assetBridge.js
index 69e140d..f94b6e6 100644
--- a/server/utils/assetBridge.js
+++ b/server/utils/assetBridge.js
@@ -153,10 +153,10 @@ async function withBusyRetry(send, what) {
* warnings: a truncated catalogue is indistinguishable downstream from a client
* that simply has fewer creatures.
*/
-function checkPage(page, { arrayName, cursor, pages }) {
+function checkPage(page, { arrayName, cursor, pages, noun = 'asset' }) {
if (!page || !Array.isArray(page[arrayName])) {
throw new AssetBridgeError(
- `The shard sent an asset page with no ${arrayName} array`,
+ `The shard sent a ${noun} page with no ${arrayName} array`,
'MALFORMED',
)
}
@@ -164,7 +164,7 @@ function checkPage(page, { arrayName, cursor, pages }) {
if (!page.more) {
if (page.cut !== 'end') {
throw new AssetBridgeError(
- `The shard stopped sending assets after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
+ `The shard stopped sending ${noun} rows after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
'INCOMPLETE',
)
}
@@ -173,7 +173,7 @@ function checkPage(page, { arrayName, cursor, pages }) {
if (!page.cursor || page.cursor === cursor) {
throw new AssetBridgeError(
- `The shard asked for another asset page without advancing its cursor (${page.cursor || 'none'})`,
+ `The shard asked for another ${noun} page without advancing its cursor (${page.cursor || 'none'})`,
'STUCK',
)
}
@@ -181,6 +181,17 @@ function checkPage(page, { arrayName, cursor, pages }) {
return { done: false, cursor: page.cursor }
}
+/**
+ * SHA-256 of a buffer, lowercase hex.
+ *
+ * Here rather than in each caller because the shard's `BridgeAssets.Sha256Hex`
+ * is one function on its side too, and a hash that has to match across a wire
+ * should have exactly one spelling at each end.
+ */
+function sha256Of(buffer) {
+ return require('crypto').createHash('sha256').update(buffer).digest('hex')
+}
+
// The client files the body catalogue is derived from. `assets.sources` reports
// every file the shard can see; these are the ones that decide a sprite.
//
@@ -218,6 +229,18 @@ async function sourceFingerprint() {
const res = await uoLinkClient.getAssetSources()
if (!res.ok) throw describeFailure(res, 'client file manifest')
+ // A 200 from this call stopped meaning "client files are on offer" in phase 7:
+ // it now answers whenever either plane is enabled, so a shard serving only its
+ // configuration tree reports an empty file list rather than a 403. Read as-is
+ // that becomes "your client has no animation files", which sends an operator to
+ // the wrong place entirely.
+ if (res.data?.assetsEnabled === false) {
+ throw new AssetBridgeError(
+ 'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
+ 'DISABLED',
+ )
+ }
+
const wanted = new Set(SOURCE_FILES)
const files = {}
@@ -564,6 +587,12 @@ async function resolveBodies({ creatures } = {}) {
module.exports = {
AssetBridgeError,
+ // Shared with `treeBridge.js` (phase 7): the 425 backoff, the page-envelope
+ // checks and the hash are properties of this PLANE, not of the body family, and
+ // a second copy of any of them is a second place for the envelope to drift.
+ withBusyRetry,
+ checkPage,
+ sha256Of,
FAMILY,
BODY_CHUNK,
FETCH_CHUNK,
diff --git a/server/utils/clilocBridge.js b/server/utils/clilocBridge.js
index 1dc8726..01daa77 100644
--- a/server/utils/clilocBridge.js
+++ b/server/utils/clilocBridge.js
@@ -127,6 +127,18 @@ async function fingerprint() {
const res = await uoLinkClient.getAssetSources()
if (!res.ok) throw describeFailure(res, 'client file manifest')
+ // Since protocol 8 phase 7 this call answers when EITHER plane is enabled, so
+ // a 200 no longer means the client files are on offer. Without this check an
+ // operator who switched client-file extraction off would read "your UO client
+ // has no cliloc.enu" and go looking at their client install for a setting that
+ // lives on their shard.
+ if (res.data?.assetsEnabled === false) {
+ throw new ClilocBridgeError(
+ 'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
+ 'DISABLED',
+ )
+ }
+
const files = Array.isArray(res.data?.files) ? res.data.files : []
const entry = files.find((f) => String(f?.name || '').toLowerCase() === SOURCE_FILE)
diff --git a/server/utils/spawnAtlasSource.js b/server/utils/spawnAtlasSource.js
index 5b9364e..625dce2 100644
--- a/server/utils/spawnAtlasSource.js
+++ b/server/utils/spawnAtlasSource.js
@@ -6,6 +6,15 @@
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
// - the CLI (`scripts/importSpawnAtlas.js`)
//
+// **As of protocol 8 phase 7 it is no longer the only way in** (docs/link/v8.md
+// §10). `treeBridge.js` reads the same five labelled groups off the SHARD, over
+// the sidecar, and hands back files in exactly the shape `readSources` produces
+// here — which is why `buildFromFiles` below is where the parse actually starts
+// and both readers feed it. That closes the one place the platform's rule (only
+// the sidecar bridges the shard) was broken, and broken by the component that
+// faces the internet: this file's `SERVUO_PATH` required the WEBSITE to be able
+// to read the shard's directories.
+//
// The shard's own files are the single source of truth. Nothing is precomputed
// and committed, because a shard's maps change over its lifetime — facets get
// added, replaced or renamed — and a snapshot in the repo would silently go
@@ -48,8 +57,20 @@ class AtlasSourceError extends Error {
// ── Reading ────────────────────────────────────────────────────────────────
-function sha256(text) {
- return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
+/**
+ * The fingerprint of one source file, over its RAW BYTES.
+ *
+ * Bytes rather than the decoded string, so that this reader and the bridge
+ * reader cannot disagree. The shard hashes what it sends; a hash taken here over
+ * `text` would be a hash of a UTF-8 RE-ENCODING of what was read — identical for
+ * every valid UTF-8 file, and different for one that is not, because Node's utf8
+ * decode replaces each undecodable byte with U+FFFD and the re-encode never gets
+ * them back. A spawn file with one Latin-1 character in a creature name would
+ * then fingerprint differently depending on which end read it, and the drift gate
+ * would report a change on every single import, forever, with the tree untouched.
+ */
+function sha256(bytes) {
+ return crypto.createHash('sha256').update(bytes).digest('hex')
}
function listXml(dir) {
@@ -93,7 +114,7 @@ function listCfgTree(dir, prefix = '') {
function readIfPresent(file) {
try {
- return fs.readFileSync(file, 'utf8')
+ return fs.readFileSync(file)
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
throw err
@@ -118,9 +139,14 @@ function readSources(root) {
const files = []
const push = (label, file) => {
- const text = readIfPresent(file)
- if (text === null) return false
- files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
+ const bytes = readIfPresent(file)
+ if (bytes === null) return false
+ files.push({
+ label,
+ text: bytes.toString('utf8'),
+ sha256: sha256(bytes),
+ bytes: bytes.length,
+ })
return true
}
@@ -180,8 +206,14 @@ function hashSources(root) {
* targets (Phase 12b). The bump is what re-reads a tree the boot path
* would otherwise skip on an unchanged hash — the source files have not
* changed, only what is kept from them.
+ * 5 — source files are parsed in one canonical label order (protocol 8 phase
+ * 7). The decoration index keeps the first item id it sees for a type, so
+ * the read order decided a preview graphic; it now cannot differ between a
+ * tree read off a disk and the same tree read over the bridge. Identical
+ * sources, and for a handful of types a different answer, which is exactly
+ * what this number exists to make reach an install.
*/
-const PARSER_VERSION = 4
+const PARSER_VERSION = 5
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
@@ -251,8 +283,45 @@ function aggregateCreatures(points) {
* here writes. `shardAtlas.model.js` decides what to do with the result.
*/
function buildAtlas(root, options = {}) {
- const { files } = readSources(root)
+ return buildFromFiles(readSources(root).files, options)
+}
+
+/**
+ * The parse itself, over files that have already been read.
+ *
+ * Split out in phase 7 so that a tree which arrived over the sidecar and a tree
+ * read off a local disk go through the SAME code from here on. The alternative —
+ * a second build for the bridge — would have been a second place for the facet
+ * reconciliation, the decoration case-folding and the disabled-spawner filter to
+ * be subtly different, and the difference would only ever show up as one install
+ * having a slightly wrong atlas.
+ */
+function buildFromFiles(files, options = {}) {
+ // **Sorted here, once, whatever order the reader handed them over in.**
+ //
+ // Order is not cosmetic in this parse: the decoration index keeps the FIRST
+ // item id it sees for a type and the first spelling of it, and `meta.source` is
+ // written in iteration order. Both readers happen to agree on a stock tree, and
+ // "happen to" is the problem — the filesystem reader walks each decoration
+ // directory with `localeCompare` while the shard sorts whole relative paths,
+ // and those two disagree the moment a directory mixes cases. A tree read over
+ // the bridge would then produce a subtly different atlas from the same tree read
+ // off a disk, in a way nothing reports and only a side-by-side diff would find.
+ //
+ // A plain ordinal comparison rather than `localeCompare`, because the answer
+ // must not depend on the host's ICU data either.
+ files = [...files].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
+
const byLabel = new Map(files.map((file) => [file.label, file]))
+
+ if (!byLabel.has('Data/Regions.xml')) {
+ throw new AtlasSourceError('Missing required file: Data/Regions.xml', 'NO_REGIONS')
+ }
+
+ if (!files.some((file) => file.label.startsWith('Spawns/'))) {
+ throw new AtlasSourceError('No spawn files found in Spawns', 'NO_SPAWNS')
+ }
+
const source = {}
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
@@ -403,6 +472,49 @@ function buildAtlas(root, options = {}) {
}
}
+// ── Backends ───────────────────────────────────────────────────────────────
+//
+// One descriptor, two readers (§10, phase 7). A source is `{ kind: 'fs', root }`
+// or `{ kind: 'bridge' }`, and everything above this line belongs to the first.
+//
+// `treeBridge` is required lazily and INSIDE the functions rather than at the top
+// of the file, because this module is also loaded by `scripts/importSpawnAtlas.js`
+// and by tests that have no sidecar, no core logger and no intention of touching
+// either. A top-level require would drag the whole client stack into both.
+
+/** `{ files }` from whichever end this source names. */
+async function readFrom(source) {
+ if (source?.kind === 'bridge') {
+ const { files } = await require('./treeBridge').readSources()
+ return { files }
+ }
+
+ return readSources(source?.root)
+}
+
+/**
+ * The `{ label: sha256 }` fingerprint, from whichever end.
+ *
+ * The bridge answers this from the MANIFEST alone — no file bytes cross the wire
+ * to answer "has anything changed", which is the whole reason the manifest is a
+ * separate call. A stock tree is one page and about 32 KB.
+ */
+async function hashFrom(source) {
+ if (source?.kind === 'bridge') {
+ const treeBridge = require('./treeBridge')
+ const listing = await treeBridge.manifest()
+ return treeBridge.fingerprintOf(listing.files)
+ }
+
+ return hashSources(source?.root)
+}
+
+/** The full atlas, from whichever end. */
+async function buildFrom(source, options = {}) {
+ const { files } = await readFrom(source)
+ return buildFromFiles(files, options)
+}
+
module.exports = {
AtlasSourceError,
PARSER_VERSION,
@@ -410,6 +522,10 @@ module.exports = {
hashSources,
sameSources,
buildAtlas,
+ buildFromFiles,
+ readFrom,
+ hashFrom,
+ buildFrom,
aggregateCreatures,
displayName,
}
diff --git a/server/utils/treeBridge.js b/server/utils/treeBridge.js
new file mode 100644
index 0000000..e545e03
--- /dev/null
+++ b/server/utils/treeBridge.js
@@ -0,0 +1,418 @@
+// Spawn atlas sources — the SHARD half (docs/link/v8.md §10, protocol 8 phase 7).
+//
+// `spawnAtlasSource.js` reads a ServUO tree off a filesystem and has done since
+// the atlas existed. That is the half this replaces, and it is worth being blunt
+// about what was wrong with it: `SPAWN_ATLAS.md` required the WEBSITE to be able
+// to read the shard's directories — "the same host, a bind mount, or a shared
+// volume". Everything else about this platform holds that only the sidecar
+// bridges the shard, and that one requirement broke the rule using the component
+// that faces the internet.
+//
+// So the shard now serves its own files over the same request/reply path as
+// every other shard read, and `SERVUO_PATH` becomes what it should always have
+// been: the development and same-host convenience, not the design.
+//
+// **The parsers do not move.** `spawnAtlasParse.js` is pure, fs-free and covered
+// by CI without a ServUO tree anywhere near it, and every quirk it handles — the
+// two respawn delay units, `:OBJ=` splitting, facet-name reconciliation, the
+// XmlSpawner directive stripping — stays exactly where it is. The shard sends
+// bytes; the website still decides what they mean.
+//
+// ── Why a file arrives in pieces ──────────────────────────────────────────
+//
+// §10 said the shard would serve `tree/` → bytes, and phase 7 measured
+// that it cannot. A stock `Spawns/trammel.xml` is 4.03 MB; the sidecar discards
+// any inbound line over 1 MiB; that file as a single base64 row is 5.4 MiB. It
+// would never arrive — the reply would be dropped, the request would time out,
+// and the import would retry forever with no error in it anywhere. Two files on
+// a STOCK tree are in that state.
+//
+// A file therefore crosses as chunks, each gzipped:
+//
+// tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
+// tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
+//
+// Measured on the stock 57.4 tree: 141 files, 11.34 MB, 158 chunks, three pages,
+// 1.33 MB actually on the wire.
+//
+// ── The three checks below that are not decoration ────────────────────────
+//
+// Every one of them catches a way this can end in a tree that LOOKS imported:
+//
+// - **Each chunk re-declares its own address** and carries the hash of its own
+// uncompressed bytes. A reassembly that put chunk 3 where chunk 4 belongs
+// would produce XML that still parses — XML is forgiving about what it skips
+// — and an atlas quietly missing spawns.
+// - **The whole file is hashed after reassembly** against what the manifest
+// said, which is also the fingerprint the drift gate stores.
+// - **The catalog must not move mid-walk.** An operator editing a spawn file
+// while this runs would otherwise produce one atlas stitched out of two
+// trees, with nothing anywhere reporting a problem.
+
+// Required as a namespace, not destructured: a test that stubs the sidecar
+// replaces these on the module object, and a destructured copy taken at load
+// time would keep calling the real one.
+const zlib = require('zlib')
+const uoLinkClient = require('./uoLinkClient')
+const assetBridge = require('./assetBridge')
+const log = require('../core').logger('tree-bridge')
+
+/** The §5 key family the shard serves these under. */
+const FAMILY = 'tree'
+
+// How many chunk keys go in one `assets.fetch`. The shard cuts the PAGE by byte
+// budget within whatever it is asked for, so this only bounds the request; a
+// stock tree's 158 chunks fit in a single one.
+const FETCH_CHUNK = 200
+
+// Bounds on the walk. Neither is expected to be reached on any real tree — the
+// stock one is 141 files and 158 chunks — and both exist so that a shard
+// answering nonsense costs a bounded amount of memory rather than all of it.
+const MAX_FILES = 20000
+const MAX_BYTES = 256 * 1024 * 1024
+
+class TreeBridgeError extends Error {
+ constructor(message, code) {
+ super(message)
+ this.name = 'TreeBridgeError'
+ this.code = code
+ }
+}
+
+/**
+ * Recast an asset-plane failure as one of ours.
+ *
+ * The distinction worth keeping is 403: on this family it does NOT mean the
+ * operator declined to serve their client files, it means they declined to serve
+ * their own configuration tree — a different switch (`Bridge.TreeEnabled`) with
+ * a different fix, and telling them to look at the wrong one costs them an
+ * afternoon.
+ */
+function rethrow(err, what) {
+ if (!(err instanceof assetBridge.AssetBridgeError)) return err
+
+ if (err.code === 'DISABLED') {
+ return new TreeBridgeError(
+ 'The shard is refusing to serve its configuration tree (Bridge.TreeEnabled is off): '
+ + err.message,
+ 'DISABLED',
+ )
+ }
+
+ return new TreeBridgeError(`${what}: ${err.message}`, err.code)
+}
+
+/**
+ * Stage 1: every atlas source file the shard has, with its hash — and no bytes.
+ *
+ * Returns `{ catalog, chunkBytes, files: [{ key, label, bytes, mtime, chunks,
+ * sha256 }] }`.
+ *
+ * This is the whole of the drift gate. The website stores these hashes; the next
+ * import asks for this list again and fetches nothing at all when nothing moved,
+ * which on a shard whose maps are not being edited is every import. One page and
+ * about 32 KB on a stock tree.
+ */
+async function manifest() {
+ const started = Date.now()
+ const files = []
+
+ let cursor = null
+ let pages = 0
+ let catalog = null
+ let chunkBytes = 0
+ let finished = false
+
+ while (pages < assetBridge.MAX_PAGES) {
+ let page
+
+ try {
+ page = await assetBridge.withBusyRetry(
+ () => uoLinkClient.getAssetManifest({ family: FAMILY, cursor }),
+ 'the shard configuration tree',
+ )
+ } catch (err) {
+ throw rethrow(err, 'reading the tree manifest')
+ }
+
+ pages++
+
+ if (catalog === null) {
+ catalog = page.catalog ?? null
+ chunkBytes = Number(page.chunkBytes) || 0
+ } else if (page.catalog !== catalog) {
+ throw new TreeBridgeError(
+ "The shard's configuration tree changed while it was being listed; nothing was imported",
+ 'SOURCE_CHANGED',
+ )
+ }
+
+ for (const row of page.rows ?? []) {
+ const label = String(row?.label ?? '')
+ if (label === '') continue
+
+ files.push({
+ key: String(row?.key ?? `${FAMILY}/${label}`),
+ label,
+ bytes: Number(row?.bytes) || 0,
+ mtime: Number(row?.mtime) || 0,
+ chunks: Number(row?.chunks) || 0,
+ sha256: row?.sha256 ? String(row.sha256) : null,
+ })
+ }
+
+ if (files.length > MAX_FILES) {
+ throw new TreeBridgeError(
+ `The shard listed more than ${MAX_FILES} tree files; refusing to keep reading`,
+ 'TOO_LARGE',
+ )
+ }
+
+ let state
+
+ try {
+ state = assetBridge.checkPage(page, { arrayName: 'rows', cursor, pages, noun: 'tree' })
+ } catch (err) {
+ throw rethrow(err, 'reading the tree manifest')
+ }
+
+ if (state.done) {
+ finished = true
+ break
+ }
+
+ cursor = state.cursor
+ }
+
+ if (!finished) {
+ throw new TreeBridgeError(
+ `The tree manifest did not end within ${assetBridge.MAX_PAGES} pages; nothing was imported`,
+ 'TOO_LARGE',
+ )
+ }
+
+ log.info('tree manifest read from the shard', {
+ files: files.length,
+ catalog,
+ pages,
+ ms: Date.now() - started,
+ })
+
+ return { catalog, chunkBytes, files, pages }
+}
+
+/** A manifest as the `{ label: sha256 }` fingerprint the atlas model stores. */
+function fingerprintOf(list) {
+ const hashes = {}
+ for (const file of list) hashes[file.label] = file.sha256
+ return hashes
+}
+
+/**
+ * Stage 2: the bytes.
+ *
+ * Returns `{ files: [{ label, text, sha256, bytes }] }` — deliberately the exact
+ * shape `spawnAtlasSource.readSources` returns from a filesystem, so that
+ * `buildAtlas` cannot tell which end a tree arrived from and nothing downstream
+ * has a second code path to be wrong in.
+ */
+async function readSources() {
+ const started = Date.now()
+ const listing = await manifest()
+
+ if (listing.files.length === 0) {
+ throw new TreeBridgeError(
+ 'The shard served no atlas source files at all (is this a ServUO tree?)',
+ 'NO_SOURCE',
+ )
+ }
+
+ const expected = listing.files.reduce((sum, file) => sum + file.bytes, 0)
+
+ if (expected > MAX_BYTES) {
+ throw new TreeBridgeError(
+ `The shard's tree is ${expected} bytes, over the ${MAX_BYTES} this will read`,
+ 'TOO_LARGE',
+ )
+ }
+
+ const keys = []
+ for (const file of listing.files) {
+ // A zero-length file is still ONE chunk. An overlay that said zero would
+ // leave a manifest row nothing could ever fetch, and the walk below would
+ // report the import incomplete forever.
+ const chunks = Math.max(1, file.chunks)
+ for (let i = 0; i < chunks; i++) keys.push(`${file.key}/c${i}`)
+ }
+
+ const parts = new Map()
+ let pages = 0
+ let wire = 0
+
+ for (let i = 0; i < keys.length; i += FETCH_CHUNK) {
+ const batch = keys.slice(i, i + FETCH_CHUNK)
+
+ let cursor = null
+ let finished = false
+ let walked = 0
+
+ while (walked < assetBridge.MAX_PAGES) {
+ let page
+
+ try {
+ page = await assetBridge.withBusyRetry(
+ () => uoLinkClient.fetchAssets({ keys: batch, catalog: listing.catalog, cursor }),
+ 'the shard configuration tree',
+ )
+ } catch (err) {
+ throw rethrow(err, 'reading the tree')
+ }
+
+ pages++
+ walked++
+
+ if (typeof page.catalog === 'string' && page.catalog !== '' && page.catalog !== listing.catalog) {
+ throw new TreeBridgeError(
+ `The shard's configuration tree changed mid-read (catalog ${listing.catalog} became ${page.catalog})`,
+ 'SOURCE_CHANGED',
+ )
+ }
+
+ for (const row of page.rows ?? []) {
+ const key = String(row?.key ?? '')
+
+ if (row?.status !== 'ok') {
+ // Unlike an asset key, a tree key comes straight off a manifest this
+ // same walk just read. There is no such thing as an expected gap here:
+ // the shard listed the file, so a refusal means the tree moved or the
+ // two ends disagree about the key scheme, and importing the rest would
+ // silently drop whatever that file held.
+ throw new TreeBridgeError(
+ `The shard refused ${key || 'a tree chunk'} (${row?.status || 'unknown'}: `
+ + `${row?.reason || 'no reason given'})`,
+ 'INCOMPLETE',
+ )
+ }
+
+ const label = String(row.label ?? '')
+ const chunk = Number(row.chunk)
+
+ if (label === '' || !Number.isInteger(chunk) || chunk < 0) {
+ throw new TreeBridgeError(`The shard sent a tree chunk with no address (${key})`, 'MALFORMED')
+ }
+
+ let raw
+
+ try {
+ raw = zlib.gunzipSync(Buffer.from(String(row.gzip ?? ''), 'base64'))
+ } catch (err) {
+ throw new TreeBridgeError(`Could not decompress ${key}: ${err.message}`, 'MALFORMED')
+ }
+
+ const declared = Number(row.bytes)
+
+ if (Number.isFinite(declared) && declared !== raw.length) {
+ throw new TreeBridgeError(
+ `${key} declared ${declared} bytes and decompressed to ${raw.length}`,
+ 'MALFORMED',
+ )
+ }
+
+ if (row.sha256 && assetBridge.sha256Of(raw) !== String(row.sha256)) {
+ throw new TreeBridgeError(`${key} does not match its own hash`, 'MALFORMED')
+ }
+
+ if (!parts.has(label)) parts.set(label, new Map())
+ parts.get(label).set(chunk, raw)
+ }
+
+ let state
+
+ try {
+ state = assetBridge.checkPage(page, { arrayName: 'rows', cursor, pages: walked, noun: 'tree' })
+ } catch (err) {
+ throw rethrow(err, 'reading the tree')
+ }
+
+ if (state.done) {
+ finished = true
+ break
+ }
+
+ cursor = state.cursor
+ }
+
+ if (!finished) {
+ throw new TreeBridgeError(
+ `A tree fetch did not end within ${assetBridge.MAX_PAGES} pages; nothing was imported`,
+ 'TOO_LARGE',
+ )
+ }
+ }
+
+ const files = []
+
+ for (const file of listing.files) {
+ const chunks = Math.max(1, file.chunks)
+ const held = parts.get(file.label)
+
+ if (!held) {
+ throw new TreeBridgeError(`The shard sent nothing for ${file.label}`, 'INCOMPLETE')
+ }
+
+ const ordered = []
+
+ for (let i = 0; i < chunks; i++) {
+ const part = held.get(i)
+
+ // Indexed rather than appended in arrival order. The rows come back in the
+ // order they were asked for today, and a design that depends on that is one
+ // reordering away from an atlas that is wrong in a way nothing reports.
+ if (!part) {
+ throw new TreeBridgeError(`${file.label} is missing chunk ${i} of ${chunks}`, 'INCOMPLETE')
+ }
+
+ ordered.push(part)
+ }
+
+ const whole = Buffer.concat(ordered)
+ const sha256 = assetBridge.sha256Of(whole)
+
+ if (file.sha256 && sha256 !== file.sha256) {
+ throw new TreeBridgeError(
+ `${file.label} does not match the hash its manifest row carried`,
+ 'MALFORMED',
+ )
+ }
+
+ files.push({
+ label: file.label,
+ text: whole.toString('utf8'),
+ sha256,
+ bytes: whole.length,
+ })
+
+ wire += whole.length
+ }
+
+ log.info('tree read from the shard', {
+ files: files.length,
+ bytes: wire,
+ chunks: keys.length,
+ pages,
+ ms: Date.now() - started,
+ })
+
+ return { files, catalog: listing.catalog, pages, chunks: keys.length }
+}
+
+module.exports = {
+ TreeBridgeError,
+ FAMILY,
+ FETCH_CHUNK,
+ MAX_FILES,
+ MAX_BYTES,
+ manifest,
+ fingerprintOf,
+ readSources,
+}
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 318faa7..3b1267c 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -271,8 +271,8 @@
"tags": [
"Admin · Shard"
],
- "summary": "Spawn atlas status: path, drift, counts, pending review (admin only)",
- "description": "Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.",
+ "summary": "Spawn atlas status: source, drift, counts, pending review (admin only)",
+ "description": "Which source the atlas is built from — the linked shard over uo-link, or a local ServUO tree — whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. On the bridge, reading drift costs one shard round trip for the file manifest (hashes, no bytes). The public /atlas/meta route reports the game world only; this detail is here.",
"responses": {
"200": {
"description": "Atlas status",
@@ -345,8 +345,8 @@
"tags": [
"Admin · Shard"
],
- "summary": "Re-import the spawn atlas from the ServUO tree (admin only)",
- "description": "Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.",
+ "summary": "Re-import the spawn atlas from its source (admin only)",
+ "description": "Applies a map change without a restart — and on a linked shard it is the only thing that does, because boot never calls the shard for this. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable source answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong.",
"responses": {
"200": {
"description": "What happened",
@@ -7152,7 +7152,7 @@
},
"description": {
"type": "string",
- "example": "Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review."
+ "example": "Admin view of atlas state: which source the tree comes from, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review."
},
"properties": {
"type": "object",
@@ -7170,6 +7170,33 @@
}
}
},
+ "source": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "bridge",
+ "fs"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "`bridge`: the shard serves its own configuration files over uo-link (protocol 8 phase 7, the normal case once a shard is linked). `fs`: a ServUO tree the website can read directly — development and same-host installs, and the only source where boot re-imports by itself."
+ },
+ "example": {
+ "type": "string",
+ "example": "bridge"
+ }
+ }
+ },
"path": {
"type": "object",
"properties": {
@@ -7177,9 +7204,13 @@
"type": "string",
"example": "string"
},
+ "description": {
+ "type": "string",
+ "example": "The local tree path, or `the shard bridge` when that is the source."
+ },
"example": {
"type": "string",
- "example": "/srv/servuo"
+ "example": "the shard bridge"
}
}
},
@@ -7209,7 +7240,7 @@
},
"description": {
"type": "string",
- "example": "True when the tree's source hashes differ from the loaded atlas. NULL when the tree could not be read."
+ "example": "True when the source file hashes differ from the loaded atlas. NULL when the source could not be read. On the bridge this is answered from the shard's file MANIFEST — hashes only, no file bytes."
},
"example": {
"type": "boolean",
@@ -7350,6 +7381,37 @@
}
}
},
+ "source": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "bridge",
+ "fs"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Which end this attempt read from. Absent only on `skipped`, where there was no source at all."
+ },
+ "example": {
+ "type": "string",
+ "example": "bridge"
+ }
+ }
+ },
"path": {
"type": "object",
"properties": {
@@ -7360,6 +7422,27 @@
"nullable": {
"type": "boolean",
"example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "The local tree path, or `the shard bridge`."
+ }
+ }
+ },
+ "code": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "On `unavailable`: NO_PATH, NOT_FOUND, NO_REGIONS or NO_SPAWNS from a local tree; DISABLED, SOURCE_CHANGED, INCOMPLETE, MALFORMED, BUSY, SHARD_DOWN or TOO_LARGE from the bridge."
}
}
},