Files
docs/link/RESEARCH.md
Claude 5880bc9f1e docs: fix cross-refs and scrub machine paths
- Repoint doc-to-doc references from the old docs/ prefix to the co-located
  sibling filenames (docs live under link/ here now).
- Replace the personal ServUO checkout path (C:\Users\...\servuo) with a
  <servuo> placeholder throughout.
2026-07-18 02:32:49 -05:00

55 KiB
Raw 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, <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