Files
servuo-plugins/tools/scaffolding
wtclaude 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
..

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, 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.

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.