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
27 KiB
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.CanEquiprejects whenfrom.Str < strReq(PlateChestneeds 95). A rejectedEquipItemleaves the item parentless, and theCleanuppass later deletes it en masse. The seeder gives charactersStr100–125 and deletes any item whose equip is refused, rather than orphaning it. VendorItem.Priceis get-only andPlayerVendor.SetVendorItemis private. Dropping an item into a vendor's pack firesOnSubItemAdded, which registers the item at the default price of 999. The seeder reachesSetVendorItemby reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.- Houses only decay when condemned.
BaseHouse.CanDecayis true only forDecayType.CondemnedorManualRefresh. An active owner's newest house isAutoRefreshand never decays. The seeder backdates 18 accounts pastAccount.InactiveDuration(180 days) to condemn them, then forces stages withSetDynamicDecay— not by backdatingLastRefreshed, becauseDynamicDecay.Enabledis true on this expansion andGetOldDecayLevelis 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 2–4× 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.mulwhile 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 mirrorsFileIndex's own resolution order, and phase 1 must too. - A custom-art shard that adds graphics to
art.mulwhile 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
corruptandcustomarttiers ofpatch_client.ps1therefore need itsnouoptier 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)throwsIndexOutOfRangeExceptionforid >= 49,152rather than returning null — 16,385 of them in a full sweep.Art.GetStatic(id)with the defaultcheckmaxid: trueis worse:GetLegalItemIDmaps 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.