feat(asset-bridge): phase 0 spike — the decoders, from inside a live shard
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
This commit is contained in:
@@ -14,10 +14,12 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
|
||||
| `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`, `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.** |
|
||||
| `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|patched]`. Flag: `AssetProbeOnStart`. **Its `gump` section deliberately kills the shard** and is never part of `all`. |
|
||||
| `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
|
||||
|
||||
@@ -189,3 +191,127 @@ 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](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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 0` — **no 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 asset** — `Seek` 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
|
||||
|
||||
```powershell
|
||||
.\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:
|
||||
|
||||
```ini
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user