feat(asset-bridge): the cliloc table, decompressed on the shard (Phase 2) #29

Merged
whitlocktech merged 2 commits from feat/asset-bridge-p2 into edge 2026-09-10 16:19:50 +00:00
Member

Phase 2 of the Asset Bridge (docs/link/v8.md §9), shard half. Part of a five-repo change: link#42, module-uo, website, docs.

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 their client.

What this adds

BridgeCliloc.cs — the one decoder protocol 8 writes rather than calls (§4): 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 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.

cliloc.table rides phase 1's rails unchanged — the single slot, the byte-budget page builder, the more/cursor/cut envelope. The cursor is a cliloc number, not an offset, because the decoded table is cached for five idle minutes and released after the last page: it can be dropped and rebuilt between two pages of one import, and an index would then silently mean something else.

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.

Measured, on a stock client

4,989,921 bytes read, decompressed and parsed in 290 ms67,496 non-blank rows in id order.

That count is the acceptance test. It is exactly what UOFiddler's own Ultima.dll produced from this same client through the converter this phase deletes — an independent implementation agreeing to the row is not something a subtly-wrong decoder produces. Alongside it: zero U+FFFD, the 696 non-ASCII rows carry correct curly quotes, and 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.

Verified live

Deployed to a real ServUO tree, booted, and driven through a real sidecar: 12 pages, every page inside the 512 KiB budget, the whole table in 1.4 s, ?lang=fra → 404, a malformed cursor → 400, and three concurrent callers → 425 while one is served.

AI disclosure

Written with Claude Code (Opus 5); commits carry the Co-Authored-By trailer.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Phase 2 of the **Asset Bridge** (`docs/link/v8.md` §9), shard half. Part of a five-repo change: link#42, module-uo, website, docs. 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 their client. ## What this adds `BridgeCliloc.cs` — the one decoder protocol 8 **writes** rather than calls (§4): 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 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. `cliloc.table` rides phase 1's rails unchanged — the single slot, the byte-budget page builder, the `more`/`cursor`/`cut` envelope. The cursor is a cliloc **number**, not an offset, because the decoded table is cached for five idle minutes and released after the last page: it can be dropped and rebuilt between two pages of one import, and an index would then silently mean something else. 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. ## Measured, on a stock client 4,989,921 bytes read, decompressed and parsed in **290 ms** → **67,496** non-blank rows in id order. That count is the acceptance test. It is exactly what **UOFiddler's own `Ultima.dll`** produced from this same client through the converter this phase deletes — an independent implementation agreeing to the row is not something a subtly-wrong decoder produces. Alongside it: zero U+FFFD, the 696 non-ASCII rows carry correct curly quotes, and 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. ## Verified live Deployed to a real ServUO tree, booted, and driven through a real sidecar: 12 pages, every page inside the 512 KiB budget, the whole table in 1.4 s, `?lang=fra` → 404, a malformed cursor → 400, and three concurrent callers → 425 while one is served. ## AI disclosure Written with Claude Code (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 1 commit 2026-09-10 16:14:48 +00:00
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
wtclaude added 1 commit 2026-09-10 16:18:08 +00:00
`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
whitlocktech merged commit c79a2a3b2b into edge 2026-09-10 16:19:50 +00:00
whitlocktech deleted branch feat/asset-bridge-p2 2026-09-10 16:19:51 +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#29
No description provided.