# 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, ``, net48 / x64, Expansion **EJ**. **Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) 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 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 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 `x64` 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 | 30–60 s | **0.0002 ms/house.** No EventSink exists. | Hold a `Dictionary` 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.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 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, `from`→`to` 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: ```csharp // 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) ```jsonc {"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) ```jsonc {"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 repo** — `BRIDGE_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. **Update (3.0).** That recommendation held, and the reason it had to hold turned out to be stronger than "avoids a dependency": **ServUO cannot resolve clilocs either.** Every current client ships its `Cliloc.*` files compressed, and the bundled `Ultima.StringList` reads only the older plain layout — so `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name` on any modern shard. The in-game Vendor Search gump has the same gap, which is why `vendor.listing` never calls it. Pushing name resolution to the plugin was never an option. See [`v3.md`](v3.md) §8.6 and `docs/website/CLILOCS.md` for how the site gets a table instead (the operator converts one from their own client, once). --- ## 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.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 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 0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.` 1. ~~**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. 2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. 6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16. 7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17. 5. **`[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. 6. **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. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. 8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar. **Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning + world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework, shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from 3.0 so far: **Part A** — the visibility framework — **`world.ruleset`** ([`v3.md`](v3.md) §5), `BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep (it is emitted once per connect, like `server.hello`, because shard config changes only when an operator edits a file) — the **spawn atlas** ([`v3.md`](v3.md) §6), which is website-only and touches no wire at all — and **`points.board`** ([`v3.md`](v3.md) §7), `BridgePoints.cs`, the loyalty/points leaderboards. `BridgePoints` is the widest read the bridge performs: ten of ServUO's ~25 point systems keep a row for every character ever created, so it selects the top N in a single bounded pass rather than sorting, and runs on a deliberately slow 300 s interval. Also shipped: **`vendor.listing`** ([`v3.md`](v3.md) §8), `BridgeMarket.cs`, the shard-wide player-vendor index. It introduces the one sweep pattern the bridge did not previously have — an **amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for tens of houses or a fixed set of point systems and is not fine for a world of shops whose inventories recurse into containers. `BridgeMarket` inventories at most `MarketSweepBatch` vendors per tick from a persistent cursor, so the per-tick cost is bounded by the batch rather than by world size, and full coverage takes `ceil(vendors / batch) x MarketSweepSeconds`. Measured at **15.4 ms** for a cold tick of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff), on a shard of 209k items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own `PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site. That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 → 3** and the coordinated `edge` → `main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1). The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints but changes nothing that already existed in v2 — so the operator-visible break is limited to re-pinning the version, which the website does for itself in a one-shot boot migration. ### Config keys (`Config/Bridge.cfg`) ```ini Host=127.0.0.1 Port=7788 QueueCap=10000 StatSweepSeconds=30 DecaySweepSeconds=60 EconomySweepSeconds=300 ``` Read in `Configure()` via `Config.Get("Bridge.", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`. The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's `RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` and `Market*` blocks). **`servuo-plugins/overlay/Config/Bridge.cfg` is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults. --- ## 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. --- ## 17. Phase 7 acceptance The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`. Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so. Verified end to end with a probe that fired the event using **real seeded-vendor data**: ```json {"kind":"vendor.sale","committed":true, "buyerSerial":"0x1F8","buyerAcct":"seed_001", "ownerSerial":"0x1F5","ownerAcct":"seed_000", "vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword", "itemId":3937,"amount":1,"price":69819,"commission":0} ``` Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events. **Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`). --- ## 16. Phase 6 acceptance `BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary. Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree: | Sent | Reply | Crier list | |------|-------|------------| | `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines | | `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list | | `remove n1` | `towncrier.ok` | entry gone | | `remove does-not-exist` | `towncrier.error "unknown id"` | no change | The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged. Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs. No core changes; this closes the pure-plugin inbound work. --- ## 15. Phase 5 acceptance `BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`. Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it): ``` <- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300 -> link.confirm code=77M9TK websiteUserId=web-9931 <- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931 -> link.confirm code=BADCOD ... <- link.error code=BADCOD reason="unknown or expired code" ``` **The tag persists.** After a `World.Save()`, `accounts.xml` contained: ```xml web-9931 ``` This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised. Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup. Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`. --- ## 14. Phase 4 acceptance `BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence. Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread: - `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline. - `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed. - `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree. - `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each. - `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`. Two things the real character surfaced that the seeded dummies could not: - **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully. - **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking. Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete. --- ## 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: ```json {"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.