- Repoint doc-to-doc references from the old docs/ prefix to the co-located sibling filenames (docs live under link/ here now). - Replace the personal ServUO checkout path (C:\Users\...\servuo) with a <servuo> placeholder throughout.
545 lines
55 KiB
Markdown
545 lines
55 KiB
Markdown
# 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, `<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 15–30 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` | Low–Med | ✔ **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. 6–8 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 15–30 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.0–99.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** (30–60 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 | 30–60 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 | **Medium–High** | 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 | Medium–High | `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 | Low–Medium | 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` |
|