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>
This commit is contained in:
322
link/PLAN.md
Normal file
322
link/PLAN.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
|
||||
|
||||
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
|
||||
**Date:** 2026-07-10
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
||||
|
||||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
||||
|
||||
---
|
||||
|
||||
## 1. Measured budget
|
||||
|
||||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
||||
|
||||
| Read | Cost | Payload | Per-unit |
|
||||
|------|------|---------|----------|
|
||||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
||||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
||||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
||||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
||||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
||||
|
||||
Linear extrapolation at the same gear complexity:
|
||||
|
||||
| Scenario | Cost | Verdict |
|
||||
|----------|------|---------|
|
||||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
||||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
||||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
||||
| Economy @ 5000 accounts | 0.06 ms | free |
|
||||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
||||
|
||||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
||||
|
||||
### Caveat on these numbers
|
||||
|
||||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
||||
|
||||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture (confirmed, unchanged)
|
||||
|
||||
```
|
||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||||
```
|
||||
|
||||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
||||
|
||||
Non-negotiable rules, all of which the measurements support:
|
||||
|
||||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
||||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
||||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
||||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
||||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisite: fix the build, or the plugin will not load
|
||||
|
||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
||||
|
||||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
||||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
||||
|
||||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Plugin layout
|
||||
|
||||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
||||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
||||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
||||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
||||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
||||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
||||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
||||
|
||||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
||||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
||||
|
||||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data catalog — everything the shard can give you
|
||||
|
||||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
||||
|
||||
### 5.1 Session & identity
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
||||
|
||||
### 5.2 Character state
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
||||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
||||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
||||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
||||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
||||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
||||
|
||||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
||||
>
|
||||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
||||
|
||||
### 5.3 Economy & commerce
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
||||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
||||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
||||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
||||
|
||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
||||
|
||||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
||||
|
||||
### 5.4 Housing / IDOC
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
||||
|
||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
||||
|
||||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
||||
|
||||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
||||
|
||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
||||
|
||||
### 5.5 Combat, death, PvP
|
||||
|
||||
| Signal | Hook | Freq | Notes |
|
||||
|--------|------|:----:|-------|
|
||||
| Player death | `PlayerDeath` | low | |
|
||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
||||
|
||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
||||
|
||||
### 5.6 Progression & activity
|
||||
|
||||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
||||
|
||||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
||||
|
||||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
||||
>
|
||||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
||||
|
||||
### 5.7 Cheat detection & staff audit
|
||||
|
||||
This is where the catalog earns its keep, and it is thin in the original doc.
|
||||
|
||||
| Signal | Hook | Why |
|
||||
|--------|------|-----|
|
||||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
||||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
||||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
||||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
||||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
||||
|
||||
### 5.8 Lifecycle
|
||||
|
||||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
||||
|
||||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
||||
|
||||
### 5.9 Known gaps (no clean hook)
|
||||
|
||||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
||||
- **Per-hit combat damage.** Virtual overrides only.
|
||||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
||||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
||||
|
||||
---
|
||||
|
||||
## 6. The one core edit: `PlayerVendorSale`
|
||||
|
||||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
||||
|
||||
```csharp
|
||||
// PlayerVendorGumps.cs:84-96
|
||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
||||
int commission = 0;
|
||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
||||
```
|
||||
|
||||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
||||
|
||||
Three edits, then the bridge stays pure-subscription:
|
||||
|
||||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
||||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
||||
3. Bridge subscribes in `Initialize` like any other event.
|
||||
|
||||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire protocol
|
||||
|
||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
||||
|
||||
### Outbound (shard → sidecar)
|
||||
|
||||
```jsonc
|
||||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
||||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
||||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
||||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
||||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
||||
{"t":1752…,"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","acct":"PerryAdimn"},"coOwners":[],
|
||||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
||||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
||||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
||||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
||||
```
|
||||
|
||||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
||||
|
||||
### Inbound (sidecar → shard)
|
||||
|
||||
```jsonc
|
||||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
||||
{"kind":"account.roster","account":"PerryAdimn"}
|
||||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
||||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
||||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
||||
{"kind":"towncrier.remove","id":"n123"}
|
||||
```
|
||||
|
||||
Every inbound handler marshals to the Core thread before touching world state.
|
||||
|
||||
### Item names are clilocs
|
||||
|
||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
||||
|
||||
---
|
||||
|
||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
||||
|
||||
| § | Claim | Reality |
|
||||
|---|-------|---------|
|
||||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
||||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
||||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
||||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
||||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
||||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation phases
|
||||
|
||||
0. **Fix the build** (§3). Nothing below loads until this is done.
|
||||
1. **Transport.** `BridgeLink`: `TcpClient`, writer thread + bounded queue, reader thread → `Timer.DelayCall`. Emit `ServerStarted` / `Shutdown` / `Crashed` only. Prove the sidecar can restart independently while the shard runs.
|
||||
2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`.
|
||||
3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers.
|
||||
4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side.
|
||||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
||||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
||||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
||||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
||||
|
||||
### Config keys (`Config/Bridge.cfg`)
|
||||
|
||||
```ini
|
||||
Host=127.0.0.1
|
||||
Port=7788
|
||||
QueueCap=10000
|
||||
StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
```
|
||||
|
||||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operational notes
|
||||
|
||||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
||||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
||||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
||||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|
||||
Reference in New Issue
Block a user