BridgeTownCrier handles inbound towncrier.add / towncrier.remove, pushing
website-published news into GlobalTownCrierEntryList so every town crier
announces it until it expires. Both run on the Core thread (required: AddEntry
mutates a shared list and the criers send packets). An id maps to the created
TownCrierEntry so a later remove can pull it, and re-adding an id replaces the
prior entry.
Caps are enforced before touching the shared list -- line count, line length,
active-entry count, duration -- as defense in depth on top of the loopback trust
boundary: a buggy or compromised sidecar still cannot flood the criers or pin a
message forever. Config: Bridge.TownCrierMax{Lines,LineLength,Active,DurationSec}.
Verified with a sending stub and a probe that logs the actual crier list. Replies
and game state agree: add n1 -> towncrier.ok and the entry appears with the exact
lines; add n2 (8 lines over the cap of 6) -> towncrier.error and never enters the
list; remove n1 -> towncrier.ok and the entry is gone; remove unknown ->
towncrier.error. Evidence in docs/PLAN.md §16.
Adds BridgeJson.GetStringList for JSON string arrays, tools/stub_sidecar_crier.ps1,
and tools/scaffolding/BridgeCrierProbe.cs. This closes the pure-plugin inbound
work; only the PlayerVendorSale core edit (Phase 7) remains on the ServUO side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
486 lines
36 KiB
Markdown
486 lines
36 KiB
Markdown
# 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 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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
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.
|
||
|
||
---
|
||
|
||
## 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.
|