feat(bridge): protocol 8 — the shard reads its own client files (Asset Bridge cutover, 1 of 5) #36

Merged
whitlocktech merged 20 commits from edge into main 2026-09-14 23:09:57 +00:00
Member

Step 1 of 5 of the Asset Bridge edge → main cutover (docs/link/v8.md §16 row 9b).

Land this together with link#44

installer's bundle.yml Gate 1 refuses a sidecar/overlay pair that disagrees on protocol.
This PR moves overlay.toml to 8; link#44 moves the sidecar's PROTOCOL_VERSION to 8.
Whichever lands first leaves a compose that cannot run until the other does. Merge them in the same
window.

What comes over

Eight phases, all already merged to edge and reviewed there — twelve overlay files, +7,221
lines
, and the whole shard half of the Asset Bridge:

Phase PR What it added
0 #27 The spike, from inside a live shard against a patched client — the decoders are safe to call, and Ultima.Gumps is a rule not a preference (AccessViolationException is uncatchable on .NET Framework 4.8)
1 #28 The transport, and the 357 wrong pictures it found — the catalogue fell 1,144 → 787
2 #29 The cliloc table, Mythic container decompressed on the shard — 67,496 names, no manual conversion step
3 #30 The body catalogue and slug → body id; the atlas's art column filled and rendered
4 #31 The UOP animation reader and an own PNG encoder — 787 → 1,022 bodies, written without System.Drawing so the libgdiplus door stays open
5 #32 Item statics and land art on demand, hued on the shard — 49,152 addressable, 39,189 with art
6 #33 The 73 bodies with no art at action 0, and §4.10's per-body action ceiling — without which the fallback walk serves 452 validated pictures of the next body. 1,022 → 1,095
7 #34 The spawn tree over the bridge: a 4.03 MB trammel.xml against a 1 MiB line cap, so 512 KiB gzipped chunks. 141 files / 11.9 MB → 1.33 MB on the wire in 512 ms
9a #35 The README's file table

EXTRACTOR_VERSION is 3. overlay.toml protocol 7 → 8.

The two safety rules this codifies

  • Take BodyConverter.Convert's answer; if it yields nothing, report nothing. Sweeping file
    types for a hit does not find missing art — it puts a giant spider on the gargoyle page, and
    every one of those reads reports success. Nothing downstream can detect it.
  • Nothing calls Ultima.Gumps. It is the one decoder that passes hasExtra: true, the branch
    the access violation lives on, and a corrupted-state exception has no in-process defence.

Verification

  • Every one of the 40 overlay files is byte-identical (modulo line endings) to the ServUO tree
    that booted and served phase 9a's acceptance walk — so this exact overlay is known to compile and
    run on a stock 57.4 tree. The shard was left running rather than stopped for a -Verify pass.
  • Phase 9a's walk on that shard: 1,095 portraits in 3.18 s (746/800 creatures matched),
    67,496 names in 1.42 s, 313 item pictures in 1.38 s, the atlas over the bridge in ~2 s,
    and an update with no drift answered in 0.99 s. Portraits verified by eye — panther, boar,
    troll and bears all distinct.

After the merge

Release fires on push to main; expect a minor overlay release, then bundle.yml recomposing
the bundle at protocol 8 once link's release is out too. edge stays standing.

AI disclosure

Authored with Claude Code (Claude Opus 5). Commits carry the Co-Authored-By trailer.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Step **1 of 5** of the Asset Bridge `edge → main` cutover (`docs/link/v8.md` §16 row 9b). ## Land this together with `link#44` `installer`'s `bundle.yml` **Gate 1 refuses a sidecar/overlay pair that disagrees on protocol.** This PR moves `overlay.toml` to **8**; `link#44` moves the sidecar's `PROTOCOL_VERSION` to 8. Whichever lands first leaves a compose that cannot run until the other does. Merge them in the same window. ## What comes over Eight phases, all already merged to `edge` and reviewed there — **twelve overlay files, +7,221 lines**, and the whole shard half of the Asset Bridge: | Phase | PR | What it added | |---|---|---| | 0 | #27 | The spike, from inside a live shard against a patched client — the decoders are safe to call, and `Ultima.Gumps` is a **rule** not a preference (`AccessViolationException` is uncatchable on .NET Framework 4.8) | | 1 | #28 | The transport, and the **357 wrong pictures** it found — the catalogue fell 1,144 → 787 | | 2 | #29 | The cliloc table, Mythic container decompressed **on the shard** — 67,496 names, no manual conversion step | | 3 | #30 | The body catalogue and slug → body id; the atlas's `art` column filled and rendered | | 4 | #31 | The **UOP animation reader** and an own PNG encoder — 787 → 1,022 bodies, written without `System.Drawing` so the libgdiplus door stays open | | 5 | #32 | Item statics and land art on demand, hued on the shard — 49,152 addressable, 39,189 with art | | 6 | #33 | The **73 bodies with no art at action 0**, and §4.10's per-body action ceiling — without which the fallback walk serves 452 validated pictures of the *next* body. 1,022 → **1,095** | | 7 | #34 | The spawn tree over the bridge: a 4.03 MB `trammel.xml` against a 1 MiB line cap, so 512 KiB gzipped chunks. 141 files / 11.9 MB → **1.33 MB on the wire in 512 ms** | | 9a | #35 | The README's file table | `EXTRACTOR_VERSION` is **3**. `overlay.toml` `protocol` **7 → 8**. ## The two safety rules this codifies - **Take `BodyConverter.Convert`'s answer; if it yields nothing, report nothing.** Sweeping file types for a hit does not find missing art — it puts a **giant spider** on the gargoyle page, and every one of those reads reports success. Nothing downstream can detect it. - **Nothing calls `Ultima.Gumps`.** It is the one decoder that passes `hasExtra: true`, the branch the access violation lives on, and a corrupted-state exception has no in-process defence. ## Verification - Every one of the **40 overlay files** is byte-identical (modulo line endings) to the ServUO tree that booted and served phase 9a's acceptance walk — so this exact overlay is known to compile and run on a stock 57.4 tree. The shard was left running rather than stopped for a `-Verify` pass. - Phase 9a's walk on that shard: **1,095 portraits in 3.18 s** (746/800 creatures matched), **67,496 names in 1.42 s**, **313 item pictures in 1.38 s**, the atlas over the bridge in ~2 s, and an update with no drift answered in 0.99 s. Portraits verified **by eye** — panther, boar, troll and bears all distinct. ## After the merge Release fires on push to `main`; expect a **minor** overlay release, then `bundle.yml` recomposing the bundle at protocol 8 once `link`'s release is out too. `edge` stays standing. ## AI disclosure Authored with Claude Code (Claude Opus 5). Commits carry the `Co-Authored-By` trailer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 20 commits 2026-09-14 22:37:43 +00:00
docs/link/v8.md §16 phase 0. §4 chose to CALL ServUO's vendored `Ultima`
rather than reimplement it, on the evidence of a PowerShell probe against a
stock client — neither the process nor the client the extractor will run in.
This runs the same decoders from inside a running ServUO 57.4 against a
client broken in 21 catalogued ways, and it found more than a crash.

Adds, all under tools/ and therefore never deployed:

  * BridgeAssetProbe.cs — the sweep, plus BridgeAssetValidator, a prototype
    of the validate-before-calling response chosen for §4.2's residual risk.
    Runs off the Core thread, snapshots Race.AllRaces on it, and writes the
    id it is ABOUT to touch to a checkpoint file before every call.
  * BridgeMythicCliloc.cs — the §9 Mythic cliloc reader in net48 C#, ported
    from UOFiddler (Beerware) with every file-derived index bounds-checked.
    Phase 2 promotes this into overlay/.
  * patch_client.ps1 — builds the patched client in five tiers. Hashes every
    file it touches in the SOURCE before and after and aborts on a change.
  * an `assetprobe` verb on BridgeRigDriver, so stock and patched can be run
    against one boot rather than two shard processes.

The findings are written up in tools/scaffolding/README.md. The four that
change what phase 1 has to build:

  * FileIndex's UOP constructor ends `MulPath = uopPath`, so artLegacyMUL.uop
    wins outright and art.mul/artidx.mul are never opened on a current
    client. A validator bounding offsets against art.mul is not approximate,
    it is nonsense — the first run refused 34,299 good statics on that
    mistake, and every refusal looked like a real finding.

  * 22,102 WRONG PICTURES on a stock, unmodified client. Empty UOP index
    slots read `lookup 0, length 0`; Seek treats that as a hit, and
    LoadStatic decodes zero bytes into a shared buffer it reuses, only ever
    grows, and fills from a Read whose return value is discarded — so the id
    renders the previously-decoded asset. The mul path does not do this
    (artidx stores -1), which is why the earlier probe counted 32,766 of
    them as "ok". A bulk import that trusted the library would have written
    22,102 duplicate images under ids that have no art.

  * The validator caught all 8 record-level defects — 7 of which the library
    rendered without raising anything, including a verdata lookup past
    verdata.mul's own end (Verdata.Seek is bounds-checked nowhere) and an
    8000x8000 bitmap allocated from two bytes in a file. It refused NOTHING
    across 49,151 statics and 16,384 land tiles on the stock client, which
    is the number that makes the boundary defensible.

  * §4.1's crash reproduces in-process: one Ultima.Gumps.GetGump(2) and the
    ServUO process disappeared — no catch reached, no console line, the
    checkpoint file the only record. "Nothing calls Ultima.Gumps" is now an
    earned safety rule.

§9 is proven: 123,490 entries in 218 ms, byte-identical to UOFiddler's own
output, with no UOFiddler installed and nothing copied to a server.

Not covered, and named as phase 1 work: the animation path has no validator
at all, and the patched wolf decoded something else in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #27
Asset Bridge phase 1 (docs/link/v8.md §16). Sidecar half: RunicGateway/link#41.
Docs half: RunicGateway/docs#236.

The transport for protocol 8, plus phase 0's validator promoted into the overlay
and extended to animations — which is where the interesting part is.

## 357 of the 1,144 "decodable" bodies are wrong pictures, on a STOCK client

Phase 0 measured the art path and left the animation half unbuilt. It has the same
defect, and it is worse: `GetAnimation` decodes through
`new MemoryStream(m_StreamBuffer, false)` — the whole shared buffer, not the
`length` bytes just read into it — so a truncated or absent record does not even hit
end-of-stream. It sails on into the previous animation's bytes.

Measured directly, because no count could tell:

| Decode body 320 (`lookup 22638982, length 0`) straight after… | Comes back |
|---|---|
| body 12, the dragon | the dragon, 176x167, identical hash |
| body 34, the wolf | the wolf's dimensions, 35x34 |
| body 400, the human male | the human, 27x63, identical hash |

The catalogue is **787 bodies, not 1,144**. Importing the other 357 would have written
duplicate creature portraits into the site showing whichever body the walk decoded
before them.

The record walk refused **0** real bodies on the stock client — the false-refusal
measurement §4.5 says the boundary depends on.

## And four of the twelve player bodies, not six

§5.2 listed the elf ghosts (607, 608) as decoding. Their index entry is `length 0`;
what came back was the elf female at her exact dimensions, because 606 is what the
walk decoded immediately before. Confirmed the same way — 607 after the dragon is
the dragon. Phase 4's UOP decoder now covers eight ids rather than six.

## What is here

- **`overlay/Scripts/Custom/Bridge/BridgeAssets.cs`** — the plane. Accepts on the Core
  thread, hands off to a dedicated asset worker, returns immediately. Three rules, all
  answering a specific failure:
  - **one slot**, second request answered `bridge.busy` (425). `Emit`'s queue is bounded
    in *lines*, so 10,000 queued 200 KB replies is 2 GB of shard memory; the bound that
    holds is flow control, on the side where the memory is.
  - **byte budgets** (`AssetBatchBytes`, 512 KiB) under the sidecar's new 1 MiB cap. The
    factor of two is load-bearing: a page always admits its first item, so it may
    overshoot by one, and the headroom is what makes that land on the wire.
  - **replies, never events** — no `reqId`, no answer. An uncorrelated frame is an event
    by definition, and §3.1 is why none of this may be one.
- **`PageBuilder`** — one paging envelope (`more`/`cursor`/`cut`) for all five families
  that will page, defined before the first one needs it. `cut` matters: "short page" has
  three meanings and only `end` means finished.
- **`assets.sources`** — stage 1 of the import gate, its first user.
- **`BridgeAssetValidator.cs`** — promoted from `tools/`, plus `ResolveAnimation` (the
  never-sweep-file-types rule as code, with no loop and no fallback),
  `AnimationRecordSane` and the frame walk.
- **`EXTRACTOR_VERSION`**, **`overlay.toml` protocol 7 → 8**, `AssetsEnabled`.

## Hashing had to come off the request path

§6's gate is (size, mtime) first, hash only when those differ. The first call has nothing
cached, so that still means hashing 1.06 GB — inside the sidecar's 10 s reply timeout it
does not fit. So hashes are computed on their own thread (deliberately not the single-slot
worker, which would answer every status poll `bridge.busy` for the whole pass) and the
reply carries `hashing`/`complete`.

Measured on the real rig: first call instant with `sha256: null`, second call **44 ms**
with every hash present.

## Verified on the wire, not just compiled

Real ServUO 57.4 + the real sidecar + the real client. `GET /assets/sources` → 200,
`X-UOLink-Version: 8`, `imaging: {ok: true}`, and §4.6's diagnostic firing on a live
client: `artDataFile: artlegacymul.uop`, with `art.mul` and `artidx.mul` both carrying
`shadowedBy`. Live events kept flowing through the new capped reader with no warnings.

Not exercised live: the disabled-plane 403 and the busy 425 (both unit-tested on the
sidecar side; the shard halves are a config read and a lock).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #28
The shard reads its own client's `Cliloc.enu` and serves it over the bridge, so
the operator stops installing UOFiddler, building a converter against its
`Ultima.dll`, and copying a 5 MB file to the web host every time they patch.

`BridgeCliloc.cs` is the one decoder protocol 8 writes rather than calls
(docs/link/v8.md §4, §9): a port of UOFiddler's `MythicDecompress` +
`MoveToFront` — Beerware, so clean to bring into a GPL-3.0-or-later tree —
rewritten against plain arrays, because the upstream is `Span<T>` /
`ArrayPool<T>` / `BinaryPrimitives` code and ServUO targets `net48`.

The algorithm is deliberately unchanged, including the parts that read oddly.
The three-region count/cursor/end table and the symbol-table shifts are
upstream's, because a tidier rewrite of somebody else's format decoder is a
chance to be subtly wrong in a way that produces plausible text. Two bounds
checks were added and they are the only behavioural difference: the upstream
indexes its payload unchecked, which is safe for a file the client wrote and is
not safe for a file this shard was handed.

Measured on a stock client: 4,989,921 bytes read, decompressed and parsed in
**290 ms**, yielding **67,496** non-blank rows in id order. That number is the
acceptance test — it is what UOFiddler's own DLL produced from this same client
through the converter this phase deletes, so an independent implementation
agrees to the row. Zero U+FFFD; the 696 non-ASCII rows carry correct curly
quotes; the longest row is a 12,149-character EULA, which is why the record
length is read unsigned.

Blanks never reach the wire — ~56,000 of the 123,490 entries are empty strings
the client reserves, and the website discards them at import anyway.

Also on this plane:

  * `assets.error` gains a `code`. Phase 1 chose between 403 and 400 by looking
    for the word "disabled" in an operator-facing sentence, which makes prose
    load-bearing; `DISABLED` / `NOT_FOUND` / `UNREADABLE` / `UNAVAILABLE` /
    `BAD_REQUEST` say it directly.
  * `Accept` and `Fail` are internal rather than private, because the asset
    plane's single slot and its refusal frame are shared by every family on it.

The cursor is a cliloc NUMBER, not an offset: the decoded table is cached for
five idle minutes and released after the last page, so it can be dropped and
rebuilt between two pages of one import, and an index would then silently mean
something else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
`PageBuilder` keeps 256 bytes back for the envelope it still has to write, so a
field appended after `Close()` is spent outside that reserve. It fits today by a
wide margin — the largest measured page was 524,086 of a 524,288 budget, and the
budget is half the line cap on purpose — but "nothing is written after Close()"
is the invariant worth having, because the next family to page will copy this.

No behaviour change; the field moves earlier in the same object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #29
Two request families, and they run on opposite threads on purpose.

`assets.bodies` (BridgeBodies) answers the question only code inside ServUO
can: the atlas knows a creature by the class name in Spawns/*.xml, the client
knows it by a body id, and nothing in the tree declares the mapping. Construct
the type, read Body.BodyID, Delete(). That is world mutation, so it answers on
the CORE thread and is the one family here that does not take the asset
worker's slot -- and the batch is capped at 100 names, REFUSED rather than
truncated, because a truncated answer is indistinguishable from a complete one
from the website's side.

`assets.manifest` / `assets.fetch` (BridgeCatalog) are the catalogue, on the
worker. The manifest carries { key, sha256, bytes, width, height } and no
pixels, so an Update fetches only what moved; the fetch carries base64 PNG.
The scan keeps the bytes it hashed rather than decoding all 787 sprites twice.

Three things worth stating about the shapes:

- It pages on the WALL CLOCK as well as on bytes. The rows are ~90 bytes and
  the whole catalogue is one page by the byte budget, but building it means
  decoding hundreds of sprites against a 10 s reply timeout.
- `catalog` is derived from the client files (sizes, mtimes, both direction
  settings, EXTRACTOR_VERSION), not minted per build -- the cache is released
  when idle, and a fresh id per build would force a restart mid-import although
  nothing about the client moved.
- ARGB1555 is expanded to 32bpp here rather than handed to GDI+, because what
  it does with a one-bit alpha channel varies by platform and a black rectangle
  behind every sprite would pass any test that only checked the bytes decoded.

Nothing trusts the library's success. Every body goes through CheckEntry and
AnimationRecordSane before it is decoded, which is what keeps the 357 bodies
whose index entry reads `length 0` -- and which the decoder hands back the
PREVIOUS creature's bitmap for -- out of the catalogue.

Walked on a live shard: 787 rows in one 734 ms page; bodies 320, 607, 666 all
absent rather than wrong; 783 at direction 1 and 4 at direction 0; all 455 stock
creature classes resolved at ~190 ms per 100 with zero mobiles leaked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #30
ServUO's vendored `Ultima.Animations` reads legacy `anim*.mul` only -- it builds its
five FileIndexes with the constructor that passes `uopFile: null` -- so everything a
modern client moved into `AnimationFrame*.uop` is invisible to it. This adds the one
reader docs/link/v8.md 4.3 reserved for phase 4, and wires it in as a fallback beneath
the legacy path.

What it actually recovers is not what the plan expected, and the difference was
measured before any of this was written:

  - Of the EIGHT player-character bodies 4.8 assigned to this phase, only TWO are in
    the client at all: gargoyles 666 and 667, in AnimationFrame3.uop. The six ghost
    bodies (human 402/403, elf 607/608, gargoyle 694/695) are in no package. The five
    packages hold 10,724 entries between them and the
    `build/animationlegacyframe/%06d/%02d.bin` name scheme claims every one, so there
    is no other naming they could be hiding under.
  - The same fallback reaches 233 further bodies the catalogue had nothing for, so the
    working set goes from 787 to 1,022 (57 Monster, 26 Animal, 97 Equipment, 50
    unlisted, 3 Human, 2 Sea). The catalogue was already 366 Equipment bodies before
    this, so its character does not change.

Decided with the org lead before building: the fallback applies to every body rather
than to player bodies alone; ghost ids leave the player-body set entirely (no client
has art for any of them, and listing them only advertised keys that cannot exist); the
UOP path gets its own PNG encoder rather than Bitmap.Save; and a host without
libgdiplus keeps the flat NO_IMAGING refusal rather than serving a partial catalogue.

Three things about the reader:

  - It is not the never-sweep rule being broken. That rule exists because a legacy
    index is addressed by POSITION, so asking the wrong file returns a giant spider
    that decodes cleanly. A UOP entry is addressed by the hash of a name containing the
    body id, and the payload declares that id again for `Group.TryOpen` to check, so a
    hit is proof of identity. Measured: no hash appears in two packages.
  - Validate-as-we-go replaces validate-before-calling, because here we ARE the
    library: the block chain is bounded against the file, the record against the file,
    the inflated length against the declared one, the frame table against the payload,
    and every run header against both the record's remaining bytes and the bitmap it
    writes into. Measured the way 4.5 was -- across every UOP body on a stock client it
    refuses nothing that carries art. The one body it refuses (286) declares a 0x0
    frame, which the vendored decoder treats as absent too.
  - No System.Drawing anywhere in it, which is what 4.4 promised: the decode fills a
    ushort[] of ARGB1555 and BridgePng encodes that directly (zlib around net48's
    raw-deflate-only DeflateStream, CRC32, one IDAT, filter 0).

EXTRACTOR_VERSION 1 -> 2: every client file is byte-identical and the answer is
different, which is exactly what that number exists to say. The UOP packages join
`assets.sources` and the catalogue id, so patching one is drift; `Ultima.Files` cannot
resolve them (its table predates UOP animations) so BridgeUop.FindClientFile does it,
case-insensitively by enumeration for Linux hosts. Manifest and fetch rows carry a new
`source` field (`legacy` / `uop`).

Protocol stays 8 -- no message shape changed, only fields added.

Measured on the live rig (real sidecar, real ServUO, this machine's client):
1,022 rows in ONE page, 1,409 ms cold; six player bodies, all six with art for the
first time (400/401/605/606 legacy, 666/667 uop), all at direction 0; 1,016 at
direction 1; the six ghost bodies absent; 45 duplicate-hash groups of which exactly one
is new, bodies 1531/1532, two distinct records whose first frames match -- legitimate,
and provable only because each payload declares its own body id. The gargoyles were
rendered and looked at, because 4.3's whole point is that this failure mode produces
confident, wrong pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #31
The body catalogue is a set; this is not. This client addresses 49,152 static
ids and has art for 39,189 of them, plus 4,244 land tiles of 16,384 -- and hues
multiply the statics by three thousand. So there is no manifest and no scan:
`assets.fetch` grows two more families (`static`, `land`) and answers the keys
the website's own data names.

`assets.fetch` becomes shared plumbing. BridgeAssets now owns the command, does
the reqId/consent/key-ceiling checks once, derives the family from the keys
themselves (§5 made the key the address; a request that also named its family
would have two places to be wrong and one of them silent) and dispatches to the
reader that registered it. A batch must be of one family, because the reply
carries one `catalog` id. `assets.sources` gains `families` -- additive, so the
protocol stays 8, and EXTRACTOR_VERSION stays 2 because no existing key's bytes
change.

Two traps, both in §4.5's family -- a confident, plausible, wrong picture:

- `Art.GetStatic` memoises into a static Bitmap[0xFFFF] and hands back the SAME
  instance, while `Hue.ApplyTo` repaints in place. Hue a static once and the
  plain key comes back hued from then on, and the next hue stacks on the last.
  Measured on this client before the fix. `Files.CacheData` is now off for the
  life of the process; `TryHue` re-checks it and refuses rather than risk it,
  and the same flag decides whether a bitmap is ours to dispose.

- `PartialHue` decides whether a hue repaints every pixel or only the grey ones,
  per item id, out of `tiledata.mul` -- 13,259 of 65,536 ids on this client.
  Item 597 is a wooden screen with painted flowers: one mode reddens the
  flowers, the other the whole screen. Both decode. The first cut of this reader
  bound `TileData` to ServUO's OWN `Server.TileData` (the enclosing namespace
  beats `using Ultima;`, and it has a PartialHue flag too), which compiled and
  refused every hued key at runtime. Every such type is spelled `Ultima.` now.

Land takes no hue segment: the mode that decides how is an item flag and land
has no equivalent, so `land/3/h33` is refused rather than guessed. `h0` is not a
key either -- hue 0 means "not hued", and the plain key already names it.

Measured through the reader over the whole range: 39,189 statics and 4,244 land
tiles served, and the only refusals are the 9,963 + 12,140 empty index slots
§4.5 predicted. Nothing that carries art is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #32
The catalogue asked every body for action 0 and reported the rest absent. 73 of
this client's bodies have no art there and real art deeper — body 820's first
drawn action is 23, and it is a horse — so they rendered as text on the bestiary.
The catalogue now falls back to the first action that has art, and the key names
that action (`body/820/a23`). 1,022 -> 1,095 rows.

Walking the action axis is the one thing that can walk off the end of a body's
slots, and the slots after a body's band are the NEXT BODY'S. Measured here: one
action past the band, 643 of 795 legacy bodies return a fully validated picture
and 452 of those are byte-identical to body+1's action 0 (body 1 action 22 is an
ettin; body 3's is an imp, both confirmed by rendering them). Phase 0's validator
cannot catch that — the record is real — so the ceiling refuses the ADDRESS, in
ResolveAnimation where every caller already goes.

The ceiling is the index banding, never `Animations.GetAnimLength`: for a body
reaching file type 5 as id 34 that function answers 22 while the arithmetic gives
13, and the difference is nine actions of another creature's art.

A fetch serves only the key the catalogue chose for that body. `body/820/a0` and
`body/400/a2` come back `unsupported` with the chosen action alongside, never by
decoding what was asked for.

`EXTRACTOR_VERSION` 2 -> 3 (unchanged input, a different answer). Protocol stays
8 — `action` on a manifest/fetch row is additive.

Deep frame keys and the bulk-fill switch that §16 planned for this phase were
NOT built: the site displays still pictures, and a complete one-direction
animation set measures 174,453 frames / 281.5 MB against no consumer (docs
§11.2, org lead 2026-09-11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #33
The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Phase 7 found this on the tree family and fixed it there. It was inline in THREE
places: the body catalogue (phase 3), statics and land (phase 5), and the tree.
`expected != null` treats "" as a real fingerprint, so a caller that serialises a
missing value as an empty string has EVERY fetch refused -- with a sentence that
names no catalog at all ("catalog  is now 8159778b"), which reads as a shard
fault rather than a caller one.

All three now go through one BridgeAssets.CatalogMismatch. Three copies of a
comparison are three chances for the next family to get it wrong in a way only a
differently-written client would ever reveal.

BridgeLeases keeps its own `expected != null` and is deliberately untouched:
there the value is a world property, where an empty string is a legitimate thing
to expect.

Verified against a live shard on a stock ServUO install, every family asked three
ways -- with a real catalog, with the field absent, and with an empty string:

  cliloc.table walk                     67,496 rows, 12 pages
  body manifest / fetch                 1,095 rows; ok all three ways
  static + land fetch                   ok all three ways
  static/land carry their OWN catalog   art 66a112c1 vs body 323f284f
  a cross-family catalog                refused 422
  tree manifest / fetch                 141 files incl. BOTH empty ones, all three ways
  empty files carry a VALID gzip member 2 rows gunzip to 0 bytes
  a STALE catalog                       still refused on body, static and tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #34
The file table in this README stopped at Phase 6's town crier -- ten rows for a
directory that now holds 38 files, stale across four workstreams. Filling all of
it is not this phase's job; documenting the nine files this workstream added is,
and the table now says plainly what it covers so a reader does not take it for an
inventory.

The rows carry the reasoning worth having at a glance: the validator is the
boundary that turned 22,102 confident wrong pictures into honest absences, the
catalogue's action ceiling is what stops the fallback walk serving the next
body's art, `BridgeBodies` is the one question no code outside ServUO can answer,
and `BridgeUop`/`BridgePng` are written without `System.Drawing` on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #35
whitlocktech approved these changes 2026-09-14 23:09:44 +00:00
whitlocktech merged commit 59a6c446c6 into main 2026-09-14 23:09:57 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#36
No description provided.