`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>
13 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, save, shutdown. Flag: RigDriverEnabled. Sets passwords 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. |
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.