Files
link/docs/PLAN.md
colby be05ea912c Phase 5: [link account linking
BridgeAccountLink ties a game account to a website account. [link mints a
one-time, 5-minute code from an unambiguous alphabet (no O/0/I/1), holds it in a
Core-thread dict keyed to the account, and emits link.request. The website
relays the code back through the sidecar as link.confirm; the shard validates,
writes the WebsiteUserId account tag, and replies link.ok. A bad or expired code
gets link.error.

The tag persists to accounts.xml in ServUO's standard <tags> format, read by
LoadTags at boot, so a link survives restarts with no new persistence layer.
mob.login now carries webId when the account is linked, so the sidecar can
attribute a session to a site user without a lookup.

Safeguards: one-time codes; only the newest code per account is valid; per-account
30s rate limit against code spam; a 1-minute purge bounds the code table; the
websiteUserId is trusted only because the socket is loopback-only. The tag reaches
memory on confirm but disk only on the next save — a hard crash between loses it,
and the player just re-runs [link.

Verified end to end with a smart stub that reads the emitted code and confirms
it: link.request -> link.confirm -> link.ok, a bad code -> link.error, and the
tag observed in accounts.xml after a save. Evidence in docs/PLAN.md §15.

The [link command body is exposed as RequestLink(Mobile) so it can be driven in
tests without a client. Adds tools/stub_sidecar_link.ps1 and
tools/scaffolding/BridgeLinkProbe.cs.

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

464 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](#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 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, `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.
---
## 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
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.
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.
### 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`.
---
## 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.
---
## 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.