Files
docs/link/RESEARCH.md
Claude c87ca88d86 Phase 0: fix runtime script compilation
ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release`
with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both
OutputPath and DefineConstants on Configuration|Platform == Release|x64, so
under the server's own build the DLL landed in Scripts/bin/Release/ (while the
core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went
undefined (XmlSpawner compiled its non-ServUO branches).

Compile() also never checks the build's exit code before Assembly.LoadFrom, so
the failure was silent and the stale DLL reloaded. Runtime script compilation
had had no effect since 2026-05-30.

Condition both property groups on Configuration alone. Server.csproj is left
alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\
would make the boot-time build try to overwrite the running ServUO.exe.

Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build
succeeded." and loads 206208 items, 42771 mobiles.

Also adds the implementation plan, the measured performance budget, the test
scaffolding used to produce it (seeder + probe, both default-off), and the
record of shard repairs that had to precede any of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 04:34:18 -05:00

545 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# ServUO ⇄ External Service Bridge — Research Findings
**Status:** Research only, no implementation.
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
**Date:** 2026-07-07
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
---
## 0. Note on empirical verification
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
- run `EventSink.ServerStarted` and AutoSave against real state.
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
1. The source pins the threading model exactly (call sites shown below), and
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
```
Server.EventSink.InvokeClientVersionReceived(...)
Server.Network.MessagePump.HandleReceive(NetState ns)
Server.Network.MessagePump.Slice()
Server.Core.Main(String[] args)
```
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
---
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
The §1§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
```
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
```
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
### II.2 Tracking targets → concrete hooks (and the gaps)
| Target | Hook | Freq | Notes / caveats |
|--------|------|------|-----------------|
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 1530 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
| **Gold (per player)** | `EventSink.AccountGoldChange` | LowMed | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
### II.3 The `[link` command flow
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
1. **Register** in your plugin's `Initialize()`:
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 68 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
### II.4 Revised flags for THIS architecture
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 1530 s snapshot of online players; don't expect push-on-change.
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
---
## PART III — Player-vendor tracking, IDOC, town-crier news, config
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
### III.1 Player-vendor sales — the one place you need a small core touch
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
```csharp
// PlayerVendorGumps.cs ~line 81-96 (existing code)
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
...
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
```
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
3. Bridge subscribes in `Initialize` like any other event.
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
### III.2 Config-editable sweep interval (and other tunables)
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
```csharp
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
```
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.099.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (3060 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
**State & re-baseline (important, since the plugin now holds state):**
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
| Field | Source |
|-------|--------|
| house serial | `house.Serial` |
| decay from → to | tracked dict → `house.DecayLevel` |
| coords + facet | `house.X/Y/Z`, `house.Map` |
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
| house name | `house.Sign?.GetName()` (`:2108`) |
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
Example emit:
```jsonc
{ "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","account":"PerryAdimn"},
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
"collapseEta":"2026-07-08T..." }
```
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
### III.4 Town-crier news pushed from the website (inbound → main thread)
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
### III.5 Updated capability map
| Capability | Mechanism | Core touch? | Runs on |
|-----------|-----------|:-----------:|---------|
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
---
## PART IV — Full character profiles (armor / weapons / skills / everything)
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
| Tier | Contents | When emitted |
|------|----------|--------------|
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
### IV.3 Character-profile schema (sketch)
```jsonc
{
"kind": "char.profile",
"account": "PerryAdimn", "slot": 0,
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
"body": 400, "hue": 33770, "online": true,
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
"equipment": [
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
"name":"Ancient Plate","armor":{"baseRating":45},
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
],
"vendorsOwned": 3 // future (IV.5)
}
```
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
### IV.4 Gotchas for the profile export
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
### IV.5 Vendor stats per player (the "eventually")
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
### IV.6 Updated capability map (supersedes III.5)
| Capability | Mechanism | Core touch? | Cadence |
|-----------|-----------|:-----------:|---------|
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 3060 s sweep |
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
---
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
```
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
```
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
| Order | Mechanism | How you hook it |
|------:|-----------|-----------------|
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
| 2 | `World.Load()` | (world state restored from `Saves/`) |
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
**Shutdown.** Two clean hooks, both fire on the Core thread:
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
---
## 2. EventSink — available events, subscription, and frequency
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
### Events relevant to a state-export bridge
| Event | Fires when | Frequency | Notes for export |
|-------|-----------|-----------|------------------|
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
| `CreatureDeath` | NPC/creature dies | **MediumHigh** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
| `AggressiveAction` | Combat aggression declared | MediumHigh | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | LowMedium | Naturally diff-shaped. |
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
### ⚑ Gap flag — events with *no* clean EventSink hook
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
---
## 3. Timers — mechanism and which thread callbacks run on
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
Other properties worth knowing:
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
---
## 4. Object model & serialization — and a diff-friendly state shape
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, >` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
```jsonc
// one line per change, main-thread produced, drained by background writer
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
```
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
---
## 5. Thread-safety rules & marshaling onto the main thread
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
**What *is* safe from a non-main thread:**
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
**Pitfalls specific to this codebase:**
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
---
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
**Outbound (fire-and-forget writer) — the important one:**
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
**Inbound (command listener):**
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
**Pitfalls:**
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
---
## 7. Flags against the bridge architecture
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
---
## Appendix A — Drop-in empirical probe (run this yourself)
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
```csharp
using System;
using System.Threading;
using Server;
namespace Server.Custom
{
public static class BridgeThreadProbe
{
private static void Log(string where)
{
var t = Thread.CurrentThread;
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
where, t.ManagedThreadId, t.Name);
}
public static void Initialize()
{
Log("Initialize"); // expect: Core Thread
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
EventSink.Login += e => Log("Login (client)"); // needs a client login
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
// Cross-thread marshal test: schedule from a raw background thread,
// confirm the callback still lands on Core Thread.
new Thread(() =>
{
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
}).Start();
// Core.Slice runs every main-loop iteration; log once then detach.
Slice one = null;
one = () => { Log("Core.Slice"); Core.Slice -= one; };
Core.Slice += one; // expect: Core Thread
}
}
}
```
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
---
## Key source references
| Topic | File:line |
|-------|-----------|
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
| Serial identity | `Server/Serial.cs:7-33` |
| Serialization API | `Server/Serialization.cs:17+` |
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |