Files
link/docs/PLAN.md
colby afc97c414e Phase 3: polled sweeps (vitals, house decay, economy)
BridgeSweeps runs three repeating Core-thread timers for the state that has no
EventSink. Cost measured in Phase 1 is why they can run on the main thread: a
full pass of all three is well under a millisecond at the seeded scale.

  - Vitals: online players only. Small and volatile; the sidecar diffs snapshots.
    Offline characters do not move, so they are served on demand as full profiles
    instead, not swept.
  - House decay: emits only on a level transition. A silent baseline on
    ServerStarted records every house's current stage, so a restart does not
    re-announce them. Payload carries from/to, coords, nested ban location,
    region, sign name, owner serial+account, and built/refreshed timestamps, all
    null-guarded.
  - Economy supply: periodic sum of every account's currency as a snapshot. The
    level; AccountGoldChange and the vendor events are the flow.

All three re-arm on `[bridge reload`; `[bridge sweepnow` runs one of each on
demand; `[bridge status` reports sweep counters. Sweeps skip emitting while the
sidecar is disconnected, since their state is perishable and re-emitted next
tick anyway (unlike events, which queue through an outage).

Verified on the seeded world with 8s intervals: baseline recorded 29 houses
silently, a probe bumped one Somewhat->Fairly, and the next sweep emitted exactly
one house.decay and none for the other 28. Economy emitted a supply snapshot per
interval. Vitals emitted nothing, correctly, since all seeded characters are
offline. Evidence in docs/PLAN.md §13.

Adds tools/scaffolding/BridgeSweepProbe.cs (never deployed) to force a decay
transition on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:59:21 -05:00

30 KiB
Raw Blame History

ServUO Bridge Plugin — Implementation Plan & Data Catalog

Status: Design, grounded in measurements taken on this shard, not estimates. Date: 2026-07-10 Codebase: ServUO 57.4, C:\Users\colby\Desktop\servuo, net48 / x64, Expansion EJ. Supersedes the speculative parts of BRIDGE_FINDINGS.md. See §8 for where that document is wrong.

Test scaffolding used to produce this plan lives in Scripts/Custom/BridgeSeeder.cs (world population) and Scripts/Custom/BridgeProbe.cs (timing). Both are gated behind Config/Bridge.cfg flags and default to off. Neither is part of the bridge. Delete before production.


1. Measured budget

Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the Core thread — the probe printed thread: Core Thread (id 1), which empirically confirms the threading model that BRIDGE_FINDINGS.md could only infer from a crash log.

Read Cost Payload Per-unit
Full character profile 0.069 ms/char 2,386 B JSON
Vitals sweep (150 chars) 0.223 ms ~180 B/char 0.0015 ms/char
House decay sweep (35 houses) 0.007 ms 0.0002 ms/house
Economy supply sweep (51 accounts) 0.001 ms ~0.00002 ms/acct
Vendor snapshot (30 vendors, 1200 listings) 0.343 ms 0.0003 ms/listing

Linear extrapolation at the same gear complexity:

Scenario Cost Verdict
Vitals sweep @ 200 online 0.30 ms free
Vitals sweep @ 1000 online 1.49 ms free
Decay sweep @ 2000 houses 0.38 ms free
Economy @ 5000 accounts 0.06 ms free
Profiles for 1000 chars 69.4 ms stall — never in a sweep

The headline result inverts the original doc's anxiety. BRIDGE_FINDINGS.md treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is not free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. Tier by volatility and serve profiles on demand. That conclusion survives; the reasoning behind it changes.

Caveat on these numbers

Seeded characters carry 8 equipped items with ~6 non-zero mods each and ~12 trained skills. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore understated, plausibly by 24×. Read 0.069 ms / 2.4 KB as a floor: budget ~0.2 ms and ~68 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.

Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.


2. Architecture (confirmed, unchanged)

ServUO plugin (C#, net48)  ──loopback TCP, newline-JSON──►  Rust sidecar  ──WebSocket/JSON──►  website
      (Core-thread reads)  ◄──inbound commands─────────────┘  (owns WS, auth, buffering, fan-out)

ServUO does not speak WebSocket. It writes {...}\n lines to 127.0.0.1. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.

Non-negotiable rules, all of which the measurements support:

  • Every world read happens on the Core thread. Verified: probe reported Core Thread (id 1).
  • The Core thread never touches the socket. Producer formats a line, enqueues to a bounded ConcurrentQueue, returns. A dedicated writer thread drains it.
  • Inbound commands marshal back via Timer.DelayCall(TimeSpan.Zero, ...), which is lock-protected and cross-thread safe (Server/Timer.cs:243-251). The read thread touches no World/Mobile/Item API.
  • Bound the outbound queue (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
  • Never block or throw inside an EventSink handler. Several are veto hooks sitting in a transaction path.

3. Prerequisite: fix the build, or the plugin will not load

ScriptCompiler.Compile() (Server/ScriptCompiler.cs:38-58) runs dotnet build Scripts/Scripts.csproj -c Release, prints the output, never checks the exit code, then Assembly.LoadFrom("Scripts.dll") and returns true. Two consequences:

  1. A failing script build is silently ignored and the previous Scripts.dll reloads. (BRIDGE_FINDINGS.md §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
  2. The build passes no Platform, so it defaults to AnyCPU. OutputPath is only set under the Release|x64 condition, so the DLL lands in Scripts/bin/Release/ while the server loads Scripts.dll from the repo root. Script edits currently never take effect.

Fix before writing any bridge code. Either add a default <Platform>x64</Platform> to Scripts.csproj and Server.csproj, or pass -p:Platform=x64 in ScriptCompiler.cs:38. Without it, AnyCPU also leaves TRACE;NEWTIMERS;ServUO undefined for the scripts build while the core was compiled with them — a latent mismatch.


4. Plugin layout

All under Scripts/Custom/Bridge/. Keep each file small and wrap every handler body in try/catch — an exception escaping into a game code path is a shard bug.

File Responsibility
BridgeConfig.cs Configure(): read Config/Bridge.cfg into static fields. Runs before World.Load.
BridgeLink.cs TcpClient to 127.0.0.1. Writer thread draining a bounded queue; reader thread parsing lines → Timer.DelayCall. Reconnect on EOF.
BridgeJson.cs Hand-rolled StringBuilder writers. No reflection serializer — the probe's numbers assume this.
BridgeEvents.cs Initialize(): subscribe the EventSink streams in §5.
BridgeSweeps.cs Vitals / decay / economy / vendor timers. Re-armable via [bridge reload.
BridgeRequests.cs Inbound char.request, account.roster, vendor.snapshot.
BridgeLink.Commands.cs [link registration, code table, link.confirm handling.

Lifecycle (Server/Main.cs:544-562, all Core thread): Configure()World.Load()Initialize()EventSink.ServerStarted.

Read config in Configure. Subscribe events in Initialize. Open the socket and take the decay baseline on ServerStarted. Tear down on EventSink.Shutdown — but Shutdown does not fire on a crash (Main.cs:198,313), so the sidecar must treat socket EOF as normal and re-handshake.


5. Data catalog — everything the shard can give you

91 public static event declarations exist in Server/EventSink.cs. Below is every one worth shipping, grouped by stream, with the raise site verified.

5.1 Session & identity

Signal Hook Freq Notes
Player online EventSink.Login low Best per-player anchor. Snapshot account, char, serial, map, loc.
Player offline EventSink.Logout low Pair with Login.
Socket up/down Connected / Disconnected low Lower level; fires at char-select too.
Auth attempts AccountLogin, GameLogin low Failed-login / IP signals for the website.
Roster change CharacterCreated, DeleteRequest rare Keep the sidecar's roster cache honest.
Client fingerprint ClientVersionReceived, ClientTypeReceived low Classic vs Enhanced; version enforcement.

5.2 Character state

Signal Hook Freq Notes
Vitals 30 s sweep periodic 0.0015 ms/char. hits/mana/stam, str/dex/int, loc, online flag.
Full profile on demand + on Login request 0.069 ms/char, 2.4 KB. All skills, worn gear, flattened mods, resists.
Skill progression SkillGain medium High-signal. Ship it.
Skill/stat caps SkillCapChange, StatCapChange rare Powerscroll application.
Reputation FameChange, KarmaChange low-med Naturally diff-shaped.
Hunger HungerChanged low Cosmetic; optional.

There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam. They move through the delta queue (Mobile.ProcessDeltaQueue). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.

EventSink.OnPropertyChanged is not a stat-change hook. It is raised only from Scripts/Commands/Properties.cs:282,444,472 — i.e. staff [set commands. See §5.7.

5.3 Economy & commerce

Signal Hook Freq Notes
Account gold delta EventSink.AccountGoldChange low-med ✔ AccountGold is live on this shard. Args give IAccount + old/new TotalCurrency (a double).
Money supply economy sweep periodic 0.001 ms / 51 accts. Sum Account.TotalCurrency × Account.CurrencyThreshold.
NPC vendor — buy ValidVendorPurchase medium Scripts/VendorInfo/GenericBuy.cs:379. Total = AmountPerUnit × stack Amount.
NPC vendor — sell ValidVendorSell medium Scripts/Mobiles/NPCs/BaseVendor.cs:2209.
Player vendor sale needs core edit medium See §6. The one non-drop-in piece.
Vendor placed PlacePlayerVendor rare PlayerVendorDeed.cs:60,106, VendorRentalGumps.cs:418. Tracks vendor population.
Vendor listings vendor snapshot sweep / on demand periodic 0.0003 ms/listing. Serial, itemId, price, IsForSale, HoldGold.
Item consumed OnConsume medium Regs, potions — consumption side of the economy.

⚠️ ValidVendorPurchase / ValidVendorSell are validation-stage veto hooks, not "sale committed" callbacks. Treat as sale attempted; reconcile against AccountGoldChange if you need ledger accuracy. Never block or throw in them.

Note: CurrencyThreshold is 1,000,000,000 on this shard. TotalCurrency is a double in platinum units. DepositGold(n) stores n / CurrencyThreshold. Total shard supply measured: 110,478,209 gold across 51 accounts. Do not read TotalCurrency as gold.

5.4 Housing / IDOC

Signal Hook Freq Notes
Decay transition decay sweep, emit on change 3060 s 0.0002 ms/house. No EventSink exists.

Hold a Dictionary<Serial, DecayLevel> and emit only on transition. On ServerStarted, take a silent baseline pass (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one idoc.snapshot for houses already at IDOC/Collapsed, clearly flagged as a snapshot.

The decay model in BRIDGE_FINDINGS.md §III.3 is wrong for this shard. Corrected:

  • DynamicDecay.Enabled returns Core.ML (Scripts/Multis/DynamicDecay.cs:21). Expansion is EJ, so Core.ML is true, so BaseHouse.GetOldDecayLevel() and its "IDOC = 95.099.9% of DecayPeriod" thresholds are dead code. The live model is the staged machine (m_CurrentStage, NextDecayStage, SetDynamicDecay). Real IDOC stage duration: 1224 h random (DynamicDecay.cs:18).
  • BaseHouse.CanDecay is true only for DecayType.Condemned or DecayType.ManualRefresh (BaseHouse.cs:136-157). An active owner's newest house is AutoRefresh and never decays. So a house reaches IDOC only when the owner account is inactive (LastLogin older than Account.InactiveDuration, 180 days → Condemned) or the house is not the owner's newest.
  • Any account with AccessLevel >= GameMaster — or any character on it — makes all its houses Ageless.

Payload per transition: house serial, fromto level, X/Y/Z, Map, BanLocation, Region.Name, Sign?.GetName(), owner serial + account, co-owners, BuiltOn, LastRefreshed, NextDecayStage. Guard Owner/Sign/Region for null (abandoned or mid-demolition). Read house.DecayLevel once per house per sweep into a local — the getter is computed and mutates m_CurrentStage.

5.5 Combat, death, PvP

Signal Hook Freq Notes
Player death PlayerDeath low
Murder PlayerMurdered low High-signal for the website.
Killer attribution OnKilledBy medium Killed + KilledBy. Better than PlayerDeath for PvP feeds.
Creature death CreatureDeath high Every mob kill. Filter or aggregate.
Aggression AggressiveAction med-high Per aggression state change, not per swing.

No per-hit damage event. Damage numbers require overriding Mobile.Damage / weapon OnHit, not an EventSink.

5.6 Progression & activity

QuestComplete, CraftSuccess, ResourceHarvestSuccess, ResourceHarvestAttempt, TameCreature, JoinGuild, CreateGuild, VirtueLevelChange, BODOffered, BODUsed, RepairItem, AlterItem, Speech, OnEnterRegion.

OnEnterRegion (Server/Region.cs:1160) gives from, oldRegion, newRegion — a cheap location stream, and the right answer instead of Movement. Filter to PlayerMobile.

⚠️ Movement is the single most dangerous event to export. Raised from Mobile.InternalOnMove for every mobile that takes a step, including all NPCs. It is synchronous and cancellable (args.Blocked gates the move), so your handler sits inside the movement decision path. Its args are pooled and Free()d immediately (EventSink.cs:802-834) — never retain the reference. Prefer OnEnterRegion.

Same caution for ItemCreated/ItemDeleted/MobileCreated/MobileDeleted — they fire for every transient object.

5.7 Cheat detection & staff audit

This is where the catalog earns its keep, and it is thin in the original doc.

Signal Hook Why
Speedhack EventSink.FastWalk Core's own fast-walk detector. Straight to the fraud feed.
Staff property edits OnPropertyChanged Raised only from [set (Properties.cs:282,444,472). Gives Mobile (the staffer), target Instance, PropertyInfo, old and new value. An audit trail for GM abuse.
Staff commands EventSink.Command Every command invocation.
Player-vendor sale new event (§6) Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns.
Gold flow AccountGoldChange Reconcile against sale stream.

5.8 Lifecycle

ServerStarted, Shutdown, Crashed, WorldLoad, WorldSave, BeforeWorldSave, AfterWorldSave, WorldBroadcast.

AfterWorldSave is a natural snapshot boundary. Crashed gives an args.Close vote. Shutdown is skipped on a crash.

5.9 Known gaps (no clean hook)

  • Item pickup / drop / lift. No EventSink. Lives on virtuals: Item.OnDragLift / OnDragDrop / OnDroppedInto, Mobile.OnDragDrop / OnDragLift. Partial coverage via OnItemObtained, ContainerDroppedTo, CorpseLoot. The biggest remaining gap.
  • Per-hit combat damage. Virtual overrides only.
  • Equip / unequip. CheckEquipItem is a veto hook; EquipMacro/UnequipMacro are macro-only.
  • Stat/vital deltas. Sweep. (Cheap — see §5.2.)

6. The one core edit: PlayerVendorSale

Player-vendor purchases do not raise ValidVendorPurchase. The sale commits in PlayerVendorBuyGump.OnResponse (Scripts/Gumps/PlayerVendorGumps.cs:41), at the gold transfer:

// PlayerVendorGumps.cs:84-96
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice);   // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice);               // ...and bank
int commission = 0;
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission;                      // seller credited — committed

At that point everything cheat detection wants is in scope: buyer (from), vendor (m_Vendor), vendor owner (m_Vendor.Owner — the player who profits), item (m_VI.Item), price (m_VI.Price), commission. This is better data than the NPC Valid* events, which lack owner and commission — and unlike them it fires on a committed sale.

Three edits, then the bridge stays pure-subscription:

  1. Server/EventSink.cs — declare PlayerVendorSaleEventHandler PlayerVendorSale, InvokePlayerVendorSale, and PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission } (copy the ValidVendorSellEventArgs shape).
  2. Scripts/Gumps/PlayerVendorGumps.cs — one line after the HoldGold += at line 96.
  3. Bridge subscribes in Initialize like any other event.

~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the buyer, which is exactly what cheat detection needs.


7. Wire protocol

Newline-delimited JSON, one object per line, serial as the primary key.

Outbound (shard → sidecar)

{"t":1752,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
 "items":206467,"mobiles":42826,"accounts":51}
{"t":1752,"kind":"server.shutdown"}
{"t":1752,"kind":"server.crashed","error":"…"}
{"t":1752,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
{"t":1752,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
 "str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
{"t":1752,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
{"t":1752,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
 "owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
 "item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
{"t":1752,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
 "map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
 "region":"Britain","name":"The Silver Anvil",
 "owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
 "builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
{"t":1752,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
{"t":1752,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
{"t":1752,"kind":"economy.supply","accounts":51,"gold":110478209}

char.profile follows the shape in BRIDGE_FINDINGS.md §IV.3 — it was correct — with mods a flattened union of non-zero entries across AosAttributes, AosWeaponAttributes, AosArmorAttributes, produced by iterating each enum through the bag's indexer (Scripts/Misc/AOS.cs:924,1464,2238). No hardcoded property names.

Inbound (sidecar → shard)

{"kind":"char.request","account":"PerryAdimn","slot":0}
{"kind":"account.roster","account":"PerryAdimn"}
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
{"kind":"towncrier.remove","id":"n123"}

Every inbound handler marshals to the Core thread before touching world state.

server.hello is per-connection, not per-boot

The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on every connect. An earlier draft emitted server.started once at EventSink.ServerStarted; a sidecar that came up second never received it and had no idea which shard it was attached to.

bootId is a GUID generated at ServerStarted. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes "I reconnected" (keep cached state) from "the shard restarted" (discard it). connects is the shard's count of successful connections, so the first hello of a run carries connects:1.

Counts in hello are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.

Item names are clilocs

Item.Name is frequently null; the display name is LabelNumber, a cliloc id. There is no Data/Cliloc.enu in this repoBRIDGE_FINDINGS.md §IV.4 is wrong about this. Cliloc data lives in the client install, which DataPath resolves to D:\Games\Electronic Arts\Ultima Online Classic\. Ship both name (when non-null) and cliloc, and resolve the number on the website against a cliloc map. That avoids a server-side dependency on the client directory.


8. Corrections to BRIDGE_FINDINGS.md

§ Claim Reality
§1 "A compile error in your bridge file takes the whole shard down at boot." False. Compile() ignores the build exit code; a failing build silently reloads the stale Scripts.dll. Worse: your plugin would appear absent, not broken. See §3.
§III.3 IDOC = 95.099.9% of DecayPeriod, per GetOldDecayLevel. Dead code on EJ. DynamicDecay.Enabled == Core.ML == true, so the staged machine governs. IDOC lasts 1224 h. Also: CanDecay is true only for Condemned/ManualRefresh, so an active owner's newest house never decays.
§IV.4 Resolve clilocs against Data/Cliloc.enu. No such file. Cliloc data is in the client install via DataPath. Resolve website-side.
§0 "117 mobiles / 2469 items per the last crash report." The world holds 203,386 items and 42,591 mobiles before seeding.
§II.2 Stat sweep is the thing to budget for. Measured free (0.0015 ms/char). The real cost is bulk profiles (69 ms/1000).
§2 SkillGain is a "medium" player-activity signal. Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose.
§II.4 Player-vendor sales are the only gap needing a core edit. Still true, and confirmed at PlayerVendorGumps.cs:96.

9. Implementation phases

  1. Fix the build (§3). Done. Verified: a plain boot now logs Core: Compiling scripts... / Build succeeded.
  2. Transport. Done. BridgeLink: TcpClient, link thread + bounded drop-oldest queue, reader thread → Timer.DelayCall, reconnect with backoff capped at 5 s. Emits server.hello / server.shutdown / server.crashed, answers ping with pong. [bridge status|reload|ping]. Acceptance evidence in §11.
  3. Cheap event streams. Done. BridgeEvents subscribes the streams selected below. All observed on the live shard; evidence in §12.
  4. Sweeps. Done. BridgeSweeps: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
  5. Request/response. char.profile, account.roster, vendor.snapshot. Sidecar caches profiles; rate-limit requests sidecar-side.
  6. [link account linking. CommandSystem.Register("link", AccessLevel.Player, …), one-time short-TTL codes in a main-thread dict, Account.SetTag("WebsiteUserId", id) — persists to accounts.xml for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
  7. Town-crier inbound. GlobalTownCrierEntryList.Instance.AddEntry(lines, duration) (Scripts/Mobiles/NPCs/TownCrier.cs:96), marshaled to the Core thread. Cap line count/length and active entries.
  8. Core edit: PlayerVendorSale (§6). Then the cheat-detection feed.
  9. Cheat signals. FastWalk, OnPropertyChanged audit, vendor-sale anomaly detection in the sidecar.

Config keys (Config/Bridge.cfg)

Host=127.0.0.1
Port=7788
QueueCap=10000
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300

Read in Configure() via Config.Get<T>("Bridge.<Key>", default). Key scope is the filename: Bridge.cfg + StatSweepSecondsBridge.StatSweepSeconds.


11. Phase 1 acceptance

Run against the seeded shard with tools/stub_sidecar.ps1. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.

Claim Evidence
The shard boots normally with no sidecar listening. World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat.
Events emitted while disconnected are buffered and delivered on connect. server.hello carried t=…070312 (boot) but arrived at …114209, 44 s later, when the sidecar first appeared.
Inbound commands execute on the Core thread. {"kind":"ping","id":"t1"}{"kind":"pong","id":"t1"}.
An unknown kind is ignored, not fatal. [Bridge] no handler for inbound kind 'nonsense.kind'
Malformed JSON does not kill the reader. [Bridge] malformed inbound line, ignoring, connection stayed up.
Killing the sidecar does not disturb the shard. Shard stayed up, CPU unchanged, no exception, no log spam.
The shard reconnects unattended. Second [Bridge] connected, hello re-sent with connects:2 and the same bootId.

Two defects were found this way and fixed:

  • Backoff ceiling was 30 s, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
  • A stale reader could kill a fresh connection. reader.Join(1s) can time out, and the old reader's finally then set the shared _dead flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.

13. Phase 3 acceptance

BridgeSweeps.cs runs three repeating Core-thread timers: vitals (StatSweepSeconds), house decay (DecaySweepSeconds), economy supply (EconomySweepSeconds). All re-armable via [bridge reload; [bridge sweepnow runs one of each on demand; [bridge status reports sweep counters.

Verified on the seeded world with intervals cut to 8 s:

  • Decay is transition-only. Baseline recorded 29 houses silently on ServerStarted. A probe bumped one house Somewhat → Fairly with SetDynamicDecay; the next sweep emitted exactly one house.decay, none for the other 28:

    {"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
     "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
     "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
     "builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
    
  • Economy supply emitted a snapshot each interval: {"kind":"economy.supply","accounts":51,"gold":…}.

  • Vitals correctly emitted nothing — the seeded characters are all offline (NetState == null). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.

Notes from the run:

  • region is null for the seeded houses — they sit outside any named region. The handler guards Region, Sign, and Owner for null; all three can be absent on abandoned or oddly-placed houses.
  • The sweeps skip emitting when the sidecar is disconnected (BridgeLink.Connected), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
  • Config duplicate keys: last write wins (Config.cs does _Entries[key] = e), which is why the scaffolding appends test overrides to the end of Bridge.cfg.

12. Phase 2 acceptance

The selected streams (Login, Logout, AccountLogin, AccountGoldChange, ValidVendorPurchase/Sell, PlacePlayerVendor, SkillGain, FameChange, KarmaChange, QuestComplete, PlayerDeath, PlayerMurdered, OnKilledBy, FastWalk, OnPropertyChanged, Command, Before/AfterWorldSave) are in BridgeEvents.cs. Gold, fame, karma, and the save boundaries were fired through their real code paths (DepositGold, the Fame/Karma setters, World.Save()) and observed at the stub sidecar:

{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
{"kind":"karma.change",...,"old":7903,"new":7853}
{"kind":"world.save.before"}
{"kind":"world.save.after","items":206312,"mobiles":42826}

gold.change reads old:3836893, exactly the previous boot's new (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.

The finding: SkillGain fires for NPCs, hard

The first run emitted 115 skill.gain events in four seconds — every one an NPC grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. OnSkillGain now drops anything where !From.Player. After the filter the same boot produced zero stray skill events.

This is the general rule for this codebase, and the reason each handler filters at the top: most "player" events also fire for NPCs. FameChange, KarmaChange, and OnKilledBy are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.

Safety facts baked into the handlers

  • AccountLoginEventArgs carries a plaintext Password and is a veto hook (Accepted, RejectReason). We read the username and IP only; the password never leaves the process.
  • FastWalkEventArgs.Blocked and AccountLogin.Accepted gate game logic. Handlers are read-only; they never set these.
  • OnPropertyChanged passes a null Mobile from one of its three raise sites, so audit.set tolerates an unknown staffer.
  • The property is FastWalkEventArgs.NetState, not .State.

10. Operational notes

  • Commands and timers do not run during a world save. TimerMain early-continues while World.Saving || World.Loading (Server/Timer.cs:322), and the main loop is inside World.Save anyway. A link.confirm arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
  • Pending link codes are in-memory and lost on crash. Acceptable — the player re-runs [link.
  • zlibwapi64 DllNotFoundException already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
  • The bridge should carry the resolved websiteUserId on every player event once the account tag is read at Login and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.