Files
wtclaude c89e818dbf feat(asset-bridge): the transport, and the 357 wrong pictures it found
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
2026-09-10 08:32:18 -05:00
..

Test scaffolding

Not part of the bridge. Never deployed. deploy.ps1 only copies overlay/, so nothing here reaches a server unless you put it there by hand.

These two scripts produced the measured budget in PLAN.md §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.

File Server path when testing What
BridgeSeeder.cs Scripts/Custom/BridgeSeeder.cs Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each.
BridgeProbe.cs Scripts/Custom/BridgeProbe.cs Times every read the plugin performs, on the Core thread. Read-only.
BridgeEventProbe.cs Scripts/Custom/BridgeEventProbe.cs Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. Mutates the world and saves. Flag: EventProbeOnStart.
BridgeSweepProbe.cs Scripts/Custom/BridgeSweepProbe.cs Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: SweepProbeOnStart. Pair with short *SweepSeconds overrides.
BridgeLinkProbe.cs Scripts/Custom/BridgeLinkProbe.cs Triggers [link for seed_001 without a client, then saves so the WebsiteUserId tag reaches accounts.xml. Flag: LinkProbeOnStart. Pair with a sidecar that reads the code and sends link.confirm.
BridgeCrierProbe.cs Scripts/Custom/BridgeCrierProbe.cs Logs the global town-crier entry list every 3s so towncrier.add / remove can be seen landing in game state. Flag: CrierProbeOnStart.
BridgeVendorSaleProbe.cs Scripts/Custom/BridgeVendorSaleProbe.cs Fires PlayerVendorSale (Phase 7) with real seeded-vendor data so vendor.sale can be verified without a live buy. Requires the Phase 7 patches applied. Flag: VendorSaleProbeOnStart.
BridgeDemoDress.cs Scripts/Custom/BridgeDemoDress.cs Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on seed_000 so a character can be logged in. Flags: DemoDressOnStart, DemoDressPassword. In game: [demodress.
BridgeRigDriver.cs Scripts/Custom/BridgeRigDriver.cs Drives the shard from OUTSIDE the game, one verb per line in Config/rigcmd.txt, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (Scripts/Misc/ConsoleCommands.cs), so [p5probe cannot be typed at a headless shard at all. Verbs: decaylist, decay, vendorlist, vendorfunds, citylist, governor, election, activate, password, configset, configread, partprobe, assetprobe, save, shutdown. Flag: RigDriverEnabled. configset exists because Config.Get is written by exactly ONE caller in the whole of ServUO 57.4 (Server/ScriptCompiler.cs): no in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately, and a lease's compare-and-set restore would have no way to be proved. configread reads a key back through Config.Get long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. Sets passwords, writes live config and mutates the world.
BridgeProtocol5Probe.cs Scripts/Custom/BridgeProtocol5Probe.cs Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- estimatedCollapse must appear only on the IDOC frame), reports each player vendor's fee state straight off the PlayerVendor so the emitted fees block can be checked against the shard's own numbers, and fires EventSink.AccountLogin. Flags: Protocol5ProbeOnStart, Protocol5ProbeAccount, Protocol5ProbePassword. In game: [p5probe. Sets a password on the named account.
BridgeProtocol6Probe.cs Scripts/Custom/BridgeProtocol6Probe.cs Spawns a real champion boss through the shard's own SpawnChampion(), waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so champ.boss.killed can be observed with a real damage table. The wait is the assertion: without it the kill still emits, but with no serial/type/level, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: Protocol6ProbeOnStart. In game: [p6probe. Spawns and kills a champion boss; rig only. Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar.
BridgeParticipationProbe.cs Scripts/Custom/BridgeParticipationProbe.cs Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. Presence is the half it cannot drive -- the sweep credits players with a live NetState, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: [partprobe <map> <x> <y>; from a headless rig, through BridgeRigDriver's partprobe verb (the two ship together for that reason). Moves players and spawns and kills a creature; rig only.
BridgeAssetProbe.cs Scripts/Custom/BridgeAssetProbe.cs Asset Bridge phase 0 (docs/link/v8.md §16). Drives ServUO's vendored Ultima decoders from inside a running shard against a deliberately patched client, and compares every answer with what a pre-flight validator says about the index entry before the call. The interesting column is not the error count, it is WRONG PICTURES -- records the validator rejects and the library renders anyway. Sweeps statics, land, all 2,048 bodies, the player-character bodies from Race.AllRaces, and the ported Mythic cliloc reader against UOFiddler's own output. In game / from BridgeRigDriver: `[assetprobe [section] [stock
BridgeMythicCliloc.cs Scripts/Custom/BridgeMythicCliloc.cs The §9 reader for the Mythic compressed cliloc container -- the one decoder Protocol 8 writes rather than calls. Ported from UOFiddler (Beerware) into net48 C# with every file-derived index bounds-checked, which upstream's blanket catch does not do. Reproduces UOFiddler's 123,490-entry table exactly. Phase 2 promotes this file into overlay/; it is scaffolding only for as long as it is a spike.

Deploy overwrites Bridge.cfg

deploy.ps1 copies overlay/Config/Bridge.cfg, which deliberately omits the scaffolding flags. So every deploy strips SeedOnStart / EventProbeOnStart / etc. Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)

Using them

Copy both into Scripts/Custom/, then append the flags to Config/Bridge.cfg:

SeedOnStart=True
CensusOnStart=False
ProbeOnStart=False

Boot once to seed and save, then set SeedOnStart=False. CensusOnStart reports what the loaded world actually contains; ProbeOnStart prints timings two seconds after ServerStarted.

Because Config.Get returns false for a missing key, a server whose Bridge.cfg lacks these keys never runs the scaffolding — even if the .cs files are sitting in Scripts/Custom/. That is the safety net, not an excuse to ship them.

In-game, [seedworld and [unseedworld (Administrator) do the same work on a live shard.

Dressing a seeded world for screenshots

BridgeSeeder builds a world at realistic scale, which is all the bridge ever needed. It does not build one that looks like anything: a vendor is seed vendor trading as Seed Shop 810, a character is Seed004A, a house sign says Seed House 12. Those strings travel the whole bridge and land on the marketplace, the guild roster and the housing pages of the website — fine for a protocol test, wrong for a screenshot.

BridgeDemoDress.cs renames them in place. It seeds nothing: prices, listing counts, decay stages, fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the data keeps its provenance and only the strings a human reads change. Names are drawn from fixed tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house names are re-dressed when they are names the pass itself produced — so a change to the tables can be applied to a world that has already been through here.

DemoDressOnStart=True
DemoDressPassword=<a password you choose>

Boot once, then set DemoDressOnStart=False. The password is written to seed_000 so a real client can log a character in — the only way to make the website's online roster non-empty — and it is read from the config rather than compiled in, so it never lands in source control.

It dresses seeded objects only, which means your own characters keep their names. That is the right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev world usually also holds the accounts, characters, guilds and houses of whoever built it, and those are real identifiers on a page that may end up public.

The sidecar's board is cached, so the website lags a rename. A shop name reaches the site on the next market sweep, and a sweep advances MarketSweepBatch vendors per tick — 27 vendors at the defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a debugging detour once: the shard had the new names all along and the sidecar was still serving the previous ones.

Back up Saves/ first

[seedworld and SeedOnStart write to the live world. Copy Saves/ somewhere outside the repo before running either. Backups/Automatic is rotated by AutoSave.cs and Backups/Temp is deleted outright, so neither is a safe destination.

[unseedworld deletes every seed_* account, which takes their characters and houses with it — but not necessarily their PlayerVendor mobiles. Restoring a backup is the reliable reset.

What the seeder had to work around

Worth knowing before you trust its output:

  • Plate needs strength. BaseArmor.CanEquip rejects when from.Str < strReq (PlateChest needs 95). A rejected EquipItem leaves the item parentless, and the Cleanup pass later deletes it en masse. The seeder gives characters Str 100125 and deletes any item whose equip is refused, rather than orphaning it.
  • VendorItem.Price is get-only and PlayerVendor.SetVendorItem is private. Dropping an item into a vendor's pack fires OnSubItemAdded, which registers the item at the default price of 999. The seeder reaches SetVendorItem by reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.
  • Houses only decay when condemned. BaseHouse.CanDecay is true only for DecayType.Condemned or ManualRefresh. An active owner's newest house is AutoRefresh and never decays. The seeder backdates 18 accounts past Account.InactiveDuration (180 days) to condemn them, then forces stages with SetDynamicDecay — not by backdating LastRefreshed, because DynamicDecay.Enabled is true on this expansion and GetOldDecayLevel is unreachable.

Reference output

Census after a fresh load of the seeded world:

[BridgeSeeder] houses=35
[BridgeSeeder]   decay Ageless 13, Slightly 3, Somewhat 7, Fairly 3, Greatly 3, IDOC 6
[BridgeSeeder] seeded chars=150 avgEquipped=8.00 naked=0
[BridgeSeeder] playervendors=30

Probe, best-of-20 on the Core thread:

[BridgeProbe] char.profile      0.069 ms/char     2386 bytes json
[BridgeProbe] vitals sweep      0.223 ms  for 150 chars   (0.0015 ms/char)
[BridgeProbe] decay sweep       0.007 ms  for 35 houses   (0.0002 ms/house)
[BridgeProbe] economy sweep     0.001 ms  for 51 accounts (supply 110,478,209 gold)
[BridgeProbe] vendor snap       0.343 ms  for 30 vendors  (1200 listings)

Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a floor — budget 24× for a fully-kitted character.

The login half needs a socket, not the sink

BridgeProtocol5Probe fires EventSink.InvokeAccountLogin directly, which proves the REJECTED half of account.login.result and nothing more. ServUO's own AccountHandler calls acct.HasAccess(e.State) before it ever checks the password, and a null NetState fails that -- so an in-process probe logs Access denied for a correct password too, and never produces an accepted:true.

To prove the accepted half, speak the wire. A real socket also gives the frame a real ip, which is one of the fields being tested:

# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
s = socket.create_connection(('127.0.0.1', 2593))
s.sendall(b'\x7f\x00\x00\x01')
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')

The shard logs Invalid password for '<acct>' or Valid credentials for '<acct>', and the sidecar's /history?kind=account.login.result should show accepted:false reason:BadPass and accepted:true respectively. Both saying accepted:true is the bug the kind exists to prevent -- it means the verdict was read inside the handler, before it existed.

Walking a house into IDOC needs a house that can decay

Only a Condemned or ManualRefresh house decays. An AutoRefresh one -- and the owner's NEWEST house is always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and reports Ageless, so a forced SetDynamicDecay is wiped on the very next read, the sweep sees no change, and nothing is emitted at all. That looks exactly like a broken emitter. Filter on house.CanDecay, and expect a seeded world to have only one or two houses that qualify -- both probably already at IDOC, so the walk has to put one back down first.

A decaying house cannot be refreshed — only its owner coming back rescues it

BaseHouse.RefreshDecay() returns false immediately when DecayType == Condemned, and on a seeded world every house that can decay is Condemned — the seeder backdates 18 accounts past Account.InactiveDuration precisely to make them decay. So SetDynamicDecay(DecayLevel.LikeNew) is wiped by the next read and RefreshDecay() does nothing: the sweep sees no change and emits nothing, which looks exactly like a broken emitter for the second time on the same page.

The rescue is the OWNER LOGGING IN (BridgeRigDriver's activate <account> reproduces it by setting LastLogin). What the shard then reports depends on how many houses that owner has:

the house DecayType after the login DecayLevel reads
their newest AutoRefresh Ageless — off the decay clock entirely
any older one ManualRefresh LikeNew — back on the clock, at the top

Both are "out of danger", and the newest-house case is the common one. A consumer that watches only for LikeNew misses most rescues — which is what the engagement mapper did until this walk.

The console takes a fixed verb set, so [commands cannot be typed at a headless shard

Scripts/Misc/ConsoleCommands.cs handles save, shutdown, restart, online, kick and a handful more; it does not dispatch arbitrary [commands. Every other probe here therefore runs either at boot or from an in-game client, and neither works for a walk driven from a script. That is what BridgeRigDriver and its rigcmd.txt are for.

Also: only a CLEAN shutdown emits. Stop-Process drops the socket and the shard says nothing, so a killed shard is indistinguishable from a wedged one and server.shutdown never reaches the sidecar — use the driver's shutdown verb (Core.Kill) when the shutdown itself is what is being tested.

The innermost region has no name

BridgeProtocol6Probe places its altar in the middle of Britain rather than at a dungeon altar, and that is not cosmetic. An active ChampionSpawn registers a ChampionSpawnRegion over its own spawn area, constructed with a null name and with the town region as its parent -- so the most specific region containing a champion boss is the one region on the map guaranteed to be nameless. Mobile.Region then hides that by falling back to the map's unnamed default region rather than to null, and the emitted frame simply has no region.

Region registration is also deferred, which is what makes this survive a first look: a lookup taken immediately after the altar is placed answers "Britain", and one taken at the kill twenty seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the value that lies, printed next to a frame that disagrees with it.

Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately absent and the probe proves nothing about it.

What phase 0 found

BridgeAssetProbe exists because v8.md §4 chose to call ServUO's vendored Ultima rather than reimplement it, and the evidence for that choice was a PowerShell probe against a stock client — neither the process nor the client the extractor will actually run in. These are its results, from inside a running ServUO 57.4 against this machine's client, and against a copy broken in 21 catalogued ways by tools/patch_client.ps1.

The UOP wins outright, and it took a whole run to notice

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

  • A validator that bounds an index offset against art.mul while the index holds UOP offsets is not approximate, it is nonsense. The first run of this probe refused 34,299 perfectly good statics for "declaring 10533x2085" — and every one of those refusals looked like a real finding. BridgeAssetValidator.ArtDataPath() now mirrors FileIndex's own resolution order, and phase 1 must too.
  • A custom-art shard that adds graphics to art.mul while the UOP is still in place gets nothing, silently. That is an operator trap rather than a bug in this protocol, but the extractor is where it will be noticed.
  • The corrupt and customart tiers of patch_client.ps1 therefore need its nouop tier to mean anything at all. Without it they report that they applied, and change nothing.

22,102 wrong pictures on a stock, unmodified client

The counts that matter, assetprobe all stock:

statics 0..65535   ok 39,189   WRONG PICTURES (empty record)  9,962    threw 16,385
land    0..16383   ok  4,244   WRONG PICTURES (empty record) 12,140

Those 22,102 ids have an index entry of lookup 0, length 0no record at all. FileIndex.Seek treats that as a hit (it rejects lookup < 0 and length < 0, and zero is neither), hands back the stream, and LoadStatic decodes length = 0 bytes into m_StreamBuffer — which is reused, only ever grown, and filled by a stream.Read whose return value is discarded. So the id renders whatever the previously-decoded asset left in the buffer.

It is specific to the UOP path. Run the same sweep against the mul path and those ids come back empty and honest, because artidx.mul stores -1 for an absent record while unmapped UOP slots are simply zeroed structs. That is also why the earlier PowerShell probe counted 32,766 of these as "ok": they decode, they raise nothing, and no success count can tell them from art.

A bulk import that trusted the library would have written 22,102 duplicate images into the site under ids that have no art. This one measurement is the argument for validate-before-calling.

Every deliberate defect was caught by the validator and rendered by the library

assetprobe all patched, against the 21-defect client:

statics   ok 39,190   absent  9,954   refused 1   WRONG PICTURES (bad record) 6   threw 16,385
land      ok  4,243   absent 12,140               WRONG PICTURES (bad record) 1
id the defect what the library did
static/4104 lookup 4 KB past the end of art.mul returns nothing — Seek does check the record's start
static/4105 starts 64 bytes before EOF, declares 8,192 renders the previous assetSeek never checks the record's end
static/4108 declared length 4, smaller than the header renders something
static/4109 header declares 8000x8000 allocates it — a ~128 MB bitmap from two bytes in a file, and the same field can ask for 65535×65535
static/4111 row table points 60,000 words outside a 512-byte record renders — LoadStatic's two guards bound the write into the bitmap, and nothing bounds the read
static/4112 a 16-pixel run declared in a 20-byte record renders
static/4131 verdata entry whose lookup is past verdata.mul's own end renders — Verdata.Seek has no bounds check whatsoever
land/256 512-byte land record renders — LoadLand reads a fixed 2,024 bytes whatever the length says

Seven of the eight produce a confident, wrong picture and raise nothing anywhere.

The validator refused all eight, and refused nothing on the stock client across 49,151 statics and 16,384 land tiles. That second number is the one that matters: a checker that refuses real art is worse than no checker, so "zero false refusals on a clean client" is what makes validate-before-calling more than a hopeful phrase.

The eight customart ids appended past the stock ceiling all decode cleanly, which is that tier's whole point — the ceiling is a property of a file, not a constant anyone should write down.

Two more ways to get a wrong answer out of an id that has no art

  • Art.GetStatic(id, false) throws IndexOutOfRangeException for id >= 49,152 rather than returning null — 16,385 of them in a full sweep.
  • Art.GetStatic(id) with the default checkmaxid: true is worse: GetLegalItemID maps an out-of-range id to 0, so the call returns item 0's picture. An exception is recoverable; a picture of the wrong item is not even detectable.

So the extractor takes its id ceiling from the index it opened, and passes checkmaxid: false so an overrun is loud rather than plausible.

The gump crash reproduces in-process, and nothing catches it

assetprobe gump called Ultima.Gumps.GetGump(2) once. The ServUO process disappeared — no exception line in the report, no catch reached, no shutdown, nothing in the console. The report ends mid-section, and checkpoint.txt reading gump 2 is the entire record of what happened. That is exactly why the checkpoint is written before the call and flushed.

AccessViolationException is a corrupted-state exception and .NET Framework 4.8 does not deliver it to ordinary handlers, so there is no in-process defence — on a live shard this is a crash with players on it. "Nothing calls Ultima.Gumps" is a safety rule, and phase 0's job was to make sure that sentence had been earned rather than assumed. It has.

The cliloc port is byte-identical to UOFiddler

123,490 entries in 218 ms (55,986 blank, 67,504 would be stored)
vs UOFiddler: 123,490 identical, 0 differ, 0 only ours, 0 only theirs

§9 is proven: the shard can produce the whole table with no UOFiddler installed, no dotnet build, and no 5 MB file copied to a server.

The reference is what makes this a test rather than a demonstration. A subtly wrong inverse-BWT coder still produces a plausible table — mostly-right strings with a few mangled ones is the expected shape of a bug in this algorithm, and a row count alone would sail past it.

Note the blank count is 55,986, not the 55,994 recorded from the manual pipeline. The difference is eight whitespace-only entries, blank to a trim() and not to IsNullOrEmpty — a definition rather than a defect, but exactly the sort of eight-row drift that gets investigated as one.

What phase 0 did not cover, and phase 1 must

The animation path has no validator. 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, silently rendering something else, with nothing in the report to say so. GetAnimation also allocates new int[frameCount] straight from a file-supplied int. Everything above about statics applies here and none of it is implemented yet.

The deliberate Bodyconv.def mis-mappings (bodies 1900 and 1901) produced nothing rather than a wrong creature on this client, so they did not reproduce the spider. The gargoyle rows remain the real evidence for the never-sweep-file-types rule: 666, 667, 694 and 695 report nothing, and nothing is the correct answer.

Reference: the rest of the run

bodies 0..2047, direction 1   decoded 1,144   empty 904   faulted 0
  by file type: 1=1222, 2=140, 3=244, 4=150, 5=292

player bodies (Race.AllRaces, direction 0)   6 decoded, 6 absent, of 12
  Human    400 / 401 decode; ghosts 402 / 403 absent
  Elf      605 / 606 / 607 / 608 all decode
  Gargoyle 666 / 667 / 694 / 695 all absent

Two details worth keeping. The body counts reproduce the PowerShell probe exactly, from a different process against the same files, which is what makes the two runs comparable at all. And the gargoyle ghost bodies resolve to file type 1, not 5 like the living gargoyle bodies — so "the gargoyle is an anim5 problem" is not quite the shape of it.

Building the patched client

.\tools\patch_client.ps1 -Dest D:\uo-patched-client

Copies a client (~3.5 GB) and breaks the copy in five catalogued tiers — nouop, verdata, customart, corrupt, bodyconv. It never writes to the source: every file it touches is hashed in the source before and after, and a changed hash aborts the run. Each defect is recorded in patched-client.manifest.json beside the copy, which is what makes a nonzero WRONG PICTURES count readable as "the tier worked" instead of "something broke".

Then point the shard at it and drive the probe:

RigDriverEnabled=true
AssetProbeClient=D:\uo-patched-client
AssetProbeClilocRef=<a clilocs.tsv from website/server/tools/cliloc-export --tsv>
assetprobe all stock       # the baseline: the validator must refuse nothing here
assetprobe all patched     # the experiment

Run both against one boot, through rigcmd.txt, so a difference between them cannot be a difference between two shard processes. Without AssetProbeClilocRef the cliloc section reports a row count, which proves nothing about the strings.

The copy is EA's client art. It stays on the machine that made it, exactly like every other extraction in this project, and is never committed.