- 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.
55 KiB
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 — 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.
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(perScriptCompiler.Compile,Compiler.Dynamic=trueby default), - bind the live game port and load/write your actual
Saves/world (117 mobiles / 2469 items per the last crash report), - run
EventSink.ServerStartedand 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:
-
The source pins the threading model exactly (call sites shown below), and
-
Your own crash log is live evidence.
Crash 6-5-2026-22-38-3.logcontains 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 fromCore.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 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.ClientWebSocketdoes 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 rawTcpClientwith\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.DelayCallto 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 PlayerMobiles 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 againstAccountGoldChange, 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.
- Register in your plugin's
Initialize():CommandSystem.Register("link", AccessLevel.Player, OnLink); [linkhandler (e.Mobile): reade.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), storecode → {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.")
- push
- Website (user logged in there) submits the code → sidecar → ServUO inbound line
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}. - Inbound handler marshals to main thread (
Timer.DelayCall), validates code + TTL, thenaccount.SetTag("WebsiteUserId","9931"), drops the code, and replies{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}. OptionallySendMessagethe player if still online. - 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
- ✔ Threading is a solved problem given the split. Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget +
Timer.DelayCallinbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO. - ⚑ 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.
- ⚑ 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
PlayerVendorSaleEventSink (~15 lines of core instrumentation). It's the one non-drop-in piece. - ⚑ "Economy" needs both a periodic supply snapshot and the flow stream.
AccountGoldChangealone is flow, not total; physical bank coins/checks aren't in it. Do a periodicAccountsTotalCurrencysum for money supply. - ✔ Linking needs no new persistence layer — account tags serialize to
accounts.xmlfor free (II.3). Survives restarts and saves. - ⚑ Commands/inbound don't apply during world saves (§5 pitfall 3, ~every 5 min). A
[link.confirmarriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly. - ⚑ 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). - ⚑ (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:
// 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:
- In
Server/EventSink.cs: declarepublic static event PlayerVendorSaleEventHandler PlayerVendorSale;, anInvokePlayerVendorSale, and aPlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }(copy theValidVendorSellEventArgsshape atEventSink.cs:1508). - In
PlayerVendorGumps.cs, one line right after theHoldGold +=at ~line 96:EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission)); - Bridge subscribes in
Initializelike 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):
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):
DecayLevelenum (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.AllHousesis a static list of every house;Decay_OnTick(BaseHouse.cs:59) already periodically callsCheckDecay()on all of them.- The
DecayLevelgetter 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 currentDecayLevelof every house without emitting — otherwise every house re-announces its current stage on every boot. Optionally emit a singleidoc.snapshotof 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/tolevels so the consumer can tell decay progression from a refresh (owner logged in → level jumps back towardLikeNew;RefreshDecay,BaseHouse.cs). A house leaving IDOC because someone refreshed it is itself a useful signal. house.DecayLevelis 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:
{ "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:
AccountholdsMobile[] m_Mobileswithaccount.Lengthslots andaccount[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.Mobilesonly onDelete(), never on logout. A logged-off character is a liveMobilewithNetState == 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(orm.Player && online) is your online flag. - Stats/vitals (
Server/Mobile.cs):Str/Dex/Int(:8276+),Hits/HitsMax,Mana/ManaMax,Stam/StamMax(:8554+), the five resistsPhysicalResistance…EnergyResistance(:931+),VirtualArmor, plusFame,Karma,Luck,TotalWeight,Title,Body,Hue,Name. - Skills (
Server/Skills.cs):m.SkillsisIEnumerable<Skill>(:1099) withLength+ indexer. EachSkill: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 perLayer);FindItemOnLayer(Layer)(:10545) fetches a slot.Layerenum (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, plusMinDamage/MaxDamage/StrRequirement(weapon) andBaseArmorRating/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)
{
"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 frequentlynull; the real display name isLabelNumber(:3771), a cliloc ID resolved againstData/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 plainName. Send both (name+cliloc) and prefernamewhen 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.requests should be fine (main-thread, fast) but rate-limit at the sidecar. ValuevsBasefor skills:Baseis the trained number;Valueincludes item/temp bonuses (what the client shows in combat). Send both — the site likely wantsBasefor "character sheet" andValuefor "effective."- Read on the main thread only. Everything above touches live
Mobile/Itemstate (§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 VendorItems (item, Price, description), HoldGold, BankAccount, and commission. For a player-facing "my vendors" view, enumerate PlayerVendors 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 buildfails, the core loops asking to retry (Main.cs:525); under-serviceit 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 fromCore.HandleClosed()(Main.cs:313) on normal exit, afterWorld.WaitForWriteCompletion(). Not invoked if_Crashed.EventSink.Crashed— invoked from the unhandled-exception handler (Main.cs:198); gives you anargs.Closevote.- Windows console-close / Ctrl-C routes through
OnConsoleEvent→Kill()→HandleClosed()(Main.cs:254), soShutdownnormally 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.).
⚠️
Movementis the single most dangerous event to naively export.EventSink.InvokeMovementis called fromMobile.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.Blockedgates 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 immediatelyFree()d (see §5). Rules: filter toPlayerMobileat 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
EventSinkfor 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) andMobile.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 fromInitialize. Partial coverage exists viaEventSink.OnItemObtained,EventSink.ContainerDroppedTo, andEventSink.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.
AggressiveActionmarks aggression, not each swing/damage tick. For damage numbers you'd hookMobile.Damage/ weaponOnHitpaths (virtual/override), not an EventSink. - Equip/unequip of items generally.
CheckEquipItemexists (a veto hook), plusEquipMacro/UnequipMacro(macro-triggered only). No clean "item equipped" firehose via EventSink. - Stat/hits/mana/stam changes. No EventSink; these move through the delta/
ProcessDeltaQueuesystem (§4). You'd poll or hookMobiledelta 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") runsTimer.TimerThread.TimerMain(Timer.cs:314). Its only job is bookkeeping: walk the priority buckets, decide which timers are due, and enqueue them into a sharedm_Queue(Timer.cs:354-357). It does not execute callbacks. When anything becomes due it callsCore.Set()(Timer.cs:374) to wake the main loop. - Core / main thread runs
Timer.Slice()(Timer.cs:391, called fromCore.MainatMain.cs:580). This dequeues due timers and callst.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.Slicehas aBreakCount(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.
TimerMainearly-continues whileWorld.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/SaveDataimplementsSerialize(GenericWriter)/Deserialize(GenericReader)plus a serial-taking ctor.Core.VerifySerialization(Main.cs:679) enforces this at boot. GenericWriter/GenericReaderare a versioned, positional binary stream of primitives (ReadInt,ReadString,ReadMobile,ReadPoint3D, …;Serialization.cs:17+). Each object writes anintversion 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; aSaveStrategymay flush bytes to disk on a background thread, guarded bym_DiskWriteHandle(ManualResetEvent,World.cs:29). During a saveWorld.Savingis 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:
// 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 fromSerialize. - Keyed by
Serialso the external service maintains its own mirror and applies deltas. - Emit a periodic/
ServerStartedfull snapshot (iterateWorld.Mobiles/World.Itemson the main thread) as a baseline the deltas layer onto;AfterWorldSaveis 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→Changetakeslock (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
ConcurrentQueueyou own, then letting the main thread drain it — this is literally how the network stack works:MessagePump.m_Queueis aConcurrentQueue<NetState>(MessagePump.cs:14) filled by listener threads and drained byMessagePump.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.DelayCallfrom the pipe thread. On each inbound command, from the pipe read-callback thread callTimer.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 (mindBreakCount). - B — your own
ConcurrentQueue+Core.Slice. Pipe thread enqueues; register a handler on theCore.Slicedelegate (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:
- Pooled event args.
MovementEventArgs(and several others) are recycled via a plainQueuepool andFree()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. - 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).
- Timers pause during save/load. Because
TimerMainskips whileWorld.Saving/World.Loading(Timer.cs:322), inbound commands marshaled viaTimer.DelayCallare 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 insideWorld.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. - 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. - 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. 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 theNamedPipeServerStream/ClientStreamand drains the queue, usingWriteAsync/FlushAsync. One writer = writes stay ordered and you avoid interleaved frames on the pipe. - Use a length-prefixed or newline-delimited framing (
PipeTransmissionMode.Byteis simplest and most portable;Messagemode 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 pipeby 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→ReadAsyncloop, parse a framed command, then marshal to the main thread via pattern A or B from §5. The read thread must not call anyWorld/Mobile/ItemAPI. - 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 controlmaxNumberOfServerInstances. 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.Asynchronousat construction — required for the*Asyncmethods to actually overlap I/O rather than block a thread-pool thread.
Pitfalls:
- Don't
awaitpipe 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 pipeawaits on your dedicated background threads. - Named-pipe ACLs: if the external service runs as a different user/session, set a
PipeSecurityexplicitly or the connect willUnauthorizedAccessException. - First-chance
IOExceptionon client disconnect is normal; log-and-reconnect, don't crash the writer loop.
7. Flags against the bridge architecture
See Part II.4 for the flags that matter to the Rust WS sidecar + tracking/link design. The list below is the original generic set (still valid background).
- ⚑ 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, notInitialize-time subscription. This is the item most likely to change the design. - ⚑
Movement(andItem/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.Movementargs are pooled — copy-out-synchronously is mandatory, not optional. - ⚑ 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. - ✔ Inbound commands can be safely marshaled to the main thread via
Timer.DelayCall(verified thread-safe) or aConcurrentQueuedrained onCore.Slice. The named-pipe approach is not blocked by threading — but: - ⚑ 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. - ⚑ 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. - ⚑ Crash path skips graceful shutdown (§1). The external service must treat pipe EOF as normal and re-handshake; don't assume a clean
Shutdownteardown. - ⚑ 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.
- (Environmental) The
zlibwapi64native-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.)
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 |