# 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 `x64` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch. --- ## 4. Plugin layout All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug. | File | Responsibility | |------|----------------| | `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. | | `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. | | `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. | | `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. | | `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. | | `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. | | `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. | **Lifecycle** (`Server/Main.cs:544-562`, all Core thread): `Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`. Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake. --- ## 5. Data catalog — everything the shard can give you 91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified. ### 5.1 Session & identity | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. | | Player offline | `EventSink.Logout` | low | Pair with Login. | | Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. | | Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. | | Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. | | Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. | ### 5.2 Character state | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. | | **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. | | Skill progression | `SkillGain` | medium | High-signal. Ship it. | | Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. | | Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. | | Hunger | `HungerChanged` | low | Cosmetic; optional. | > ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free. > > ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7. ### 5.3 Economy & commerce | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). | | **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. | | NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** | | NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. | | **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. | | Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. | | Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. | | Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. | > ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.** Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold. ### 5.4 Housing / IDOC | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. | Hold a `Dictionary` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot. **The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected: - `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`). - **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest. - Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`. Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`. ### 5.5 Combat, death, PvP | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Player death | `PlayerDeath` | low | | | Murder | `PlayerMurdered` | low | High-signal for the website. | | Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. | | Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. | | Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. | > ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink. ### 5.6 Progression & activity `QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`. `OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`. > ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`. > > Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object. ### 5.7 Cheat detection & staff audit This is where the catalog earns its keep, and it is thin in the original doc. | Signal | Hook | Why | |--------|------|-----| | **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. | | **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. | | Staff commands | `EventSink.Command` | Every command invocation. | | **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. | | Gold flow | `AccountGoldChange` | Reconcile against sale stream. | ### 5.8 Lifecycle `ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`. `AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.** ### 5.9 Known gaps (no clean hook) - **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.** - **Per-hit combat damage.** Virtual overrides only. - **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only. - **Stat/vital deltas.** Sweep. (Cheap — see §5.2.) --- ## 6. The one core edit: `PlayerVendorSale` Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer: ```csharp // PlayerVendorGumps.cs:84-96 leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank int commission = 0; commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100)); m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed ``` At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale. Three edits, then the bridge stays pure-subscription: 1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape). 2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96. 3. Bridge subscribes in `Initialize` like any other event. ~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs. --- ## 7. Wire protocol Newline-delimited JSON, one object per line, `serial` as the primary key. ### Outbound (shard → sidecar) ```jsonc {"t":1752…,"kind":"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. ### 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). | | §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). Nothing below loads until this is done. 1. **Transport.** `BridgeLink`: `TcpClient`, writer thread + bounded queue, reader thread → `Timer.DelayCall`. Emit `ServerStarted` / `Shutdown` / `Crashed` only. Prove the sidecar can restart independently while the shard runs. 2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. 3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers. 4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. 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("Bridge.", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`. --- ## 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.