feat(asset-bridge): the UOP animation reader, and 235 bodies the legacy path cannot see (Phase 4) #31

Merged
whitlocktech merged 1 commits from feat/asset-bridge-p4 into edge 2026-09-11 10:09:55 +00:00
Member

Phase 4 of the Asset Bridge (docs/link/v8.md §4.3, §4.9). ServUO's vendored Ultima.Animations builds its five FileIndexes with the constructor that passes uopFile: null, so it never opens AnimationFrame*.uop — everything a modern client moved there is invisible to it. This adds the one decoder §4.3 reserved for this phase and wires it in beneath the legacy path, as a fallback.

What it recovers is not what the plan expected

Measured before anything was written:

Gargoyle 666, 667 legacy index position is past the end of anim5.idx; the art is in AnimationFrame3.uop — recovered
Human ghosts 402, 403 lookup -1 — and in no UOP package
Elf ghosts 607, 608 length 0 (the §4.8 wrong-picture shape) — and in no UOP package
Gargoyle ghosts 694, 695 lookup -1 — and in no UOP package

The five packages hold 10,724 entries and the build/animationlegacyframe/%06d/%02d.bin name scheme claims all of them, so there is no other naming ghost art could hide under. Phase 4 recovers 2 of the 8 bodies §4.8 assigned it; six have no art in this client at all.

Offsetting that: the same fallback reaches 233 further bodies the catalogue had nothing for. The working set goes 787 → 1,022.

Org-lead decisions taken before building

  1. The fallback applies to every body, not to player bodies alone (787 → 1,022 rather than 787 → 789).
  2. Ghost ids leave the player-body set. No client has art for any of them; listing them only advertised keys that cannot exist. A shard whose client does ship ghost art still gets it, at direction 1 like any other body.
  3. The UOP path gets its own PNG encoder, not Bitmap.Save — §4.4 promised this reader is written without System.Drawing.
  4. A host without libgdiplus keeps the flat NO_IMAGING refusal, rather than serving a partial catalogue.

Three things about the reader

  • This is not the never-sweep rule being broken. That rule (§4.3) exists because a legacy index is addressed by position, so asking the wrong file for index 666 returns 175 decodable frames of a giant spider. 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 — 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 0×0 frame, which the vendored decoder treats as absent too.
  • No System.Drawing in it. The decode fills a ushort[] of ARGB1555 and BridgePng encodes that directly — zlib written by hand around net48's raw-deflate-only DeflateStream, CRC32, one IDAT, filter 0. Phase 3's ToPng is untouched: its input is a Bitmap from a decoder that needs GDI+ to exist, so encoding it without GDI+ buys nothing.

Wire and versioning

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 rather than a silent no-op; Ultima.Files cannot resolve them (its table predates UOP animations), so BridgeUop.FindClientFile does, case-insensitively by enumeration for Linux hosts. Manifest and fetch rows carry a new additive source field (legacy / uop).

Protocol stays 8 — no message shape changed, only fields added. No module-uo or link change is needed: extractorVersion already reads as drift there, and playerBodies is passed through.

Measured on the live rig

Real sidecar, real ServUO, this machine's client:

Catalogue 1,022 rows in ONE page, 1,409 ms cold (787 legacy + 235 uop)
What the UOP added 57 Monster, 26 Animal, 97 Equipment, 50 unlisted, 3 Human, 2 Sea
Player bodies six, and all six have art for the first time — 400/401/605/606 legacy, 666/667 uop — all at direction 0
Direction split 6 at direction 0, 1,016 at direction 1
The six ghost bodies absent
Duplicate-hash scan 45 groups, exactly one 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. A gargoyle looks like a gargoyle.


  • AI-assisted: written with Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Phase 4 of the Asset Bridge (`docs/link/v8.md` §4.3, §4.9). ServUO's vendored `Ultima.Animations` builds its five `FileIndex`es with the constructor that passes `uopFile: null`, so it never opens `AnimationFrame*.uop` — everything a modern client moved there is invisible to it. This adds the one decoder §4.3 reserved for this phase and wires it in **beneath** the legacy path, as a fallback. ## What it recovers is not what the plan expected Measured before anything was written: | | | |---|---| | Gargoyle **666, 667** | legacy index position is past the end of `anim5.idx`; the art is in **`AnimationFrame3.uop`** — recovered | | Human ghosts **402, 403** | `lookup -1` — and **in no UOP package** | | Elf ghosts **607, 608** | `length 0` (the §4.8 wrong-picture shape) — and **in no UOP package** | | Gargoyle ghosts **694, 695** | `lookup -1` — and **in no UOP package** | The five packages hold **10,724 entries** and the `build/animationlegacyframe/%06d/%02d.bin` name scheme claims **all** of them, so there is no other naming ghost art could hide under. **Phase 4 recovers 2 of the 8 bodies §4.8 assigned it**; six have no art in this client at all. Offsetting that: the same fallback reaches **233 further bodies** the catalogue had nothing for. The working set goes **787 → 1,022**. ## Org-lead decisions taken before building 1. **The fallback applies to every body**, not to player bodies alone (787 → 1,022 rather than 787 → 789). 2. **Ghost ids leave the player-body set.** No client has art for any of them; listing them only advertised keys that cannot exist. A shard whose client *does* ship ghost art still gets it, at direction 1 like any other body. 3. **The UOP path gets its own PNG encoder**, not `Bitmap.Save` — §4.4 promised this reader is written without `System.Drawing`. 4. **A host without libgdiplus keeps the flat `NO_IMAGING` refusal**, rather than serving a partial catalogue. ## Three things about the reader - **This is not the never-sweep rule being broken.** That rule (§4.3) exists because a legacy index is addressed **by position**, so asking the wrong file for index 666 returns 175 decodable frames of a giant spider. 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 — 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 0×0 frame, which the vendored decoder treats as absent too. - **No `System.Drawing` in it.** The decode fills a `ushort[]` of ARGB1555 and `BridgePng` encodes that directly — zlib written by hand around net48's raw-deflate-only `DeflateStream`, CRC32, one IDAT, filter 0. Phase 3's `ToPng` is untouched: its input is a `Bitmap` from a decoder that needs GDI+ to exist, so encoding it without GDI+ buys nothing. ## Wire and versioning `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 rather than a silent no-op; `Ultima.Files` cannot resolve them (its table predates UOP animations), so `BridgeUop.FindClientFile` does, case-insensitively **by enumeration** for Linux hosts. Manifest and fetch rows carry a new additive `source` field (`legacy` / `uop`). **Protocol stays 8** — no message shape changed, only fields added. No `module-uo` or `link` change is needed: `extractorVersion` already reads as drift there, and `playerBodies` is passed through. ## Measured on the live rig Real sidecar, real ServUO, this machine's client: | | | |---|---| | Catalogue | **1,022 rows in ONE page, 1,409 ms cold** (787 `legacy` + 235 `uop`) | | What the UOP added | 57 Monster, 26 Animal, 97 Equipment, 50 unlisted, 3 Human, 2 Sea | | Player bodies | **six, and all six have art for the first time** — 400/401/605/606 `legacy`, **666/667 `uop`** — all at direction 0 | | Direction split | 6 at direction 0, 1,016 at direction 1 | | The six ghost bodies | `absent` | | Duplicate-hash scan | 45 groups, exactly **one** 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. A gargoyle looks like a gargoyle. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-11 09:55:57 +00:00
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
whitlocktech merged commit 452be696df into edge 2026-09-11 10:09:55 +00:00
whitlocktech deleted branch feat/asset-bridge-p4 2026-09-11 10:09:56 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

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