Files
docs/link/v8.md
wtclaude 018f1af5ff docs(link): phase 0 ran, and the fault it found is not a crash
Records the Asset Bridge phase 0 spike (§16) against v8.md, and closes the
last open decision.

§4's choice to call ServUO's vendored `Ultima` STANDS: nothing faulted on a
path this protocol calls, and §9's cliloc reader reproduced UOFiddler's
123,490-entry table byte for byte in 218 ms from inside the shard.

But the spike was looking for the wrong kind of failure. `LoadStatic` and
`LoadLand` decode out of a buffer that is reused, only ever grown, and
filled by a `Read` whose return value is discarded — so a short, absent or
out-of-bounds record does not throw, it renders the PREVIOUS asset. On the
stock, unmodified client on this machine that is 22,102 ids whose index
entry reads `lookup 0, length 0`, all of which the library returns a picture
for. §1.1's "32,766 decoded" was counting these.

New §4.5 states the rule that answers it — validate before calling — with
the six checks phase 0 implemented, the eight deliberate defects they caught
(seven of which the library rendered silently, including a verdata lookup
past verdata.mul's own end, which `Verdata.Seek` bounds-checks nowhere), and
the number that makes the boundary defensible: zero false refusals across
49,151 statics and 16,384 land tiles on a clean client.

New §4.6: `FileIndex`'s UOP constructor ends `MulPath = uopPath`, so
`artLegacyMUL.uop` wins outright and `art.mul` is never opened on a current
client. Bounding an offset against the wrong file is not approximate, it is
meaningless — the spike's first run refused 34,299 good statics that way,
and every refusal read like a real finding.

New §4.7: `Ultima.Gumps.GetGump(2)`, called once from inside a running
shard, made the ServUO process disappear — no catch reached, no console
line, the probe's checkpoint file the only record. §4.1's rule is earned.

§17 now has nothing open:

  * item 4 — the default audience — SETTLED: an asset inherits the audience
    of the page that uses it.
  * item 5 is new: validate-before-calling, chosen ahead of the spike over
    a child-process extractor and over reversing §4, and confirmed by it.
    The dangerous failure turns out to be a wrong picture, which no
    containment strategy would have caught.

§16 marks phase 0 done and adds the half it deliberately left unbuilt to
phase 1: the animation path has no validator, and the patched client's wolf
decoded something else in silence to prove it.

Full measurements and the rig recipe live in servuo-plugins
`tools/scaffolding/README.md`; the code is RunicGateway/servuo-plugins#27.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 02:59:56 -05:00

44 KiB
Raw Blame History

Asset Bridge (Protocol 8) — client assets without UOFiddler

Call it the Asset Bridge. That is the name for this work everywhere — commits, PR titles, branches (feat/asset-bridge-p<n>), and conversation. The protocol number is 8 and this file is docs/link/v8.md.

Status: Design of record, phase 0 complete. Approved in principle 2026-09-09 (architecture, asset scope, built-in cliloc decoder, atlas cleanup); refined 2026-09-10 (one direction not five, ServUO's own decoders, the UOP reader for player bodies, the libgdiplus requirement). The phase 0 spike ran on 2026-09-10 and its findings are §4.5-§4.7 -- §4's decision stands, and the response to a malformed record is now measured rather than proposed. Nothing in §17 is open. Supersedes the manual half of: ../website/UOFIDDLER.md, ../website/CLILOCS.md §Converting, ../website/SPAWN_ATLAS.md §Artwork and §Configuring the tree.

Two features on this platform read data that only exists inside a UO client, and today both reach the site by hand: the operator installs UOFiddler, converts Cliloc.enu on their own desktop, exports sprites one at a time from a GUI, hand-writes a slug→filename JSON map, and copies the result to the server. A third — the spawn atlas — avoids UOFiddler but pays a different price: the website must be able to read the shard's ServUO tree directly, over a bind mount or a shared volume.

This protocol deletes all three arrangements. The shard already has everything, and the bridge already goes to the website.


1. The premise, which turns out to be free

A ServUO shard cannot boot without a UO client installation. It reads maps, statics, tiledata and multis out of .mul/.uop files, and Config/DataPath.cfg is where an operator declares where those live — required on Linux, auto-detected from the registry on Windows. At runtime the resolved directories sit in Server.Core.DataDirectories, a public static the plugin can read on any shard, with no new configuration and nothing for an operator to set up.

So the files the operator has been converting on their desktop are already on the shard host, in a directory the shard already knows the path of, in a process the bridge already runs inside.

Everything below follows from that.

1.1 What was measured, not assumed

Against this machine's ServUO 57.4 tree (C:\Users\colby\Desktop\ServUO) and client (D:\Games\Electronic Arts\Ultima Online Classic, 3.5 GB), loading ServUO's own Ultima.dll — the assembly overlay/Scripts/Scripts.csproj:39 already carries a <ProjectReference> to:

Call Result
Art.GetStatic(0…16383) 16,384 decoded, 0 errors
Art.GetStatic(16384…65535) 32,766 decoded, 1 empty, 16,385 clean out-of-range errors
Art.GetLand(0…16383) 16,384 decoded, 0 errors
Animations.GetAnimation(0…2047, 0, 1) 1,144 bodies with a decodable first frame, 904 empty, 0 errors
Hues.GetHue(33) loads
Bitmap.Save(…, Png) 852-byte PNG from one creature frame
Gumps.GetGump(2) hard crashAccessViolationException, process exit 0xC0000005
new StringList("enu", "Cliloc.enu") throws — Non-negative number required

Two of those rows are load-bearing and are dealt with in §4 and §9. The rest say the same thing: most of the extraction this protocol needs is already implemented, already compiled, and already referenced by the plugin's own build.

Depth, for §11's sizing: body 400 (human male) has 35 actions × 5 directions = 1,050 frames. One body. §5.1 cuts that by exactly 5×.


2. Architecture: the shard extracts, the sidecar forwards, the website decides

UO client files (operator's own, on the shard host)
   │  read by the plugin, off the Core thread
   ▼
ServUO shard (servuo-plugins/)     ← decodes; resolves body ids; hashes
   │  loopback JSON, request/reply, one batch outstanding at a time
   ▼
uo-link sidecar (link/)            ← forwards bytes; decides nothing
   │  REST, bearer-token auth, X-UOLink-Version: 8
   ▼
website (module-uo/)               ← stores, names, gates, serves

This is deliberately the only arrangement that keeps the bridge's standing rules intact:

  • The sidecar stays a dumb forwarder. It moves opaque assets and decides nothing about them — no audience, no projection, no capability advertisement. Putting the decoders in Rust would have meant the sidecar deciding what an asset is, on top of re-deriving in Rust what is already compiled next door in C#.
  • Access control stays on the website, which has the auth machinery and the admin forms.
  • The shard is still never network-reachable. Nothing here opens a port; the plugin answers requests on the connection it already dialled out on.

2.1 Why not the sidecar, and why not the operator's desktop

A Rust extractor in the sidecar would need ports of: the Mythic cliloc decompressor, FileIndex (including UOP), the ARGB1555 run-length frame decoder, Body.def/Bodyconv.def translation, Hues.mul, and a PNG encoder — weeks of work to re-derive what §1.1 shows already runs. It also cannot do §8: resolving a creature slug to a body id requires being inside ServUO.

Automating on the operator's desktop (shipping the converter with the installer) removes UOFiddler but keeps a manual step and still cannot do §8. It was considered and rejected.


3. The transport, and the three traps in it

3.1 Assets go over the request/reply path, never the event path

link/sidecar/src/app.rs:122 persists every non-pong event into the SQLite store and broadcasts it to every WebSocket subscriber. An asset stream on that path would grow the sidecar's store without bound and fan megabytes out to every connected client, forever.

rpc.rs's try_route consumes a correlated reply and continues before either of those happens. So an asset batch is a reply, not an event. This is not a new mechanism — it is the one char.request, account.roster and vendor.snapshot already use.

3.2 One batch outstanding, always

BridgeLink.Emit() enqueues onto a bounded drop-oldest queue (Bridge.QueueCap, default 10,000). It counts lines, not bytes — a design that is correct for live events and dangerous for bulk transfer, because 10,000 queued 200 KB replies is 2 GB of shard memory.

The rule that makes this safe is flow control, not a bigger queue: the website requests batch n+1 only after batch n has arrived. Queue depth stays at approximately one. A dropped or lost reply simply times out and the batch is re-requested, which is safe because reading a client file is idempotent and has no world side effects.

3.3 The size ceilings are already fixed, and one of them is missing

Limit Value Where
Sidecar waits for a shard reply 10 s rpc.rs REPLY_TIMEOUT
Website waits for the sidecar 12 s module-uo/server/utils/uoLinkClient.js TIMEOUT_MS
Sidecar → shard line 1 MiB BridgeLink.cs:283
Shard → sidecar line none shard.rs uses read_line unbounded

The first two bound a batch: it must decode, encode, serialise and cross the wire inside ten seconds. The last is a gap this protocol must close — an unbounded read_line facing a component that is now deliberately sending large lines is a memory-exhaustion shape we would be inventing ourselves. Protocol 8 adds an explicit inbound line cap to the sidecar, set above the largest legal batch and rejecting rather than buffering past it.

Batches are therefore sized by bytes, not by count, with the emitter cutting a batch short when it would exceed the cap. Base64 costs 33%; the budget must be stated in encoded bytes.


4. The decoders are ServUO's own — decided, and the crash is narrower than it looked

We call ServUO's vendored Ultima (decided 2026-09-10). No decoders are reimplemented. overlay/Scripts/Scripts.csproj:39 already references the project, so the art half of this protocol costs plumbing rather than pixel code, and only §9's cliloc decompressor is written from scratch.

The reason that is safe, rather than merely cheap, is a distinction §1.1 did not draw at first.

4.1 The crash lives on one code path, and nothing we call uses it

Gumps.GetGump(2) does not fail — it corrupts the process: AccessViolationException, exit 0xC0000005. That is a corrupted-state exception, uncatchable by an ordinary try/catch on .NET Framework 4.8, so in-process on a live shard it is a crash with players on it. That much is alarming, and on its own it looked like an argument against using this library at all.

It is not, because of how the three decoders construct their FileIndex:

Decoder UOP file hasExtra Probed
Art artLegacyMUL.uop false 49,150 statics + 16,384 land tiles, 0 faults
Animations none — legacy anim*.mul only 1,144 bodies, 0 faults
Gumps gumpartLegacyMUL.uop true faults on the second id

FileIndex.cs's own comment says the extra-field handling exists for gumpartlegacy.uop — it is the one UOP layout carrying an extra field, and hasExtra: true is the branch written to cope with it. Gumps is the only caller that sets it. So the fault is not a general fragility in this library's unsafe code; it is a bug on a branch that exactly one decoder reaches, and that decoder is already out of scope (§11).

The rule this turns into is a safety rule, not a preference: nothing in this protocol calls Ultima.Gumps. Adding gump art later means fixing or replacing that path first, deliberately, not discovering it in production.

4.2 What the decision accepts

Three costs come with it, all known and none of them blocking:

  1. Six of the twelve stock player-character bodies have no art on this path — both human ghosts and every gargoyle body (§5.2). Animations never reads AnimationFrame*.uop. This one is not merely accepted: §4.3 adds a decoder for it, because it is the player character and the scope says player models.
  2. System.Drawing is a hard dependency, in the decode and not just the encode. Frame writes ARGB1555 straight through a LockBits pointer, so a Linux shard needs libgdiplus to read a sprite at all. That is a stated prerequisite — §4.4.
  3. We inherit whatever Ultima a given ServUO vendors, which can change under a shard upgrade. EXTRACTOR_VERSION (§7) is the mitigation: it already counts as drift, so a shard whose library changed re-derives on the next import.

The residual risk that remains is a patched or custom client tripping an out-of-bounds read on a path we do call. §16's phase 0 is where that gets exercised rather than assumed.

4.3 One decoder we do write: UOP animation, for the player bodies

The six missing bodies are the player character, and the scope is player models, so they get a decoder rather than a caveat. It is deliberately the narrowest possible addition: a reader for AnimationFrame*.uop, used only for bodies the legacy path cannot resolve. Everything the vendored code can already decode keeps going through the vendored code.

This client ships AnimationFrame1/2/3/4/6.uop (107, 118, 253, 115 and 24 MB) plus AnimationSequence.uop. ServUO's FileIndex already contains a UOP reader — but Animations never constructs one, and the UOP animation payload is its own format rather than a repackaged mul record, so wiring the existing FileIndex in is not sufficient. Two license-compatible references exist to work from: ClassicUO's animation loader (GPL-3, and we are GPL-3-or-later) and UOFiddler 4.22 (Beerware, already established in §9).

The trap this must not fall into, and it is why the phase exists at all. Bodyconv.def maps gargoyle 666 to anim5, and BodyConverter.Convert faithfully returns fileType 5 — where the client has nothing. Asking the other anim files for index 666 does not fail. It returns 175 decodable action/direction combinations of a giant spider, because something unrelated occupies that index in anim2.mul; fileTypes 3 and 4 return misaligned colour fragments. Rendered and confirmed.

So the extractor takes BodyConverter.Convert's answer and, if that yields nothing, reports nothing. It must never sweep file types looking for a hit — that does not find missing art, it silently puts a spider on the gargoyle page, and no error is raised anywhere. A "0 rows" outcome is correct behaviour; a plausible wrong picture is the failure this protocol most needs to avoid, because nothing downstream can detect it.

4.4 Requirement: a Linux shard host needs libgdiplus

Stated prerequisite, not a soft recommendation. ServUO targets net48, so on Linux it runs under Mono, and Mono's System.Drawing is a thin layer over libgdiplus. §4.2 put System.Drawing in the decode path, so without that library a Linux shard cannot extract art at all — the cliloc table (§9) and the atlas files (§10) are unaffected, since neither touches pixels.

Windows shard hosts need nothing. System.Drawing ships with .NET Framework. This is a Linux-only prerequisite and most shards will never read this section.

Host Get it with
Debian / Ubuntu sudo apt-get install libgdiplus — in Debian since bullseye (6.0.4) and bookworm/trixie (6.1), and in Ubuntu universe
Fedora / RHEL sudo dnf install libgdiplus (EPEL or the Mono repository)
Docker RUN apt-get update && apt-get install -y libgdiplus in the shard image
Alpine, or a distro with no package Build from source — see the repository below. This is the awkward case and is worth avoiding by choosing a Debian-based image

Upstream is https://github.com/mono/libgdiplus, with the Mono project's own page at https://www.mono-project.com/docs/gui/libgdiplus/.

One thing to know before depending on it: that repository was archived in March 2025 and is read-only. Distributions still package and patch it, so apt-get install libgdiplus is a normal, supported thing to do today — but upstream is not maintained. It is the strongest long-term argument for eventually moving extraction off System.Drawing, and phase 4's UOP reader (§4.3) is written without it precisely so that door stays open. It does not change the decision now.

How its absence must present. Never a stack trace and never a 500. Missing libgdiplus is a named, actionable outcome in the same family as the cliloc reader's COMPRESSED:

status: unavailable
code:   NO_IMAGING
reason: This shard host cannot render images — Mono's System.Drawing needs libgdiplus.
        Install it (apt-get install libgdiplus) and re-run the import. Cliloc and
        atlas import are unaffected.

The installer's doctor checks for it and reports it alongside its other host checks, so an operator learns about this while setting the shard up rather than from an empty bestiary weeks later.


4.5 What phase 0 measured, and the rule it produced

Phase 0 ran §4's decoders from inside a live ServUO 57.4 against a client broken in 21 catalogued ways (servuo-plugins/tools/scaffolding/README.md carries the full results). §4's decision stands — nothing faulted on a path this protocol calls, and §9's cliloc reader reproduced UOFiddler's 123,490-entry table byte for byte in 218 ms.

But the spike was looking for the wrong kind of failure, and found a worse one.

A malformed record does not usually throw. It renders the previous asset. LoadStatic and LoadLand decode out of m_StreamBuffer, which is reused across calls, only ever grown, and filled by a stream.Read whose return value is discarded. So a record that is short, absent or out of bounds produces a real bitmap of whatever was decoded last — reported as success by every count in the library and undetectable by anything downstream.

On the stock, unmodified client on this machine that is 22,102 ids: 9,962 statics and 12,140 land tiles whose index entry reads lookup 0, length 0. FileIndex.Seek rejects lookup < 0 and length < 0, and zero is neither, so it treats an empty slot as a hit. A bulk import that trusted the library would have written 22,102 duplicate images into the site under ids that have no art. §1.1's "32,766 decoded" was counting these.

This is specific to the UOP path (see §4.6), because artidx.mul stores -1 for an absent record where an unmapped UOP slot is a zeroed struct.

So the rule, and it is the deliverable phase 1 inherits:

Validate before calling. The extractor judges an index entry — and, for statics, the record header and row table behind it — before handing the id to Ultima. A record that fails is reported as absent, never decoded.

The checks, all of which phase 0 implemented and measured as BridgeAssetValidator:

Check The shape it stops
lookup >= 0, length > 0 the 22,102 empty slots above
lookup + length <= <data file>.Length Seek checks that a record starts inside the file and never that it ends inside it; a short read then decodes the previous asset
the same bound against verdata.mul for a patched entry Verdata.Seek is bounds-checked nowhere at all
land records are at least 2,024 bytes LoadLand reads exactly that many whatever the length says
declared width and height within a ceiling LoadStatic allocates new Bitmap(width, height) from two bytes in the file — phase 0 got a ~128 MB allocation out of an edit, and the same field can ask for 8 GB
walk the row table and every run, bounded LoadStatic's two guards bound the write into the bitmap and nothing bounds the read out of the record

Measured against the patched client, this refused all eight record-level defects, seven of which the library rendered without raising anything. Measured against the stock client it refused nothing across 49,151 statics and 16,384 land tiles. That second number is the one that makes the boundary defensible: a checker that refuses real art would be worse than no checker.

Two more ways an id with no art yields a picture, both of which the extractor must handle itself: Art.GetStatic(id, false) throws IndexOutOfRangeException above the index's own ceiling (16,385 ids in a full sweep), and Art.GetStatic(id) with the default checkmaxid: true is worse — GetLegalItemID maps an out-of-range id to 0 and returns item 0's picture. Take the ceiling from the index that was opened, and pass checkmaxid: false so an overrun is loud.

The animation path has none of this yet, and phase 0 proved it needs it: the patched client's verdata entry for body 34 points past verdata.mul's end and the wolf still "decoded", counted among the 1,144 successes while rendering something else. GetAnimation additionally allocates new int[frameCount] straight from a file-supplied int. Extending the validator to animations is phase 1 work, not a phase 9 tidy-up.

4.6 The UOP wins outright, and art.mul is never opened

FileIndex's UOP constructor ends with a bare MulPath = uopPath. When artLegacyMUL.uop is present it wins, and art.mul / artidx.mul are not opened at all. Every current client ships the UOP, so this is the normal case and not an edge one.

It matters twice.

For us: an index entry's lookup is an offset into whichever file FileIndex resolved, so any bound taken against art.mul while the index holds UOP offsets is not approximate — it is meaningless. Phase 0's first run refused 34,299 perfectly good statics for "declaring 10533x2085" on exactly that mistake, and every one of those refusals read like a real finding. The extractor must resolve the data file with FileIndex's own precedence, not by name.

For operators: a shard that adds custom art to art.mul while the UOP is still in place gets nothing, silently. Worth a line in the admin surface's diagnostics (§14) rather than leaving an operator to discover it as "my art did not import".

4.7 The gump crash, reproduced where it counts

§4.1 argued from source that the access violation lives on the hasExtra: true branch only Gumps reaches. Phase 0 called Ultima.Gumps.GetGump(2) once, from inside a running shard: the ServUO process disappeared. No exception line, no catch reached, no shutdown, nothing in the console — the probe's checkpoint file, written before the call, was the entire record of what happened.

AccessViolationException is a corrupted-state exception that .NET Framework 4.8 does not deliver to ordinary handlers, so on a live shard this is a crash with players on it and there is no in-process defence. §4.1's "nothing in this protocol calls Ultima.Gumps" is therefore an earned safety rule rather than a scoping preference, and adding gump art later means fixing that path first, deliberately.


5. Addressing: one key for every asset

Every asset the bridge can serve is named by a single string key, and the key is the cache key, the hash key, the filename stem and the manifest row id:

static/3922                     one item graphic
static/3922/h33                 the same graphic, hue 33 applied
land/3                          one land tile
body/34/a0                      creature body 34, action 0, first frame
body/400/a0/f0..f9              human male, action 0, all ten frames
cliloc/enu                      the whole converted string table (not an image)
tree/Spawns/Trammel.xml         a ServUO tree file (§10)

Three properties this shape buys:

  • Hue is part of the key, not a transform. itemId and hue are already on the wire together (BridgeMarket.cs:582, BridgeProfile.cs:314), so a marketplace listing already knows the exact key for its own picture. Applying hues website-side would mean shipping Hues.mul semantics into Node for no gain.
  • Depth is expressible without being mandatory. body/400/a0 and body/400/a0/f0..f9 are the same addressing scheme at two depths, which is what lets §11 bulk-import thumbnails and fetch full animations on demand without a second protocol.
  • Nothing in the key is client-version-specific, so a client patch changes an asset's bytes, not its name — which is what makes §7's delta work.

5.1 There is no direction segment, because only one direction is wanted

Bodies are stored in five directions and the client mirrors three of them to reach eight. Only one is needed here, so direction is fixed by the extractor and is not part of the key. Leaving it in would advertise a choice nobody is going to vary and would five-fold every count in §11 for nothing.

Which one depends on whether the body is a player character:

Body Direction Why
A player character body 0 — head-on, facing the viewer A character is a portrait; it should look at you
Everything else 1 — front three-quarter The view that actually reads as a creature (see the caveat below)

Which index is which was determined by rendering all five for a human, a wolf and a dragon rather than from a table, because the answer is not obvious and the small-thumbnail version of the same test suggested the exact opposite:

Index View
0 Head-on, facing the viewer — face, chest and front legs visible
1 Front three-quarter
2 Full side profile
3 Rear three-quarter
4 Directly away — back of the head, and a quadruped's tail toward the camera

The caveat the render made obvious is what produced the split: index 0 is the least legible view for four-legged and long-bodied creatures. A wolf seen head-on is a dark blob; the same wolf at index 1 is unmistakably a wolf, which is also why UOFiddler's own thumbnail list picks that view. A humanoid has no such problem — it reads fine head-on, and head-on is what a character portrait wants.

Both indices stay configuration values (defaulting to 0 and 1), so changing the catalogue's mind later is a setting and a re-import, not a protocol change.

5.2 "Player character body" is asked of the shard, never hardcoded

Server.Race.AllRaces gives every registered race, and each carries MaleBody, FemaleBody, MaleGhostBody and FemaleGhostBody. The plugin enumerates those four ids per race and that set — nothing else — takes index 0. On stock ServUO 57.4 that is twelve ids:

Race Male Female Male ghost Female ghost
Human 400 401 402 403
Elf 605 606 607 608
Gargoyle 666 667 695 694

This is the §8 argument again in miniature: only code inside ServUO can answer it, and asking is the only thing that works on a shard with a custom race. Two details make the case that a hardcoded list would have been wrong — RaceDefinitions.cs passes the gargoyle's ghost bodies in the opposite order to the other two races (695 male, 694 female), and a shard that calls RegisterRace adds ids no table of ours would contain.

Half of that set does not decode with ServUO's vendored library. Measured:

Decodes Does not
Human male/female (400, 401) Human ghosts (402, 403)
Elf male/female (605, 606) Every gargoyle body (666, 667, 694, 695)
Elf ghosts (607, 608)

Six of twelve, including a whole playable race. These are UOP-only and §4.3 adds a decoder for them — verified genuinely absent from the legacy files rather than merely mis-addressed, by rendering what the other anim files hold at those indices and finding spiders.

Until that phase lands, and on any client that lacks them entirely, the catalogue must not treat a missing player body as an error. It is the expected answer for half the set, and a status screen that flags six failures on every import teaches an operator to ignore it. shard_spawn_creatures.art staying NULL remains a first-class state everywhere it is consumed, which it already is.


6. The manifest, and what the two buttons actually do

Two stages, which is where Import and Update come from.

Stage 1 — the source gate. The shard reports a manifest of the client files themselves: size, mtime and content hash of Cliloc.enu, anim*.idx/anim*.mul, art.mul/artidx.mul, Body.def, Bodyconv.def, Hues.mul. Unchanged since the last import, and nothing else happens. This is the same hash gate the spawn atlas and the cliloc table already use, and for the same reason: the normal case is a restart that changed nothing, and it must cost nothing.

anim.mul is 195 MB and art.mul is 148 MB, so the gate is (size, mtime) first, content hash only when those differ — a full hash of 343 MB on every status poll would make the admin panel feel broken.

Stage 2 — the asset manifest. For the working set (§11), the shard streams [{ key, sha256, bytes }] — no pixels. The website diffs that against what it holds and requests only the keys whose hash changed.

  • Update = stage 1, then stage 2, then fetch the diff.
  • Import = the same path with the diff skipped and every key fetched.
  • A key that has vanished from the manifest is staged for review, never applied silently — the same rule, and the same reasoning, as a vanished cliloc source or a disappearing atlas facet. An unmounted volume and a deliberate client downgrade look identical from here.

Clilocs are the exception and stay a whole-table replace whenever the file hash changes: the measured cost is 663 ms for 67,496 rows, so per-entry deltas would be complexity bought for nothing.


7. The parser version applies here too

spawnAtlasSource.js carries PARSER_VERSION (currently 5) and the cliloc source carries its own, both counted as drift so that a corrected parse reaches an install whose files never change. The asset pipeline inherits the rule and needs it more, not less: a fixed hue application or a corrected frame offset changes the bytes we derive from files that are byte-identical.

EXTRACTOR_VERSION lives in the plugin, because the plugin is what derives the bytes, and it is folded into stage 1's gate. Bumping it makes every asset drift, which is correct.


8. Body ids: the part only the shard can do

The atlas knows creatures by slug, derived from type names in Spawns/*.xml. The client knows them by body id. Nothing in the ServUO tree declares the mapping as data — today an operator bridges it by grepping Scripts/Mobiles/Normal/<Name>.cs for Body =, which appears variously as a decimal, as hex (0xD1), as Utility.RandomList(35, 36), and as an m_IDs[] table.

Inside ServUO the problem does not exist. BridgeWorld.cs:350 already does exactly the required thing for a different feature:

var type = ScriptCompiler.FindTypeByName(name, true);
var creature = Activator.CreateInstance(type) as BaseCreature;

Construct, read creature.Body.BodyID, Delete(). Authoritative, no source parsing, and correct for custom creatures a grep would never find.

This pass must run on the Core thread — it constructs and deletes mobiles, which is world mutation — while the decode in §4 must run off it. That split is the one genuinely new threading shape in this protocol, and it is why slug→body resolution is its own request kind with its own (small) batch size rather than a step inside asset extraction.

Constructing arbitrary creature types has side effects: constructors pack items, set skills, start timers. The mitigations are per-type try/catch, immediate Delete(), small batches, and the fact that the whole pass is admin-triggered rather than something that runs at boot.


9. The cliloc decompressor is ours now

Every modern client ships Cliloc.* in the Mythic compressed container — this machine's Cliloc.enu is 4,989,921 bytes beginning E8 79 67 8E, high byte 0x8E. ServUO's bundled Ultima.StringList implements only the plain layout and throws on it (§1.1), which is also why the shard's own VendorSearch.GetItemName is already inert.

UOFiddler is released under the Beerware licence, so porting its decompressor into our GPL-3.0-or-later tree is clean. It lands in the overlay as ordinary C# — the only decoder Protocol 8 writes rather than calls (§4) — and from that point:

  • No operator installs UOFiddler.
  • No operator runs dotnet build on a converter.
  • No operator copies a 5 MB file to a server.
  • website/server/tools/cliloc-export/ is retired, and UOFIDDLER.md is deleted rather than rewritten.

What survives untouched is the custom/ overlay mechanism. Shard-added items carry cliloc ids no client table has, and ServUO has no server-side notion of a custom cliloc — that is a real gap in the game, not an artefact of the manual pipeline, and CLILOCS.md's reasoning for it stands. The base table now arrives over the bridge; overlays still come from a directory the site reads. Measured on the live shard: 16,434 cliloc ids referenced by the script tree, 37 absent from stock.


10. The atlas stops needing a shared filesystem

Today SPAWN_ATLAS.md requires the website to read the ServUO tree — "same host, a bind mount, or a shared volume". That is the one place the platform's own rule (only the sidecar bridges the shard) is broken, and it is broken by the component that faces the internet.

The same transport closes it. spawnAtlasSource.js already labels every file it reads with a portable key:

Label Count (stock 57.4)
Data/Regions.xml 1
Data/Locations/*.xml 6
Spawns/*.xml 13, ~10.5 MB
Config/ChampionSpawns.xml 1
Data/Decoration/** tree

So the shard serves tree/<label> → bytes over the same batched request/reply path, and spawnAtlasSource.js gains a second backend behind its existing interface: filesystem (today, kept for same-host installs and for development) or sidecar (new, and the default once configured).

The parsers do not move. spawnAtlasParse.js is pure, fs-free and CI-covered without a ServUO tree, 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. That is the same division as §2, and it keeps the sidecar a forwarder here too.

SERVUO_PATH and the spawn_atlas_servuo_path setting remain, and select the filesystem backend.


11. What is bulk and what is on demand

The scope approved is creature art, item art, player models "and everything", against a future project. §1.1's measurements make the sizing question concrete:

Kind Addressable Bulk?
Item statics ~49,150 No — on demand, cached, keyed by itemId (+ hue)
Land tiles 16,384 No — on demand
Creature/player bodies, first frame 1,144 Yes — this is the catalogue
One body, every action, one direction 210 frames (body 400); 96210 measured across six bodies No — on demand, per body
All bodies, every action, one direction ~173,000 frames, ~170 MB No — but no longer unthinkable
The same at five directions ~865,000 frames Not built (§5.1)
Cliloc table 123,490 entries → 67,496 rows Yes — whole-table replace
ServUO tree files (§10) ~21 files, ~10.6 MB Yes

The working set is one thumbnail per body, plus the atlas's own creatures. 1,144 sprites at roughly a kilobyte each is under 2 MB — trivial to import, trivial to re-hash, and it is the set that makes a bestiary, a marketplace listing and a character sheet render.

Everything deeper is the same protocol at a deeper key (§5), fetched on demand and cached. That is what serves the future project without exporting 3.5 GB of someone else's copyrighted client into a database: a viewer that wants body 400's full walk cycle asks for body/400/a2/f0..f9 and gets it, once, and it is cached from then on.

Because §5.1 dropped four of the five directions, a complete one-direction animation set for every body is now ~173,000 frames rather than ~865,000 — around 170 MB. That is still not the default and still not something to import before anything asks for it, but it has moved from "never" to "a thing an operator could reasonably choose", and phase 6 should leave room for a bulk-fill-everything switch rather than assuming on-demand is the only mode.

Hued variants are on demand, always. static/3922/h33 is generated when something on the wire actually carries hue 33. The cross product of 49,150 statics and ~3,000 hues is not a set anyone enumerates.

Gump art is out of scope for Protocol 8, and that is now a safety rule rather than a priority call — §4.1. It is the only decoder that reaches the hasExtra: true branch, and that branch corrupts the process on the second id. Adding gump art later means fixing that path first, deliberately; it is additive under the same key scheme (gump/<id>), which is the point of §5.


12. Where it lands on the website

Images are written by module-uo into the upload directory. ctx.uploads ({ upload, UPLOAD_DIR, MIME_EXT }) is already exposed to modules and MODULE_API.md:626 already names its consumer as "atlas art import", so no MODULE_API_VERSION bump is needed to store them.

  • shard_spawn_creatures.art stops being NULL-by-default and starts being filled by the import.
  • A new asset table carries key, sha256, bytes, width, height, imported_at — the manifest side of §6, and what makes an Update a diff rather than a re-download.
  • The operator-supplied spawnAtlas.art.json map stays supported and continues to win over an imported asset. An operator who has drawn their own creature portraits must not have them overwritten by a sprite rip on the next Update.

Licensing is unchanged and the reasoning is unchanged: these are the operator's own client files, extracted on their own host, for their own shard. Nothing is committed, nothing ships in a repo, and nothing is redistributed. What changes is only that the extraction stopped requiring a GUI on a desktop.


13. Visibility

New surfaces over shard data are admin-toggleable with an operator-set audience, and this is no exception. Asset serving is gated like every other shard read: a requireFeature gate, an audience, and 404-not-403 when the feature is off, so a disabled feature does not advertise itself.

The default is the least surprising one: assets are as public as the page that uses them. A bestiary that is already anonymous does not become staff-only because its pictures arrived over a new pipe.


14. Routes and commands added

Loopback (shard ↔ sidecar), all request/reply:

Command Reply Purpose
assets.sources assets.sources.ok Stage 1: client file manifest + EXTRACTOR_VERSION
assets.manifest assets.manifest.ok Stage 2: [{key, sha256, bytes}], paged
assets.fetch assets.fetch.ok Content for an explicit key list, paged
assets.bodies assets.bodies.ok Slug → body id (§8, Core thread)
cliloc.table cliloc.table.ok The converted table, paged
tree.manifest / tree.fetch .ok §10, the ServUO tree files

Sidecar REST mirrors those one for one under /assets/*, /cliloc, /tree/*, carrying X-UOLink-Version: 8 and forwarding verbatim.

Website admin (Admin → Shard, admin-only): status, Import, Update, approve/reject for a vanished key, and the existing path settings. Every action to the admin activity log, as shard.assets.*.


15. Cross-repo obligations

PROTOCOL_VERSION goes 7 → 8 in link/sidecar/src/main.rs, and in the same PR servuo-plugins/overlay.toml — the installer refuses to pair a sidecar and an overlay that disagree, so a split bump means the next bundle silently fails to compose.

Repo Work
servuo-plugins/ Extraction over ServUO's own Ultima (§4), the cliloc decompressor (§9), body resolution (§8), the request handlers, overlay.toml
link/ Six command families forwarded, the REST surface, the inbound line cap (§3.3), PROTOCOL_VERSION
module-uo/ Client calls, asset store, the atlas source backend (§10), cliloc ingest, admin surface
website/ None expected — ctx.uploads already suffices (§12)
docs/ This file; rewrite CLILOCS.md §Converting and SPAWN_ATLAS.md §Artwork + §Configuring; delete UOFIDDLER.md; add the libgdiplus prerequisite to SHARD_PREREQS.md (§4.4)
installer/ A doctor check for libgdiplus on Linux hosts (§4.4). Bundle pairing already enforces §15
android-app/ Consumes images by URL; no parity gate expected until a screen shows one
integration-kit/ A chapter note only — this is UO-specific and teaches nothing about the module contract

16. Phases

# Scope Repos
0 DONE 2026-09-10. Spike: the vendored decoders driven from inside a running ServUO, over a client broken 21 ways. §4 stands; the finding was 22,102 wrong pictures on a stock client, and the validator that answers them (§4.5-§4.7) servuo-plugins
1 The transport: assets.sources, flow control, the sidecar line cap, EXTRACTOR_VERSION, protocol bump. Plus §4.5's validator promoted into the overlay, extended to animations — phase 0 left that half unbuilt and proved it is needed servuo-plugins, link
2 Clilocs end to end; retire the converter and UOFIDDLER.md §Part 1 all
3 Body resolution (§8) + the 1,144-body catalogue; shard_spawn_creatures.art filled servuo-plugins, module-uo
4 The UOP animation decoder (§4.3) — the six player bodies the legacy path cannot reach, and the never-sweep-file-types rule enforced in code servuo-plugins
5 Item statics and land on demand, hued keys, the cache servuo-plugins, module-uo
6 Deep animation keys (body/<id>/a<n>/f<n>) for the future project, plus the bulk-fill switch servuo-plugins, module-uo
7 The atlas over the sidecar (§10); shared-filesystem requirement retired module-uo
8 Admin surface, Import/Update, approve/reject, activity log module-uo
9 Docs pass across five repos; live walk on the real rig docs

Phase 0 exists because §4 chose to call code that can take the shard down if it is wrong, and the honest way to hold that choice is to try to break it on purpose — in the real host process, against a client that has been patched — before building eight phases on top of it. The probes behind §1.1 were run from PowerShell against a stock client; neither of those is the environment this will actually run in.

Phase 4 sits after the catalogue rather than inside it on purpose. The catalogue is useful with 1,138 of 1,144 bodies, the UOP reader is the one piece of genuinely new format work in this protocol, and putting it on the critical path would hold up every website-side phase behind it. Its acceptance test is not "it decodes" — it is that a gargoyle looks like a gargoyle, checked by eye, because §4.3's whole point is that this failure mode produces confident, wrong pictures.


17. Decisions

Every item here is settled. Each is recorded because it changes numbers or obligations elsewhere in the document.

  1. §4: settled 2026-09-10 — call ServUO's vendored Ultima, with one exception added the same day: §4.3's UOP animation decoder, scoped to the player bodies the legacy path cannot reach and to nothing else (phase 4). The crash is confined to the hasExtra: true branch that only Gumps reaches, and nothing here calls Gumps. Phase 0 confirmed both halves in the real host process — nothing faulted on a path we call, and one GetGump(2) killed the shard outright (§4.7).
  2. §4.4: settled 2026-09-10 — libgdiplus is a stated requirement on Linux shard hosts, with all three answers taken rather than one: it goes in SHARD_PREREQS.md, the installer's doctor checks it, and its absence degrades to a named NO_IMAGING status instead of an error. Windows hosts are unaffected.
  3. §5.1/§5.2: direction — settled 2026-09-10. Player character bodies use index 0, everything else index 1, direction is not in the key, and the player-body set is enumerated from Race.AllRaces rather than hardcoded. Recorded here because it changes every count in §11. Phase 0 reproduced the twelve stock ids and the six that do not decode, exactly.
  4. §13: the default audience — settled 2026-09-10. An asset inherits the audience of the page that uses it. A bestiary that is already anonymous keeps anonymous pictures; a staff-only screen's art is staff-only. The operator can still set the policy explicitly, and the requireFeature gate with its 404-not-403 behaviour is unchanged. The alternative — one flat audience for all asset serving — was rejected because it necessarily disagrees with some page that uses it, in one direction or the other, and the disagreement is silent either way.
  5. §4.5: the response to a malformed record — settled 2026-09-10, and now measured. Validate before calling. The extractor judges an index entry, and for statics the record behind it, before handing the id to Ultima; a record that fails is reported absent and never decoded. Chosen ahead of phase 0 over two alternatives — extracting in a child process (much stronger containment, a much larger change to §2 and phase 1) and reversing §4 to write our own decoders (weeks, per §2.1). Phase 0 then found the shape that settles it: the dangerous failure is not a crash a child process would contain, it is a wrong picture that no containment strategy would have caught, on 22,102 ids of a stock client. See §4.5 for the checks and the false-refusal measurement that says the boundary is in the right place.