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'
+ ''
+ '',
],
[
'Data/Locations/Sosaria.xml',
'Yew Bank'
+ '1501500',
],
[
'Spawns/Sosaria.xml',
''
+ Array.from({ length: 40 }, (_, i) =>
`').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()
})