INTEGRATION.md was written for the window that just closed -- it told integrators the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while already carrying v3 kinds. That guidance is now wrong in the direction that matters, so the version section states 3 (header, /health, ws.hello, the 409 example and the §8 worked example) and replaces the "until then" paragraph with what a v2 integration actually has to do to upgrade: change the constant it sends, and nothing else, because nothing that existed in v2 changed shape. v3.md gains §4.1 for what the bump touches and, more importantly, WHY the website's boot migration is gated on a marker row: schema.sql is re-run on every boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would silently un-pin an operator running an older sidecar. That is the one piece of the cutover a reader could not infer from the code being one constant. Progress tables: 5b done, 6 in review. Co-Authored-By: Claude <noreply@anthropic.com>
554 lines
41 KiB
Markdown
554 lines
41 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, `<servuo>`, net48 / x64, Expansion **EJ**.
|
||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
||
|
||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
||
|
||
---
|
||
|
||
## 1. Measured budget
|
||
|
||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
||
|
||
| Read | Cost | Payload | Per-unit |
|
||
|------|------|---------|----------|
|
||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
||
|
||
Linear extrapolation at the same gear complexity:
|
||
|
||
| Scenario | Cost | Verdict |
|
||
|----------|------|---------|
|
||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
||
| Economy @ 5000 accounts | 0.06 ms | free |
|
||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
||
|
||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
||
|
||
### Caveat on these numbers
|
||
|
||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
||
|
||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
||
|
||
---
|
||
|
||
## 2. Architecture (confirmed, unchanged)
|
||
|
||
```
|
||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||
```
|
||
|
||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
||
|
||
Non-negotiable rules, all of which the measurements support:
|
||
|
||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
||
|
||
---
|
||
|
||
## 3. Prerequisite: fix the build, or the plugin will not load
|
||
|
||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
||
|
||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
||
|
||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
||
|
||
---
|
||
|
||
## 4. Plugin layout
|
||
|
||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
||
|
||
| File | Responsibility |
|
||
|------|----------------|
|
||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
||
|
||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
||
|
||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
||
|
||
---
|
||
|
||
## 5. Data catalog — everything the shard can give you
|
||
|
||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
||
|
||
### 5.1 Session & identity
|
||
|
||
| Signal | Hook | Freq | Notes |
|
||
|--------|------|:----:|-------|
|
||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
||
|
||
### 5.2 Character state
|
||
|
||
| Signal | Hook | Freq | Notes |
|
||
|--------|------|:----:|-------|
|
||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
||
|
||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
||
>
|
||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
||
|
||
### 5.3 Economy & commerce
|
||
|
||
| Signal | Hook | Freq | Notes |
|
||
|--------|------|:----:|-------|
|
||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
||
|
||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
||
|
||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
||
|
||
### 5.4 Housing / IDOC
|
||
|
||
| Signal | Hook | Freq | Notes |
|
||
|--------|------|:----:|-------|
|
||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
||
|
||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
||
|
||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
||
|
||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
||
|
||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
||
|
||
### 5.5 Combat, death, PvP
|
||
|
||
| Signal | Hook | Freq | Notes |
|
||
|--------|------|:----:|-------|
|
||
| Player death | `PlayerDeath` | low | |
|
||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
||
|
||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
||
|
||
### 5.6 Progression & activity
|
||
|
||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
||
|
||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
||
|
||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
||
>
|
||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
||
|
||
### 5.7 Cheat detection & staff audit
|
||
|
||
This is where the catalog earns its keep, and it is thin in the original doc.
|
||
|
||
| Signal | Hook | Why |
|
||
|--------|------|-----|
|
||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
||
|
||
### 5.8 Lifecycle
|
||
|
||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
||
|
||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
||
|
||
### 5.9 Known gaps (no clean hook)
|
||
|
||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
||
- **Per-hit combat damage.** Virtual overrides only.
|
||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
||
|
||
---
|
||
|
||
## 6. The one core edit: `PlayerVendorSale`
|
||
|
||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
||
|
||
```csharp
|
||
// PlayerVendorGumps.cs:84-96
|
||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
||
int commission = 0;
|
||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
||
```
|
||
|
||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
||
|
||
Three edits, then the bridge stays pure-subscription:
|
||
|
||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
||
3. Bridge subscribes in `Initialize` like any other event.
|
||
|
||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
||
|
||
---
|
||
|
||
## 7. Wire protocol
|
||
|
||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||
|
||
### Outbound (shard → sidecar)
|
||
|
||
```jsonc
|
||
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
|
||
"items":206467,"mobiles":42826,"accounts":51}
|
||
{"t":1752…,"kind":"server.shutdown"}
|
||
{"t":1752…,"kind":"server.crashed","error":"…"}
|
||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
||
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
|
||
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
|
||
"region":"Britain","name":"The Silver Anvil",
|
||
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
|
||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
||
```
|
||
|
||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
||
|
||
### Inbound (sidecar → shard)
|
||
|
||
```jsonc
|
||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
||
{"kind":"account.roster","account":"PerryAdimn"}
|
||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
||
{"kind":"towncrier.remove","id":"n123"}
|
||
```
|
||
|
||
Every inbound handler marshals to the Core thread before touching world state.
|
||
|
||
### `server.hello` is per-connection, not per-boot
|
||
|
||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
||
|
||
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
|
||
|
||
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
|
||
|
||
### Item names are clilocs
|
||
|
||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
||
|
||
**Update (3.0).** That recommendation held, and the reason it had to hold turned out to be stronger
|
||
than "avoids a dependency": **ServUO cannot resolve clilocs either.** Every current client ships its
|
||
`Cliloc.*` files compressed, and the bundled `Ultima.StringList` reads only the older plain layout —
|
||
so `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name` on any modern
|
||
shard. The in-game Vendor Search gump has the same gap, which is why `vendor.listing` never calls it.
|
||
Pushing name resolution to the plugin was never an option. See [`v3.md`](v3.md) §8.6 and
|
||
`docs/website/CLILOCS.md` for how the site gets a table instead (the operator converts one from their
|
||
own client, once).
|
||
|
||
---
|
||
|
||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
||
|
||
| § | Claim | Reality |
|
||
|---|-------|---------|
|
||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
||
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
|
||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
||
|
||
---
|
||
|
||
## 9. Implementation phases
|
||
|
||
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
|
||
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
|
||
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
|
||
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
|
||
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
|
||
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
|
||
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
|
||
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
|
||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
||
|
||
**Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning +
|
||
world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework,
|
||
shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from
|
||
3.0 so far: **Part A** — the visibility framework — **`world.ruleset`** ([`v3.md`](v3.md) §5),
|
||
`BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep (it is
|
||
emitted once per connect, like `server.hello`, because shard config changes only when an operator
|
||
edits a file) — the **spawn atlas** ([`v3.md`](v3.md) §6), which is website-only and touches no wire
|
||
at all — and **`points.board`** ([`v3.md`](v3.md) §7), `BridgePoints.cs`, the loyalty/points
|
||
leaderboards. `BridgePoints` is the widest read the bridge performs: ten of ServUO's ~25 point systems
|
||
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
|
||
than sorting, and runs on a deliberately slow 300 s interval.
|
||
|
||
Also shipped: **`vendor.listing`** ([`v3.md`](v3.md) §8), `BridgeMarket.cs`, the shard-wide
|
||
player-vendor index. It introduces the one sweep pattern the bridge did not previously have — an
|
||
**amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for
|
||
tens of houses or a fixed set of point systems and is not fine for a world of shops whose inventories
|
||
recurse into containers. `BridgeMarket` inventories at most `MarketSweepBatch` vendors per tick from
|
||
a persistent cursor, so the per-tick cost is bounded by the batch rather than by world size, and full
|
||
coverage takes `ceil(vendors / batch) x MarketSweepSeconds`. Measured at **15.4 ms** for a cold tick
|
||
of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff), on a shard of 209k
|
||
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
|
||
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
|
||
|
||
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
|
||
3** and the coordinated `edge` → `main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
|
||
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
|
||
but changes nothing that already existed in v2 — so the operator-visible break is limited to
|
||
re-pinning the version, which the website does for itself in a one-shot boot migration.
|
||
|
||
### Config keys (`Config/Bridge.cfg`)
|
||
|
||
```ini
|
||
Host=127.0.0.1
|
||
Port=7788
|
||
QueueCap=10000
|
||
StatSweepSeconds=30
|
||
DecaySweepSeconds=60
|
||
EconomySweepSeconds=300
|
||
```
|
||
|
||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
||
|
||
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
|
||
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
|
||
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` and `Market*`
|
||
blocks).
|
||
**`servuo-plugins/overlay/Config/Bridge.cfg`
|
||
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
|
||
|
||
---
|
||
|
||
## 11. Phase 1 acceptance
|
||
|
||
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
|
||
|
||
| Claim | Evidence |
|
||
|-------|----------|
|
||
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
|
||
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
|
||
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. |
|
||
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
|
||
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
|
||
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
|
||
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
|
||
|
||
Two defects were found this way and fixed:
|
||
|
||
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
|
||
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
|
||
|
||
---
|
||
|
||
## 17. Phase 7 acceptance
|
||
|
||
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
|
||
|
||
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
|
||
|
||
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
|
||
|
||
```json
|
||
{"kind":"vendor.sale","committed":true,
|
||
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
|
||
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
|
||
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
|
||
"itemId":3937,"amount":1,"price":69819,"commission":0}
|
||
```
|
||
|
||
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
|
||
|
||
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
|
||
|
||
---
|
||
|
||
## 16. Phase 6 acceptance
|
||
|
||
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
|
||
|
||
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
|
||
|
||
| Sent | Reply | Crier list |
|
||
|------|-------|------------|
|
||
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
|
||
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
|
||
| `remove n1` | `towncrier.ok` | entry gone |
|
||
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
|
||
|
||
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
|
||
|
||
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
|
||
|
||
No core changes; this closes the pure-plugin inbound work.
|
||
|
||
---
|
||
|
||
## 15. Phase 5 acceptance
|
||
|
||
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
|
||
|
||
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
|
||
|
||
```
|
||
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
|
||
-> link.confirm code=77M9TK websiteUserId=web-9931
|
||
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
|
||
-> link.confirm code=BADCOD ...
|
||
<- link.error code=BADCOD reason="unknown or expired code"
|
||
```
|
||
|
||
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
|
||
|
||
```xml
|
||
<tags>
|
||
<tag name="WebsiteUserId">web-9931</tag>
|
||
</tags>
|
||
```
|
||
|
||
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
|
||
|
||
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
|
||
|
||
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
|
||
|
||
---
|
||
|
||
## 14. Phase 4 acceptance
|
||
|
||
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
|
||
|
||
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
|
||
|
||
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
|
||
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
|
||
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
|
||
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
|
||
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
|
||
|
||
Two things the real character surfaced that the seeded dummies could not:
|
||
|
||
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
|
||
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
|
||
|
||
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
|
||
|
||
---
|
||
|
||
## 13. Phase 3 acceptance
|
||
|
||
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
|
||
|
||
Verified on the seeded world with intervals cut to 8 s:
|
||
|
||
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
|
||
|
||
```json
|
||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
||
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
|
||
```
|
||
|
||
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
|
||
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
|
||
|
||
Notes from the run:
|
||
|
||
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
|
||
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
|
||
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
|
||
|
||
---
|
||
|
||
## 12. Phase 2 acceptance
|
||
|
||
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
|
||
|
||
```
|
||
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
|
||
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
|
||
{"kind":"karma.change",...,"old":7903,"new":7853}
|
||
{"kind":"world.save.before"}
|
||
{"kind":"world.save.after","items":206312,"mobiles":42826}
|
||
```
|
||
|
||
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
|
||
|
||
### The finding: `SkillGain` fires for NPCs, hard
|
||
|
||
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
|
||
|
||
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
|
||
|
||
### Safety facts baked into the handlers
|
||
|
||
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
|
||
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
|
||
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
|
||
- The property is `FastWalkEventArgs.NetState`, not `.State`.
|
||
|
||
---
|
||
|
||
## 10. Operational notes
|
||
|
||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|