ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release` with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both OutputPath and DefineConstants on Configuration|Platform == Release|x64, so under the server's own build the DLL landed in Scripts/bin/Release/ (while the core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went undefined (XmlSpawner compiled its non-ServUO branches). Compile() also never checks the build's exit code before Assembly.LoadFrom, so the failure was silent and the stale DLL reloaded. Runtime script compilation had had no effect since 2026-05-30. Condition both property groups on Configuration alone. Server.csproj is left alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\ would make the boot-time build try to overwrite the running ServUO.exe. Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build succeeded." and loads 206208 items, 42771 mobiles. Also adds the implementation plan, the measured performance budget, the test scaffolding used to produce it (seeder + probe, both default-off), and the record of shard repairs that had to precede any of this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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, C:\Users\colby\Desktop\servuo), target framework .NET Framework 4.8 / x64.
Method: Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in §0 — 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 |