# 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, `<servuo>`, 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 `<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 | 30–60 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.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<T>("Bridge.<Key>", 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
<tags>
    <tag name="WebsiteUserId">web-9931</tag>
</tags>
```

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.
