Commit Graph

5 Commits

Author SHA1 Message Date
0ce92152a1 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
2026-09-10 03:05:32 -05:00
f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is a FREE restore. That argument depends entirely on
the leased value being memory-only too, and here it is not. A spawner is an Item
and is in the world save; a seasonal entry is written to
`Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a
restart does not put either back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00
0182732d63 feat(bridge): the world verbs an event owns (protocol 7, Phase 12a)
Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a
temporary gate, decoration -- and ONE command family underneath them, because
every one of them ends in the same sentence: an object exists, and this run
owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what`
discriminator, and the per-verb differences are fields rather than kinds.

The ownership registry is PERSISTED, and that is forced rather than chosen. A
spawned creature is in the world save, so it survives the restart that proves a
town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the
record of which run owns which serial has nowhere else to live: in memory it is
lost in the restart the creatures survive, and only in the website's ledger it
is not held here at all, so `world.despawn` would delete whatever serial it was
handed and "never touches a creature it did not create" would have no mechanism
behind it. So the Bridge gains its second persisted file beside
`Participation.bin` -- written by the same world save as the objects it
describes, so the two cannot get out of step.

The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the
reason for both halves of the decision. Its `SpeechEntry` is the evidence the
shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry
as the greeting, a proximity range, a conversation lock. It is also why not to
build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's
command-scripting language, which would leave an arbitrary-command field one
step from an event author. `Mobile.OnMovement` (delivered to every mobile in
range -- the `HandlesOnMovement` filter applies only to Items) and
`Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs.

Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s
argument from 11b: the shard's bound exists for the case where the website is
wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent
11b introduced that switch for, not a third one.

Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's
decoration placements under 1992 different graphics: for that class the graphic
IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a
player sees. Containers are refused outright -- teardown would delete whatever a
player had left inside.

`tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind
the registry's back. It is the one outcome the rig cannot reach by asking the
bridge -- every bridge verb that removes an object also drops its row -- and it
is what a player's sword does every time they kill an event creature.

Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the
release sidecar: all five verbs place; every ceiling refuses; a container and an
unknown type refuse; one run cannot despawn another's object; the registry and
its objects both survive a save and a clean restart (`pruned: 0`); a creature
deleted behind the registry's back comes back `gone` rather than `removed`; and
a five-second gate is collected by the shard's own deadline with `world.expired`
on the wire.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:51:35 -05:00
63a7dc4374 feat(bridge): lease deadlines and the participation ledger (Phase 11b)
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:20 -05:00
1dd490b483 fix(bridge): make the market sweep notice a vendor running out of gold
`BridgeMarket.Signature()` diffs shop name, owner, map, coordinates and the
item/price list -- the things a LISTING is made of. Protocol 5 added a `fees`
block to the frame and the change detector never learned about it.

So a vendor quietly running down its gold altered nothing the sweep compared,
emitted no frame, and `uo.vendor.expiring` -- the notification whose entire
subject is a vendor running out of gold -- could fire only by coincidence: when
somebody happened to reprice an item on a shop that was already broke. Proved on
the engagement Phase 11b live rig by setting a vendor's held gold to zero and
watching no frame follow.

The signature carries the DERIVED values, `exempt` and `periodsRemaining`, not
the raw ones. An integer division moves only when the shard's own answer to "is
this vendor in danger" moves; `HoldGold` changes on every sale and `NextPayTime`
on every tick, and keying on either would re-emit a fat listing frame for a shop
whose listings had not changed.

Emit CADENCE, not frame shape: no field added, PROTOCOL_VERSION untouched, and
`overlay.toml` unchanged. The general form is worth carrying forward -- a
sweep-based kind has a change detector, and a field added to the frame but not to
the detector ships correct and arrives never.

Also adds `tools/scaffolding/BridgeRigDriver.cs`: the shard driven from outside
the game over a polled command file. A walk asserts 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, so `[p5probe`
cannot be typed at a headless shard at all. Never deployed; `deploy.ps1` copies
only `overlay/`. The README gains the two ServUO facts the walk cost a rebuild
each to learn: a condemned house cannot be refreshed, and only a clean shutdown
emits.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:46 -05:00