Protocol 3.0 order 2 (v3.md §5) is built across all four repos; this is its
documentation half, plus the running progress record the plan was missing.
v3.md
- A progress table at the top and a State column on §9's sequencing table, so
"what has landed" is answerable without reading four git logs. Part A (order
1) and world.ruleset (order 2) are marked done; the spawn atlas is next.
- §5 gains the implementation notes worth keeping, chiefly: where a system's
on/off state is DERIVED rather than configured, read the system's own static
instead of inventing a .cfg key (Shadowguard has no Enabled key — it's the
TOL expansion gate; Factions is `!ViceVsVirtueSystem.Enabled` by
construction in stock ServUO). Also that the plugin CAN be compile-verified
despite the "no standalone build" caveat, and how.
INTEGRATION.md
- The world.ruleset catalog entry and GET /ruleset, with the two things
consumers get wrong: caps are in TENTHS (1000 = 100.0), and `connect` exists
only if the operator set Bridge.PublicConnectAddress — the shard's real
listen address is never published.
- §2 now says plainly that v3 has NOT been bumped yet and what that means:
sidecars on `edge` report 2 while already carrying some v3 kinds, so do not
infer feature availability from the version during this window.
PROTOCOL_2.md §10.4
- The deferred "which PvP system does this shard run?" is answered (VvV on,
Factions off — and mutually exclusive by construction), and world.systems is
marked superseded by world.ruleset, which carries the systems block it asked
for. No orphan kind is left behind.
BACKEND_DESIGN.md — the shard_ruleset table (why it is stored whole rather than
normalized, and why no row means null rather than {}) and the public route.
PROJECT_TREE.md is deliberately untouched: sync-project-tree regenerates it on
push to main, so it updates itself at the v3 cutover.
Co-Authored-By: Claude <noreply@anthropic.com>
39 KiB
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 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 noWorld/Mobile/ItemAPI. - 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:
- A failing script build is silently ignored and the previous
Scripts.dllreloads. (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.) - The build passes no
Platform, so it defaults toAnyCPU.OutputPathis only set under theRelease|x64condition, so the DLL lands inScripts/bin/Release/while the server loadsScripts.dllfrom 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.OnPropertyChangedis not a stat-change hook. It is raised only fromScripts/Commands/Properties.cs:282,444,472— i.e. staff[setcommands. 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/ValidVendorSellare validation-stage veto hooks, not "sale committed" callbacks. Treat as sale attempted; reconcile againstAccountGoldChangeif 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.EnabledreturnsCore.ML(Scripts/Multis/DynamicDecay.cs:21). Expansion is EJ, soCore.MLis true, soBaseHouse.GetOldDecayLevel()and its "IDOC = 95.0–99.9% ofDecayPeriod" 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.CanDecayis true only forDecayType.CondemnedorDecayType.ManualRefresh(BaseHouse.cs:136-157). An active owner's newest house isAutoRefreshand never decays. So a house reaches IDOC only when the owner account is inactive (LastLoginolder thanAccount.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 housesAgeless.
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/ weaponOnHit, 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.
⚠️
Movementis the single most dangerous event to export. Raised fromMobile.InternalOnMovefor every mobile that takes a step, including all NPCs. It is synchronous and cancellable (args.Blockedgates the move), so your handler sits inside the movement decision path. Its args are pooled andFree()d immediately (EventSink.cs:802-834) — never retain the reference. PreferOnEnterRegion.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 viaOnItemObtained,ContainerDroppedTo,CorpseLoot. The biggest remaining gap. - Per-hit combat damage. Virtual overrides only.
- Equip / unequip.
CheckEquipItemis a veto hook;EquipMacro/UnequipMacroare macro-only. - Stat/vital deltas. Sweep. (Cheap — see §5.2.)
6. The one core edit: PlayerVendorSale
Player-vendor purchases do not raise ValidVendorPurchase. The sale commits in PlayerVendorBuyGump.OnResponse (Scripts/Gumps/PlayerVendorGumps.cs:41), at the gold transfer:
// PlayerVendorGumps.cs:84-96
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
int commission = 0;
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
At that point everything cheat detection wants is in scope: buyer (from), vendor (m_Vendor), vendor owner (m_Vendor.Owner — the player who profits), item (m_VI.Item), price (m_VI.Price), commission. This is better data than the NPC Valid* events, which lack owner and commission — and unlike them it fires on a committed sale.
Three edits, then the bridge stays pure-subscription:
Server/EventSink.cs— declarePlayerVendorSaleEventHandler PlayerVendorSale,InvokePlayerVendorSale, andPlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }(copy theValidVendorSellEventArgsshape).Scripts/Gumps/PlayerVendorGumps.cs— one line after theHoldGold +=at line 96.- Bridge subscribes in
Initializelike any other event.
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the buyer, which is exactly what cheat detection needs.
7. Wire protocol
Newline-delimited JSON, one object per line, serial as the primary key.
Outbound (shard → sidecar)
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
"items":206467,"mobiles":42826,"accounts":51}
{"t":1752…,"kind":"server.shutdown"}
{"t":1752…,"kind":"server.crashed","error":"…"}
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
"region":"Britain","name":"The Silver Anvil",
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
char.profile follows the shape in BRIDGE_FINDINGS.md §IV.3 — it was correct — with mods a flattened union of non-zero entries across AosAttributes, AosWeaponAttributes, AosArmorAttributes, produced by iterating each enum through the bag's indexer (Scripts/Misc/AOS.cs:924,1464,2238). No hardcoded property names.
Inbound (sidecar → shard)
{"kind":"char.request","account":"PerryAdimn","slot":0}
{"kind":"account.roster","account":"PerryAdimn"}
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
{"kind":"towncrier.remove","id":"n123"}
Every inbound handler marshals to the Core thread before touching world state.
server.hello is per-connection, not per-boot
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on every connect. An earlier draft emitted server.started once at EventSink.ServerStarted; a sidecar that came up second never received it and had no idea which shard it was attached to.
bootId is a GUID generated at ServerStarted. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes "I reconnected" (keep cached state) from "the shard restarted" (discard it). connects is the shard's count of successful connections, so the first hello of a run carries connects:1.
Counts in hello are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
Item names are clilocs
Item.Name is frequently null; the display name is LabelNumber, a cliloc id. There is no Data/Cliloc.enu in this 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
Fix the build (§3).Done. Verified: a plain boot now logsCore: Compiling scripts... / Build succeeded.Transport.Done.BridgeLink:TcpClient, link thread + bounded drop-oldest queue, reader thread →Timer.DelayCall, reconnect with backoff capped at 5 s. Emitsserver.hello/server.shutdown/server.crashed, answerspingwithpong.[bridge status|reload|ping]. Acceptance evidence in §11.Cheap event streams.Done.BridgeEventssubscribes the streams selected below. All observed on the live shard; evidence in §12.Sweeps.Done.BridgeSweeps: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.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.Done.[linkaccount linking.BridgeAccountLink:[link→ one-time code →link.confirm→WebsiteUserIdtag, persisted toaccounts.xml.mob.logincarrieswebId. Evidence in §15.Town-crier inbound.Done.BridgeTownCrier:towncrier.add/removeintoGlobalTownCrierEntryList, with abuse caps. Evidence in §16.Core edit:Done. Two core patches +PlayerVendorSale.BridgeVendorSalesubscriber →vendor.salewith buyer + owner + price + commission. Evidence in §17.[linkaccount linking.CommandSystem.Register("link", AccessLevel.Player, …), one-time short-TTL codes in a main-thread dict,Account.SetTag("WebsiteUserId", id)— persists toaccounts.xmlfor free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.- 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. - Core edit:
PlayerVendorSale(§6). Then the cheat-detection feed. - Cheat signals.
FastWalk,OnPropertyChangedaudit, 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 §13; Protocol 3.0's (visibility framework,
shard content and standings) is v3.md §9, which also tracks what has landed. Shipped from
3.0 so far: Part A — the visibility framework — and world.ruleset (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.
Config keys (Config/Bridge.cfg)
Host=127.0.0.1
Port=7788
QueueCap=10000
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
Read in Configure() via Config.Get<T>("Bridge.<Key>", default). Key scope is the filename: Bridge.cfg + 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). 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'sfinallythen set the shared_deadflag — 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:
{"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:
<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.rosterforwhitlocktech→ one char, Darrow, slot 0, offline.char.requestby account+slot → full profile: stats, all 58 skills, resists, worn equipment,reqIdechoed.char.requestbyserial:"0x24C"→ byte-identical profile. Both resolution paths agree.vendor.snapshotforseed_000→ its two vendors, held gold, all 40 priced listings each.char.requestfor 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 > capis possible. Darrow (a GM character) reports every skillbase:120, cap:100. The website must not assumebase <= 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 houseSomewhat → FairlywithSetDynamicDecay; the next sweep emitted exactly onehouse.decay, none for the other 28:{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, "builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"} -
Economy supply emitted a snapshot each interval:
{"kind":"economy.supply","accounts":51,"gold":…}. -
Vitals correctly emitted nothing — the seeded characters are all offline (
NetState == null). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
Notes from the run:
regionis null for the seeded houses — they sit outside any named region. The handler guardsRegion,Sign, andOwnerfor 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.csdoes_Entries[key] = e), which is why the scaffolding appends test overrides to the end ofBridge.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
AccountLoginEventArgscarries a plaintextPasswordand is a veto hook (Accepted,RejectReason). We read the username and IP only; the password never leaves the process.FastWalkEventArgs.BlockedandAccountLogin.Acceptedgate game logic. Handlers are read-only; they never set these.OnPropertyChangedpasses a nullMobilefrom one of its three raise sites, soaudit.settolerates an unknown staffer.- The property is
FastWalkEventArgs.NetState, not.State.
10. Operational notes
- Commands and timers do not run during a world save.
TimerMainearly-continues whileWorld.Saving || World.Loading(Server/Timer.cs:322), and the main loop is insideWorld.Saveanyway. Alink.confirmarriving 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. zlibwapi64DllNotFoundExceptionalready 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
websiteUserIdon every player event once the account tag is read atLoginand cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.