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

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

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

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

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

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

55 KiB
Raw Permalink Blame History

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 (per ScriptCompiler.Compile, Compiler.Dynamic=true by default),
  • bind the live game port and load/write your actual Saves/ world (117 mobiles / 2469 items per the last crash report),
  • run EventSink.ServerStarted and AutoSave against real state.

That's outward-facing and hard to reverse, so it needs your go-ahead. It turned out not to be necessary for the core threading claims, because:

  1. The source pins the threading model exactly (call sites shown below), and

  2. Your own crash log is live evidence. Crash 6-5-2026-22-38-3.log contains this stack:

    Server.EventSink.InvokeClientVersionReceived(...)
    Server.Network.MessagePump.HandleReceive(NetState ns)
    Server.Network.MessagePump.Slice()
    Server.Core.Main(String[] args)
    

    That is a network-triggered EventSink handler executing inside MessagePump.Slice(), called directly from Core.Main — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.

If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in Appendix A and start the shard, or tell me to boot it.

⚠️ Unrelated but worth flagging: that crash was DllNotFoundException: zlibwapi64. The DLL is present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.


PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)

Confirmed architecture (from you): a Rust sidecar holds a bidirectional WebSocket connection and exposes a JSON endpoint the website consumes. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game [link command that ties a game account to a website account.

The §1§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto your design and supersedes the old §6/§7.

II.1 Transport: put the WebSocket in Rust, keep the C# side dumb

ServUO plugin (C#, net48)  ──local loopback, newline-JSON──►  Rust sidecar  ──WebSocket/JSON──►  website
        (main-thread events)  ◄──inbound commands (link, etc.)──┘   (owns WS, buffering, auth, fan-out)

Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (127.0.0.1), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.

  • System.Net.WebSockets.ClientWebSocket does exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw TcpClient with \n-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the sidecar restart independently without touching the shard.
  • Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust tokio::net::TcpListener ↔ C# TcpClient).
  • This split is exactly what §5 demands. All backpressure, reconnect, retry, website fan-out, and schema validation live in Rust. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → Timer.DelayCall to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.

Framing: newline-delimited JSON objects ({...}\n), PipeTransmissionMode/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.

II.2 Tracking targets → concrete hooks (and the gaps)

Target Hook Freq Notes / caveats
Player online / identity EventSink.Login / Logout Low Snapshot Account.Username, char name, Mobile.Serial, Map, Location. Best per-player anchor.
Player stats (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) No per-change EventSink Strategy: full snapshot on Login, then a periodic sweep (every 1530 s) of online 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 LowMed AccountGold is ENABLED on this shard (expansion EJ ≥ TOL, CurrentExpansion.cs:20). Args give IAccount + OldAmount/NewAmount (TotalCurrency, a double). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row.
Overall economy / money supply Periodic account sweep + flow events Low Money supply = periodic sum of TotalCurrency across all Accounts (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money velocity/flow = the AccountGoldChange + vendor-sale event stream. Sidecar aggregates both.
NPC vendor — player buys EventSink.ValidVendorPurchase Med Args: Mobile (buyer), Vendor, Bought (IEntity/item), AmountPerUnit. Total = AmountPerUnit × stack Amount. Raised from GenericBuy.cs:379.
NPC vendor — player sells EventSink.ValidVendorSell Med Args mirror above (Sold, AmountPerUnit). Raised from BaseVendor.cs:2209.
Player vendor sales No EventSink (gap) Med Player-vendor buys go through PlayerVendor.TryToBuy (PlayerVendor.cs:447), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales."
Account ↔ website link Account.Username + Account.SetTag/GetTag SetTag("WebsiteUserId", id) persists to accounts.xml across restarts (Account.cs:1078,1093). No schema/DB work needed on the C# side.

⚠️ The Valid* vendor events are validation-stage veto hooks, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against AccountGoldChange, or hook the actual completion path. Never block or throw in these handlers — you're inside the transaction path.

Prefix is [ (Commands.cs:131), so [link is registered directly. Everything below runs on the main thread except the socket I/O.

  1. Register in your plugin's Initialize(): CommandSystem.Register("link", AccessLevel.Player, OnLink);
  2. [link handler (e.Mobile): read e.Mobile.Account as Account. If already tagged (GetTag("WebsiteUserId") != null), tell them so. Otherwise generate a short, one-time, expiring code (e.g. 68 chars, 5-min TTL), store code → {accountUsername, expiry} in an in-memory dict (main thread), and:
    • push {"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"} to the sidecar, and
    • e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")
  3. Website (user logged in there) submits the code → sidecar → ServUO inbound line {"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}.
  4. Inbound handler marshals to main thread (Timer.DelayCall), validates code + TTL, then account.SetTag("WebsiteUserId","9931"), drops the code, and replies {"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}. Optionally SendMessage the player if still online.
  5. Thereafter, every player event you emit can carry the resolved websiteUserId (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.

Security notes: codes one-time + short-TTL; the link socket is loopback-only (bind 127.0.0.1, never 0.0.0.0); the account write happens on the main thread; rate-limit [link per account to avoid code spam. Treat websiteUserId from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.

II.4 Revised flags for THIS architecture

  1. ✔ Threading is a solved problem given the split. Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + Timer.DelayCall inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
  2. ⚑ Player stats have no change-event → sweep-and-diff in the sidecar (II.2). Budget for a 1530 s snapshot of online players; don't expect push-on-change.
  3. ⚑ Player-vendor sales aren't covered by any EventSink (II.2) — RESOLVED in §III.1. You've confirmed this stream is critical (economy + cheat detection), so add the small PlayerVendorSale EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
  4. ⚑ "Economy" needs both a periodic supply snapshot and the flow stream. AccountGoldChange alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic Accounts TotalCurrency sum for money supply.
  5. ✔ Linking needs no new persistence layer — account tags serialize to accounts.xml for free (II.3). Survives restarts and saves.
  6. ⚑ Commands/inbound don't apply during world saves (§5 pitfall 3, ~every 5 min). A [link.confirm arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
  7. ⚑ Crash path skips Shutdown (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs [link).
  8. ⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.

PART III — Player-vendor tracking, IDOC, town-crier news, config

Follow-ups you added: (1) player-vendor tracking is critical (economy balance + admin cheat detection); (2) the 30 s stat sweep must be config-editable; (3) hook IDOC / house decay; (4) town criers receive news pushed from the website.

III.1 Player-vendor sales — the one place you need a small core touch

There is genuinely no EventSink on the player-vendor buy path (confirmed). The purchase completes in PlayerVendorBuyGump.OnResponse (Scripts/Gumps/PlayerVendorGumps.cs:41), specifically at the gold transfer:

// PlayerVendorGumps.cs ~line 81-96 (existing code)
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice);   // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice);               // ...and bank
...
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission;                     // seller credited  ◄── sale is now committed

At that point every field cheat-detection wants is in scope: buyer (from), vendor (m_Vendor), vendor owner (m_Vendor.Owner — the real player who profits), item (m_VI.Item, incl. Serial, type, Amount), price (m_VI.Price), and commission. This is better data than the NPC-vendor Valid* events (which lack owner + commission), and unlike them it fires on a committed sale, not a validation stage.

Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events. Three tiny edits, then the bridge stays pure-subscription like everything else:

  1. In Server/EventSink.cs: declare public static event PlayerVendorSaleEventHandler PlayerVendorSale;, an InvokePlayerVendorSale, and a PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission } (copy the ValidVendorSellEventArgs shape at EventSink.cs:1508).
  2. In PlayerVendorGumps.cs, one line right after the HoldGold += at ~line 96: EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
  3. Bridge subscribes in Initialize like any other event.

This is the single spot where the bridge can't be pure drop-in — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every PlayerVendor's inventory + HoldGold — but that can't attribute the buyer, which is exactly what cheat detection needs, so it's a poor substitute.)

For cheat detection specifically, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note m_Vendor.Owner + from.Account are the two identities that matter; both are readable synchronously in the handler (main thread).

III.2 Config-editable sweep interval (and other tunables)

Use ServUO's own config system (Server/Config.cs), which reads Config/*.cfg. Read tunables in Configure() (runs before world load):

StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);

Drop a Config/Bridge.cfg with Bridge.StatSweepSeconds=30 etc. Config.Get<T> handles int/TimeSpan/bool. Make the sweep timer re-readable on demand (a [bridge reload admin command that re-reads config and re-arms the Timer) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.

III.3 IDOC / house decay — sweep BaseHouse.AllHouses, emit on transition

Also no EventSink here. The model (Scripts/Multis/BaseHouse.cs):

  • DecayLevel enum (BaseHouse.cs:4341): Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending. IDOC = 95.099.9% of the decay period elapsed (GetOldDecayLevel, BaseHouse.cs:211-213); Collapsed = 100%.
  • BaseHouse.AllHouses is a static list of every house; Decay_OnTick (BaseHouse.cs:59) already periodically calls CheckDecay() on all of them.
  • The DecayLevel getter has internal transition detection (m_LastDecayLevel, BaseHouse.cs:193) but it's private and only invalidates the sign — not exposed as an event.

Decision: emit on transition only, tracked plugin-side. A low-frequency sweep (3060 s, config per III.2) over BaseHouse.AllHouses reads house.DecayLevel on the main thread. The plugin holds a Dictionary<Serial, DecayLevel> of last-known levels and emits only when a house's level changes — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.

State & re-baseline (important, since the plugin now holds state):

  • The last-known map is in-memory and resets on restart. On ServerStarted (§1), do a silent baseline pass: populate the dictionary from the current DecayLevel of every house without emitting — otherwise every house re-announces its current stage on every boot. Optionally emit a single idoc.snapshot of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
  • Emit direction matters for cheat/economy signals: include both from/to levels so the consumer can tell decay progression from a refresh (owner logged in → level jumps back toward LikeNew; RefreshDecay, BaseHouse.cs). A house leaving IDOC because someone refreshed it is itself a useful signal.
  • house.DecayLevel is a computed property — read it once per house per sweep into a local, don't call it repeatedly.

Payload (home location state you asked for — all readable synchronously in the sweep): BaseHouse is a BaseMulti (an item), so it has Serial, Location/X/Y/Z, Map. Plus:

Field Source
house serial house.Serial
decay from → to tracked dict → house.DecayLevel
coords + facet house.X/Y/Z, house.Map
stable landmark (where a player stands) house.BanLocation (BaseHouse.cs:3637)
region / area name house.Region (:3672) → Region.Name
house name house.Sign?.GetName() (:2108)
owner house.Owner (:3564) → serial + Owner.Account.Username (may be null if abandoned)
co-owners / friends house.CoOwners, house.Friends (:3679-3680) — serials/accounts
built / last refreshed house.BuiltOn, house.LastRefreshed (:3786,:66)
time-to-collapse house.NextDecayStage and/or derive from LastRefreshed + DecayPeriod

Example emit:

{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
  "map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
  "region":"Britain", "name":"The Silver Anvil",
  "owner":{"serial":"0x1A2B","account":"PerryAdimn"},
  "coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
  "collapseEta":"2026-07-08T..." }

This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against Owner/Sign/Region being null (abandoned or mid-demolition houses).

III.4 Town-crier news pushed from the website (inbound → main thread)

Clean API, no core changes needed: GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration) (Scripts/Mobiles/NPCs/TownCrier.cs:96) posts a global entry that every town crier announces until it expires; RemoveEntry(entry) pulls it early. AddEntry returns the TownCrierEntry.

Flow: website publishes news → sidecar → ServUO inbound {"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}marshal to main thread (Timer.DelayCall) → var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec)); and stash id → e so a later {"kind":"towncrier.remove","id":"n123"} can call RemoveEntry(e).

Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as [link (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.

III.5 Updated capability map

Capability Mechanism Core touch? Runs on
Player online/stats/gold EventSink + 30 s sweep (§II.2) No main thread
NPC vendor sales ValidVendorPurchase/Sell No main thread
Player-vendor sales new PlayerVendorSale EventSink (§III.1) Yes, ~15 lines main thread
[link account linking CommandSystem.Register + account tags (§II.3) No main thread
IDOC / house decay sweep BaseHouse.AllHouses on transition (§III.3) No main thread
Town-crier news (inbound) GlobalTownCrierEntryList.AddEntry (§III.4) No main thread (marshaled)
Config tuning Config.Get + Config/Bridge.cfg (§III.2) No Configure()

Net: everything you listed is doable, and only player-vendor sales requires a (small, idiomatic) core edit — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.


PART IV — Full character profiles (armor / weapons / skills / everything)

You want the site's player endpoint to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for up to 5 characters per account, online or offline, and eventually their vendor stats. The object model supports all of it; the design question is how to ship it without turning the 30 s sweep into a firehose.

IV.1 It's all on the live Mobile — and offline chars stay resident

  • Account → characters: Account holds Mobile[] m_Mobiles with account.Length slots and account[index] (Account.cs:592,598); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
  • Offline = still in memory. Mobiles are removed from World.Mobiles only on Delete(), never on logout. A logged-off character is a live Mobile with NetState == null; all its gear/skills/stats are intact. → the bridge can build a full profile for any character at any time, online or offline — exactly what "see my characters from the website" needs. m.NetState != null (or m.Player && online) is your online flag.
  • Stats/vitals (Server/Mobile.cs): Str/Dex/Int (:8276+), Hits/HitsMax, Mana/ManaMax, Stam/StamMax (:8554+), the five resists PhysicalResistance…EnergyResistance (:931+), VirtualArmor, plus Fame, Karma, Luck, TotalWeight, Title, Body, Hue, Name.
  • Skills (Server/Skills.cs): m.Skills is IEnumerable<Skill> (:1099) with Length + indexer. Each Skill: SkillName, Base, Value (base + item/temp bonuses), Cap, Lock (Skills.cs:259,322,373,350,269). Emit all ~58.
  • Worn equipment: m.Items (Mobile.cs:6695) is the list of equipped items (one per Layer); FindItemOnLayer(Layer) (:10545) fetches a slot. Layer enum (Item.cs:25) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
  • Weapon/armor detail (BaseWeapon.cs, BaseArmor.cs): rich AOS attribute objects — Attributes (AosAttributes), WeaponAttributes, ArmorAttributes, AosElementDamages, ExtendedWeaponAttributes, NegativeAttributes, plus MinDamage/MaxDamage/StrRequirement (weapon) and BaseArmorRating/resists (armor). Each attribute bag exposes an enum indexerAosAttributes[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 frequently null; the real display name is LabelNumber (:3771), a cliloc ID resolved against Data/Cliloc.enu. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items do carry a plain Name. Send both (name + cliloc) and prefer name when present.
  • ⚑ Don't recurse the whole backpack/bank by default. A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship worn equipment fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
  • Building a profile allocates (skill list + per-item mod scans). Keep it on-demand / on-change, not in the 30 s vitals sweep. A burst of char.requests should be fine (main-thread, fast) but rate-limit at the sidecar.
  • Value vs Base for skills: Base is the trained number; Value includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants Base for "character sheet" and Value for "effective."
  • Read on the main thread only. Everything above touches live Mobile/Item state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.

IV.5 Vendor stats per player (the "eventually")

Ties into §III.1. A character/account can own player vendors; each PlayerVendor has Owner, an inventory of 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 3060 s sweep
Town-crier news (inbound) GlobalTownCrierEntryList.AddEntry (§III.4) No inbound
Config tuning Config.Get + Config/Bridge.cfg (§III.2) No Configure()

Net: the full-character requirement adds no new core touches — it's all readable off live objects. The only structural addition it implies is an inbound request/response channel (already needed for [link and town-crier), used here as char.request / account.roster, with the sidecar caching profiles for the website.


1. Script lifecycle — how Scripts/Custom loads and hooks startup/shutdown

Compilation model (this is a modern ServUO, not the old CodeDom one). Server/ScriptCompiler.cs:18 → when Compiler.Dynamic is true (default), the core literally runs:

dotnet build "Scripts/Scripts.csproj" -c Release   (or Debug)

then Assembly.LoadFrom("Scripts.dll") (ScriptCompiler.cs:63). Scripts.csproj is SDK-style (Microsoft.NET.Sdk) with default globbing, so every .cs anywhere under Scripts/ — including Scripts/Custom/ — is compiled automatically. There is no per-file registration. A new plugin = drop a .cs file in Scripts/Custom/ and restart (or rebuild Scripts.dll).

  • If dotnet build fails, the core loops asking to retry (Main.cs:525); under -service it just returns/exits. So a compile error in your bridge file takes the whole shard down at boot — keep the plugin minimal and defensive.
  • -service/non-interactive suppresses the console prompt (Main.cs:386).

Lifecycle entry points (in boot order, all on the Core thread — Main.cs:544-562):

Order Mechanism How you hook it
1 ScriptCompiler.Invoke("Configure") Any public static void Configure() in any script type
2 World.Load() (world state restored from Saves/)
3 ScriptCompiler.Invoke("Initialize") Any public static void Initialize() in any script type
4 EventSink.InvokeServerStarted() EventSink.ServerStarted += ...

Invoke() (ScriptCompiler.cs:87) reflects over all loaded types, finds the named public static method, sorts by [CallPriority(n)] (Server/Attributes.cs:27), and calls them. Configure runs before World.Load; Initialize runs after. → Register EventSink handlers in Initialize (or Configure); read config in Configure. Canonical example already in-tree: Scripts/Misc/WeightOverloading.cs:15 subscribes to EventSink.Movement inside Initialize().

Shutdown. Two clean hooks, both fire on the Core thread:

  • EventSink.Shutdown — invoked from Core.HandleClosed() (Main.cs:313) on normal exit, after World.WaitForWriteCompletion(). Not invoked if _Crashed.
  • EventSink.Crashed — invoked from the unhandled-exception handler (Main.cs:198); gives you an args.Close vote.
  • Windows console-close / Ctrl-C routes through OnConsoleEventKill()HandleClosed() (Main.cs:254), so Shutdown normally still fires.

Bridge implication: your named-pipe writer/listener should be created in Initialize (or on ServerStarted) and torn down in Shutdown. Don't assume Shutdown runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.


2. EventSink — available events, subscription, and frequency

Subscription pattern: EventSink.<Name> += handler; (static multicast delegates, declared Server/EventSink.cs:1692-1784). Handlers are plain delegates invoked synchronously via EventSink.Invoke<Name>(args) from the code path that raises them. Every handler runs on whatever thread raised the event — in practice always the Core thread (movement, speech, combat, login all originate from packet handling in MessagePump.Slice() or from the main-loop delta processing).

Events relevant to a state-export bridge

Event Fires when Frequency Notes for export
Login Player fully in-world Low Best "player online" signal; gives Mobile.
Logout Player disconnect (in-world) Low Pair with Login.
Connected / Disconnected Socket up/down Low Lower-level than Login/Logout (fires for char-select too).
PlayerDeath Player dies Low PlayerDeathEventArgs (mobile, corpse-ish context).
CreatureDeath NPC/creature dies MediumHigh Fires for every mob kill; on a busy shard this is a firehose. Filter/aggregate.
Speech Player/NPC speech Medium SpeechEventArgs; raised from Mobile.cs:5114. Includes NPC/system speech.
Movement Any mobile takes a step Very High See ⚠️ below.
AggressiveAction Combat aggression declared MediumHigh AggressiveActionEventArgs (EventSink.cs:372). Not per-swing, per aggression state change.
ItemCreated / ItemDeleted Item constructed/deleted Very High Fires for every item incl. transient/loot/internal. Huge volume.
MobileCreated / MobileDeleted Mobile constructed/deleted High Same caveat as items.
SkillGain, CraftSuccess, ResourceHarvestSuccess Progression Medium Good "interesting player activity" signals.
AccountGoldChange, FameChange, KarmaChange Economy/rep deltas LowMedium Naturally diff-shaped.
QuestComplete, JoinGuild, TameCreature, PlayerMurdered Milestone events Low Cheap, high-signal — ideal to export.
WorldSave / BeforeWorldSave / AfterWorldSave Save cycle Low (~5 min) Natural checkpoint boundary for the bridge.
ServerStarted / Shutdown / Crashed Lifecycle Once Bridge connect/disconnect signaling.

Full list of 70+ events at EventSink.cs:1692-1784 (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).

⚠️ Movement is the single most dangerous event to naively export. EventSink.InvokeMovement is called from Mobile.InternalOnMove (Mobile.cs:3029), which runs for every mobile that takes a step — all NPCs, all creatures, not just players. On a populated shard that's thousands of invocations/second. It is synchronous and cancellable (args.Blocked gates the move), so your handler sits inside the movement decision path — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is pooled and immediately Free()d (see §5). Rules: filter to PlayerMobile at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.

⚑ Gap flag — events with no clean EventSink hook

These are things a bridge spec commonly wants to export but that do not have a first-class EventSink:

  • Item pickup / drop / "lift". There is no EventSink for picking up or dropping items. It's handled by virtual methods on the objects: Item.OnDragLift / Item.OnDragDrop / Item.OnDroppedInto (Item.cs:4647,2157,5060) and Mobile.OnDragDrop / Mobile.OnDragLift (Mobile.cs:10877,10949). To observe these you must override them on your own subclasses or patch base classes — you can't subscribe globally from Initialize. Partial coverage exists via EventSink.OnItemObtained, EventSink.ContainerDroppedTo, and EventSink.CorpseLoot, but none of these is a universal "player moved item X from A to B" hook. This is the biggest event-availability gap for the bridge.
  • Per-hit combat damage. AggressiveAction marks aggression, not each swing/damage tick. For damage numbers you'd hook Mobile.Damage / weapon OnHit paths (virtual/override), not an EventSink.
  • Equip/unequip of items generally. CheckEquipItem exists (a veto hook), plus EquipMacro/UnequipMacro (macro-triggered only). No clean "item equipped" firehose via EventSink.
  • Stat/hits/mana/stam changes. No EventSink; these move through the delta/ProcessDeltaQueue system (§4). You'd poll or hook Mobile delta handling.

3. Timers — mechanism and which thread callbacks run on

This is the crux, and the answer is unambiguous. ServUO splits timers into a scheduler thread and main-thread execution:

  • Timer Thread (Main.cs:429-434, named "Timer Thread") runs Timer.TimerThread.TimerMain (Timer.cs:314). Its only job is bookkeeping: walk the priority buckets, decide which timers are due, and enqueue them into a shared m_Queue (Timer.cs:354-357). It does not execute callbacks. When anything becomes due it calls Core.Set() (Timer.cs:374) to wake the main loop.
  • Core / main thread runs Timer.Slice() (Timer.cs:391, called from Core.Main at Main.cs:580). This dequeues due timers and calls t.OnTick() on the main thread (Timer.cs:409).

→ Every Timer / Timer.DelayCall callback executes on the Core (main) game thread. The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs Thread.CurrentThread from a Timer tick and from Initialize — they match, and match the network path shown in your crash log).

Other properties worth knowing:

  • Timers are bucketed by TimerPriority (EveryTick, TenMS, … OneMinute); priority is auto-computed from delay/interval (Timer.cs:468).
  • Timer.Slice has a BreakCount (default 20000, Timer.cs:383) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
  • Timers do not fire during world save/load. TimerMain early-continues while World.Loading || World.Saving (Timer.cs:322). See §5 — this directly affects inbound-command latency.

4. Object model & serialization — and a diff-friendly state shape

Identity. Serial (Server/Serial.cs:7) is a struct wrapping a single int. Mobiles get serials < 0x40000000; items start at 0x40000000 (Serial.cs:11-12); IsItem/IsMobile test that boundary. Serials are stable for an object's lifetime and are the natural primary key for any external mirror of state. World.Mobiles / World.Items are Dictionary<Serial, …> (World.cs:19-20) — O(1) lookup by serial from the main thread.

ServUO's own persistence (Server/Serialization.cs, Server/World.cs):

  • Every Item/Mobile/SaveData implements Serialize(GenericWriter) / Deserialize(GenericReader) plus a serial-taking ctor. Core.VerifySerialization (Main.cs:679) enforces this at boot.
  • GenericWriter/GenericReader are a versioned, positional binary stream of primitives (ReadInt, ReadString, ReadMobile, ReadPoint3D, …; Serialization.cs:17+). Each object writes an int version first, then fields in a fixed order. It is compact but not diff-friendly: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the entire object every save.
  • Saves are orchestrated by World.Save (World.cs:1102) on the main thread; a SaveStrategy may flush bytes to disk on a background thread, guarded by m_DiskWriteHandle (ManualResetEvent, World.cs:29). During a save World.Saving is true and object add/delete is deferred into _addQueue/_deleteQueue (World.cs:1247-1280).

Recommendation for a diff-friendly representation (do NOT reuse the save system): The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an event-sourced delta keyed by Serial:

// one line per change, main-thread produced, drained by background writer
{ "t": 172..., "kind": "mob.move",  "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
  • Derive fields from the EventSink args + the live object at event time (e.g. m.X/Y/Z/Map/Serial), not from Serialize.
  • Keyed by Serial so the external service maintains its own mirror and applies deltas.
  • Emit a periodic/ServerStarted full snapshot (iterate World.Mobiles/World.Items on the main thread) as a baseline the deltas layer onto; AfterWorldSave is a natural snapshot boundary.
  • Keep each record to primitives copied out synchronously on the main thread (pooled args, live objects mutate — see §5).

5. Thread-safety rules & marshaling onto the main thread

Golden rule (RunUO/ServUO-wide): the world — World.Mobiles, World.Items, every Mobile/Item/Account, the delta queues, packet sends — is single-threaded and owned by the Core thread. None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; Mobile.ProcessDeltaQueue/Item.ProcessDeltaQueue run on the main loop (Main.cs:577-578) with no cross-thread guard.

What is safe from a non-main thread:

  • Core.Set() — wake the main loop (AutoResetEvent, Main.cs:324).
  • Timer.DelayCall(...) — verified safe cross-thread. DelayCallStartTimerThread.AddTimerChange takes lock (m_Changed) and signals the timer thread (Timer.cs:243-251,883-892). The scheduling call is lock-protected; the callback then runs on the main thread. This is the intended marshaling primitive.
  • Pushing onto a ConcurrentQueue you own, then letting the main thread drain it — this is literally how the network stack works: MessagePump.m_Queue is a ConcurrentQueue<NetState> (MessagePump.cs:14) filled by listener threads and drained by MessagePump.Slice() on the main thread (MessagePump.cs:113).

The two marshaling patterns for inbound named-pipe commands (pick one; pattern A is simplest):

  • A — Timer.DelayCall from the pipe thread. On each inbound command, from the pipe read-callback thread call Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd)). The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind BreakCount).
  • B — your own ConcurrentQueue + Core.Slice. Pipe thread enqueues; register a handler on the Core.Slice delegate (Main.cs:41,586) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.

Pitfalls specific to this codebase:

  1. Pooled event args. MovementEventArgs (and several others) are recycled via a plain Queue pool and Free()d immediately after the event (EventSink.cs:802-834). The pool itself is not thread-safe (main-thread-only). Never hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
  2. Blocking the main thread = stalling the shard. EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe write that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer must be fire-and-forget onto a background queue (see §6).
  3. Timers pause during save/load. Because TimerMain skips while World.Saving/World.Loading (Timer.cs:322), inbound commands marshaled via Timer.DelayCall are deferred until the save finishes (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside World.Save, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
  4. Reentrancy / world-mutation during save. Adding/deleting entities during a save is deferred to safety queues and logs a warning (World.cs:988,1247). If a bridge command spawns/deletes, it may silently queue.
  5. Crash path skips Shutdown. Don't rely on graceful pipe teardown (§1).

6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O

Superseded by Part II.1. For the Rust WS sidecar design the recommended C↔Rust link is loopback TCP + newline-JSON, not a named pipe, and ServUO should not speak WebSocket. The non-blocking principles below still apply verbatim to whichever local transport you pick.

Target is net48 (Scripts.csproj:3), so you have System.IO.Pipes / System.Net.Sockets with async/await and Begin/End APIs, but not the newer IAsyncEnumerable/CancellationToken niceties of modern .NET. Design around that.

Outbound (fire-and-forget writer) — the important one:

  • The producer is the Core thread (event handlers). It must never touch the pipe directly. Producer does only: format the delta record → ConcurrentQueue.Enqueue → return. This is a non-blocking, allocation-only operation.
  • A single dedicated background writer thread (or a long-running Task) owns the NamedPipeServerStream/ClientStream and drains the queue, using WriteAsync/FlushAsync. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
  • Use a length-prefixed or newline-delimited framing (PipeTransmissionMode.Byte is simplest and most portable; Message mode has size/OS quirks). Don't rely on message boundaries.
  • Bound the queue. If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
  • Handle IOException/Broken pipe by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.

Inbound (command listener):

  • A separate background thread/loop WaitForConnectionAsyncReadAsync loop, parse a framed command, then marshal to the main thread via pattern A or B from §5. The read thread must not call any World/Mobile/Item API.
  • Server vs client: making ServUO the NamedPipeServerStream (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control maxNumberOfServerInstances. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
  • Set PipeOptions.Asynchronous at construction — required for the *Async methods to actually overlap I/O rather than block a thread-pool thread.

Pitfalls:

  • Don't await pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe awaits on your dedicated background threads.
  • Named-pipe ACLs: if the external service runs as a different user/session, set a PipeSecurity explicitly or the connect will UnauthorizedAccessException.
  • First-chance IOException on client disconnect is normal; log-and-reconnect, don't crash the writer loop.

7. Flags against the bridge architecture

See Part II.4 for the flags that matter to the Rust WS sidecar + tracking/link design. The list below is the original generic set (still valid background).

  1. ⚑ Item pickup/drop has no EventSink (§2 gap). If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on virtual methods (Item.OnDragLift/OnDragDrop/OnDroppedInto, Mobile.OnDragDrop). Exporting them cleanly requires base-class overrides/patching, not Initialize-time subscription. This is the item most likely to change the design.
  2. Movement (and Item/MobileCreated/Deleted) are firehoses on the main thread (§2, §5). Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. Movement args are pooled — copy-out-synchronously is mandatory, not optional.
  3. ⚑ Everything you'd export runs on the single Core thread (§3, §5). The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on Core.Main.
  4. ✔ Inbound commands can be safely marshaled to the main thread via Timer.DelayCall (verified thread-safe) or a ConcurrentQueue drained on Core.Slice. The named-pipe approach is not blocked by threading — but:
  5. ⚑ Commands don't apply during world saves (§5 pitfall 3). Timers pause and the main loop is inside World.Save (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
  6. ⚑ Don't mirror state via ServUO's serializer (§4). If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by Serial + periodic snapshots.
  7. ⚑ Crash path skips graceful shutdown (§1). The external service must treat pipe EOF as normal and re-handshake; don't assume a clean Shutdown teardown.
  8. ⚑ A compile error in the bridge plugin fails the whole shard boot (§1). Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
  9. (Environmental) The zlibwapi64 native-load crash (§0) already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.

Appendix A — Drop-in empirical probe (run this yourself)

Save as Scripts/Custom/BridgeThreadProbe.cs, start the shard, watch the console. No game client needed — it proves the thread identity of Initialize, ServerStarted, a Timer tick, and Core.Slice. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)

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