feat(assets): creature artwork from the shard's own client (Phase 3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s

Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.

The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.

**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.

So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.

Smaller decisions worth not rediscovering:

- The derivation joins on the catalogue KEY, not on the body id. The simpler
  join is correct today and stops being correct the moment phase 6 adds
  body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
  every browser and CDN serving the previous client's sprite, with the database
  row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
  can disagree (a wiped uploads volume, a restore from a dump), and a broken
  image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
  ghost and gargoyle bodies have no art on a stock client, and an import that
  reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
  an unmounted client volume and a deliberate downgrade look identical here.

23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-10 18:40:46 -05:00
parent 55df03496d
commit a194ec68e0
17 changed files with 3605 additions and 6 deletions

539
server/utils/assetBridge.js Normal file
View File

@@ -0,0 +1,539 @@
// The Asset Bridge client (docs/link/v8.md §5, §6, §8 — protocol 8, phase 3).
//
// Three walks over the same request/reply path `clilocBridge.js` already uses,
// and everything that file says about the envelope holds here unchanged: only
// `cut: 'end'` means finished, the cursor must advance, and 425 is the ordinary
// answer during an import rather than an error.
//
// What is different is what each walk is FOR.
//
// ── `readManifest` — what the shard could serve, without the pixels ────────
//
// §6's stage 2. Every row is `{ key, sha256, bytes, width, height }`, so the
// site can diff against what it already holds and ask for only the keys whose
// hash moved. On the ordinary case — a shard restart that changed nothing —
// that diff is empty and no pixels cross at all.
//
// This family pages on the shard's WALL CLOCK, not on bytes. Its rows are about
// ninety bytes and the whole catalogue is one page by the byte budget, but
// producing that page means decoding hundreds of sprites and the sidecar waits
// ten seconds for a reply. So `cut: 'limit'` is the normal page ending here,
// where for clilocs it would have signalled something wrong.
//
// ── `fetchAssets` — the pixels, for keys we chose ─────────────────────────
//
// Each row carries a base64 PNG. The shard encodes it: `System.Drawing` is
// already in its decode path, so PNG costs it no new dependency, and having the
// hash cover exactly the bytes we store is what makes the next Update a diff.
//
// **`catalog` is passed on every fetch and it is not optional in practice.** It
// is an id the shard derives from the client files themselves, so handing it back
// makes the shard refuse if those files moved since the manifest was read.
// Without it an operator patching their client mid-import produces one asset set
// stitched out of two, with no error anywhere — the same failure `clilocBridge`
// guards against by comparing (size, mtime) across pages.
//
// ── `resolveBodies` — the atlas's creatures, by class name ────────────────
//
// §8. The shard constructs each type and reads `Body.BodyID`, which is the only
// thing that is correct for a shard's own custom creatures. That runs on its Core
// thread, so the batch is small and the shard REFUSES an over-long list rather
// than truncating it — hence the chunking here, and hence a chunk size that is a
// constant rather than "as many as fit".
// 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 uoLinkClient = require('./uoLinkClient')
const log = require('../core').logger('asset-bridge')
/** The only family phase 3 serves. §5's key scheme covers statics and land later. */
const FAMILY = 'body'
// Chunk size for the body pass. The shard's own cap defaults to 100 and it
// refuses rather than truncates, so this must stay at or under it — a mismatch
// here does not degrade, it fails every chunk.
const BODY_CHUNK = 100
// Chunk size for a fetch request. The shard cuts the PAGE by byte budget within
// whatever it is handed, so this only bounds how large a single request is; a
// chunk of 400 one-kilobyte sprites is a couple of pages.
const FETCH_CHUNK = 400
// Bounds on each walk. None is expected to be reached — the catalogue is under a
// thousand rows — and each exists so that a shard answering nonsense costs a
// bounded amount of time rather than an unbounded amount of memory.
const MAX_PAGES = 200
const MAX_ROWS = 100000
// 425 is flow control, not failure: the shard's asset plane serves one request at
// a time because its outbound queue is bounded in lines rather than bytes. During
// an import a page coming back busy is expected, so it is retried with a backoff
// rather than failing the walk.
const BUSY_RETRIES = 6
const BUSY_BACKOFF_MS = [200, 400, 800, 1600, 3200, 5000]
class AssetBridgeError extends Error {
constructor(message, code) {
super(message)
this.name = 'AssetBridgeError'
this.code = code
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Map a sidecar response onto one of this module's codes.
*
* Deliberately the same vocabulary `clilocBridge.describeFailure` uses, because
* the admin panel reports them side by side and an operator should not have to
* learn two names for "you have not switched this on".
*
* 422 is the one that means something different here: on the cliloc path it is a
* file the shard cannot decode, and on this one it is *also* the mid-import guard
* firing — the client files moved between the manifest and the fetch.
*/
function describeFailure(res, what) {
const reason = res?.data?.reason || res?.error || `sidecar responded ${res?.status}`
switch (res?.status) {
case 403:
return new AssetBridgeError(
`The shard is refusing to serve client assets (Bridge.AssetsEnabled is off): ${reason}`,
'DISABLED',
)
case 404:
return new AssetBridgeError(`The shard has no ${what}: ${reason}`, 'NO_SOURCE')
case 409:
return new AssetBridgeError(
`The sidecar refused the protocol version this build declares: ${reason}`,
'PROTOCOL',
)
case 422:
return new AssetBridgeError(reason, 'SOURCE_CHANGED')
case 425:
return new AssetBridgeError(
'The shard stayed busy serving another asset request',
'BUSY',
)
case 503:
// The named `NO_IMAGING` outcome arrives this way: a Linux shard host with
// no libgdiplus cannot render a sprite at all, and §4.4 requires that be an
// actionable sentence rather than a stack trace. The shard's own wording
// already names the package and the command, so it is passed through.
return new AssetBridgeError(reason, /libgdiplus/i.test(reason) ? 'NO_IMAGING' : 'SHARD_DOWN')
case 504:
return new AssetBridgeError(`The shard did not answer: ${reason}`, 'SHARD_DOWN')
default:
return new AssetBridgeError(reason, 'UNAVAILABLE')
}
}
/** One call, with the 425 backoff. `send` returns the client's `{ ok, ... }`. */
async function withBusyRetry(send, what) {
for (let attempt = 0; ; attempt++) {
const res = await send()
if (res.ok) return res.data
if (res.status === 425 && attempt < BUSY_RETRIES) {
await sleep(BUSY_BACKOFF_MS[Math.min(attempt, BUSY_BACKOFF_MS.length - 1)])
continue
}
throw describeFailure(res, what)
}
}
/**
* Shared page-envelope checks (§3.4).
*
* Every one of these is a way a walk can end in something that LOOKS like a
* complete import and is not, which is why they are assertions rather than
* warnings: a truncated catalogue is indistinguishable downstream from a client
* that simply has fewer creatures.
*/
function checkPage(page, { arrayName, cursor, pages }) {
if (!page || !Array.isArray(page[arrayName])) {
throw new AssetBridgeError(
`The shard sent an asset page with no ${arrayName} array`,
'MALFORMED',
)
}
if (!page.more) {
if (page.cut !== 'end') {
throw new AssetBridgeError(
`The shard stopped sending assets after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
'INCOMPLETE',
)
}
return { done: true }
}
if (!page.cursor || page.cursor === cursor) {
throw new AssetBridgeError(
`The shard asked for another asset page without advancing its cursor (${page.cursor || 'none'})`,
'STUCK',
)
}
return { done: false, cursor: page.cursor }
}
// 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.
//
// `body.def` and `bodyconv.def` are in the list and it would be easy to leave
// them out — they hold no pixels. They decide WHICH record a body id resolves to,
// so an operator editing one changes what every affected creature looks like
// while every anim file stays byte-identical. That is precisely the drift a
// content hash of the art files cannot see.
const SOURCE_FILES = [
'anim.idx', 'anim.mul',
'anim2.idx', 'anim2.mul',
'anim3.idx', 'anim3.mul',
'anim4.idx', 'anim4.mul',
'anim5.idx', 'anim5.mul',
'body.def', 'bodyconv.def',
'verdata.mul',
]
/**
* Stage 1 of the import gate (§6): have the client files this family reads
* changed at all?
*
* Returns `{ files, extractorVersion, hashing, complete, imaging }` where `files`
* is a `{ name: { size, mtime, sha256 } }` map over `SOURCE_FILES` — a file the
* shard does not have is simply absent, which is normal (few clients carry all
* five anim files).
*
* **A null `sha256` means "not computed yet", never "changed".** The shard hashes
* off the request path because `anim.mul` alone is 195 MB and hashing it cannot
* fit inside a reply, and it reports `hashing: true` while that runs.
* `sameSources` below falls back to (size, mtime) in that case, which is the same
* gate the shard itself applies.
*/
async function sourceFingerprint() {
const res = await uoLinkClient.getAssetSources()
if (!res.ok) throw describeFailure(res, 'client file manifest')
const wanted = new Set(SOURCE_FILES)
const files = {}
for (const entry of res.data?.files ?? []) {
const name = String(entry?.name || '').toLowerCase()
if (!wanted.has(name)) continue
files[name] = {
size: Number(entry.size) || 0,
mtime: Number(entry.mtime) || 0,
sha256: entry.sha256 ?? null,
}
}
return {
files,
extractorVersion: Number(res.data?.extractorVersion) || 0,
hashing: Boolean(res.data?.hashing),
complete: Boolean(res.data?.complete),
imaging: res.data?.imaging ?? null,
}
}
/**
* True when two source fingerprints describe the same client files.
*
* The file SET has to match as well as each file's contents: a client that gained
* an `anim5.mul` it did not have before is a client whose gargoyles suddenly
* resolve, and comparing only the files present in both would call that
* unchanged.
*/
function sameSources(a, b) {
if (!a || !b) return false
if (a.extractorVersion !== b.extractorVersion) return false
const names = new Set([...Object.keys(a.files ?? {}), ...Object.keys(b.files ?? {})])
for (const name of names) {
const left = a.files?.[name]
const right = b.files?.[name]
if (!left || !right) return false
if (left.sha256 && right.sha256) {
if (left.sha256 !== right.sha256) return false
continue
}
if (left.size !== right.size || left.mtime !== right.mtime || left.size <= 0) return false
}
return names.size > 0
}
/**
* Stage 2: the whole manifest for the body family.
*
* Returns `{ rows, catalog, extractorVersion, playerBodies, pages, scanned }`.
* No pixels — `rows` is `[{ key, sha256, bytes, width, height, body, direction }]`.
*/
async function readManifest({ family = FAMILY } = {}) {
const started = Date.now()
const rows = []
let cursor = null
let pages = 0
let catalog = null
let extractorVersion = 0
let playerBodies = []
let scanned = 0
let finished = false
while (pages < MAX_PAGES) {
const page = await withBusyRetry(
() => uoLinkClient.getAssetManifest({ family, cursor }),
`${family} asset manifest`,
)
pages++
if (catalog === null) {
catalog = page.catalog ?? null
extractorVersion = Number(page.extractorVersion) || 0
playerBodies = Array.isArray(page.playerBodies) ? page.playerBodies.map(Number) : []
} else if (page.catalog !== catalog) {
// The client files moved between two pages of one walk. Refusing is the
// only honest answer: half of what we hold describes files that no longer
// exist, and nothing later can tell which half.
throw new AssetBridgeError(
"The shard's client files changed while the manifest was being read; nothing was imported",
'SOURCE_CHANGED',
)
}
scanned += Number(page.scanned) || 0
for (const row of page.rows) {
const key = String(row?.key ?? '')
if (key === '') continue
rows.push({
key,
family,
sha256: String(row?.sha256 ?? ''),
bytes: Number(row?.bytes) || 0,
width: Number(row?.width) || 0,
height: Number(row?.height) || 0,
body: Number.isFinite(Number(row?.body)) ? Number(row.body) : null,
direction: Number.isFinite(Number(row?.direction)) ? Number(row.direction) : null,
})
}
if (rows.length > MAX_ROWS) {
throw new AssetBridgeError(
`The shard listed more than ${MAX_ROWS} assets; refusing to keep reading`,
'TOO_LARGE',
)
}
const state = checkPage(page, { arrayName: 'rows', cursor, pages })
if (state.done) {
finished = true
break
}
cursor = state.cursor
}
if (!finished) {
throw new AssetBridgeError(
`The asset manifest did not end within ${MAX_PAGES} pages; nothing was imported`,
'TOO_LARGE',
)
}
log.info('asset manifest read from the shard', {
family,
rows: rows.length,
scanned,
pages,
ms: Date.now() - started,
})
return { rows, catalog, extractorVersion, playerBodies, pages, scanned }
}
/**
* The bytes for an explicit list of keys.
*
* Returns a Map of key → `{ sha256, bytes, width, height, body, direction, png }`
* where `png` is a Buffer. A key the shard could not serve is **absent from the
* map** rather than present with a null — the caller then decides what that means
* for its own row, and the two ways it happens (`absent`, `unsupported`) are
* counted separately in the returned tallies so an operator can tell "this client
* has no art for that body" from "the site asked for a key shape this shard does
* not serve", which is a bug rather than a gap.
*/
async function fetchAssets({ keys, catalog } = {}) {
const started = Date.now()
const out = new Map()
const missing = { absent: 0, unsupported: 0 }
const list = Array.isArray(keys) ? keys.filter((k) => typeof k === 'string' && k !== '') : []
if (list.length === 0) return { assets: out, missing, pages: 0 }
let pages = 0
for (let i = 0; i < list.length; i += FETCH_CHUNK) {
const chunk = list.slice(i, i + FETCH_CHUNK)
let cursor = null
let finished = false
let walked = 0
while (walked < MAX_PAGES) {
const page = await withBusyRetry(
() => uoLinkClient.fetchAssets({ keys: chunk, catalog, cursor }),
'asset content',
)
pages++
walked++
for (const row of page.rows ?? []) {
const key = String(row?.key ?? '')
if (key === '') continue
if (row?.status !== 'ok') {
if (row?.status === 'unsupported') missing.unsupported++
else missing.absent++
continue
}
if (typeof row.png !== 'string' || row.png === '') {
missing.absent++
continue
}
out.set(key, {
sha256: String(row.sha256 ?? ''),
bytes: Number(row.bytes) || 0,
width: Number(row.width) || 0,
height: Number(row.height) || 0,
body: Number.isFinite(Number(row.body)) ? Number(row.body) : null,
direction: Number.isFinite(Number(row.direction)) ? Number(row.direction) : null,
png: Buffer.from(row.png, 'base64'),
})
}
const state = checkPage(page, { arrayName: 'rows', cursor, pages: walked })
if (state.done) {
finished = true
break
}
cursor = state.cursor
}
if (!finished) {
throw new AssetBridgeError(
`An asset fetch did not end within ${MAX_PAGES} pages; nothing was imported`,
'TOO_LARGE',
)
}
}
log.info('asset content fetched from the shard', {
asked: list.length,
got: out.size,
absent: missing.absent,
unsupported: missing.unsupported,
pages,
ms: Date.now() - started,
})
return { assets: out, missing, pages }
}
/**
* Slug → body id, for the atlas's own creature list (§8).
*
* `creatures` is `[{ slug, name }]` where `name` is the ServUO class name — which
* `shard_spawn_creatures.name` already holds, because the atlas build picks the
* winning spelling of the spawn TYPE token rather than inventing a display name.
* That is why this needs no new column to ask its question.
*
* Returns `[{ slug, typeName, body, status }]`, one row per creature asked, with
* every outcome recorded — including the negative ones. A creature the shard says
* it does not have is a fact worth keeping: without it, the next pass asks again,
* and the pass costs a real constructor per name on the shard's Core thread.
*/
async function resolveBodies({ creatures } = {}) {
const started = Date.now()
const list = Array.isArray(creatures) ? creatures : []
const out = []
for (let i = 0; i < list.length; i += BODY_CHUNK) {
const chunk = list.slice(i, i + BODY_CHUNK)
const bySlug = new Map()
for (const creature of chunk) {
const typeName = String(creature?.name ?? '').trim()
if (typeName === '') continue
// Several slugs can share a type name only if the atlas slugified two
// spellings to one slug, in which case they ARE one creature; asking once
// per distinct name is what keeps the batch inside the shard's cap.
if (!bySlug.has(typeName)) bySlug.set(typeName, [])
bySlug.get(typeName).push(String(creature.slug))
}
const types = [...bySlug.keys()]
if (types.length === 0) continue
const page = await withBusyRetry(() => uoLinkClient.resolveBodies(types), 'body resolution')
if (!page || !Array.isArray(page.rows)) {
throw new AssetBridgeError('The shard sent a body resolution with no rows array', 'MALFORMED')
}
for (const row of page.rows) {
const typeName = String(row?.type ?? '')
const slugs = bySlug.get(typeName)
if (!slugs) continue
const status = String(row?.status ?? 'failed')
const body = status === 'ok' && Number.isFinite(Number(row?.body)) ? Number(row.body) : null
for (const slug of slugs) out.push({ slug, typeName, body, status })
}
}
const resolved = out.filter((r) => r.status === 'ok').length
log.info('creature bodies resolved by the shard', {
asked: list.length,
answered: out.length,
resolved,
ms: Date.now() - started,
})
return out
}
module.exports = {
AssetBridgeError,
FAMILY,
BODY_CHUNK,
FETCH_CHUNK,
MAX_PAGES,
MAX_ROWS,
SOURCE_FILES,
sourceFingerprint,
sameSources,
readManifest,
fetchAssets,
resolveBodies,
}