The reset stamp is only meaningful while it is ahead of LastMoved. Clearing
it from the LastMoved setter covers every path that records a real move
(SetLastMoved, MoveToWorld, direct writes) and lets VerifyCompactInfo free
the CompactInfo instead of holding it forever for any item that was ever
unfrozen. Deserialization applies the same guard, since minute rounding can
land the stamp on LastMoved.
The setter deliberately does not touch scheduler registration: MoveToWorld
assigns LastMoved while parent/map are mid-transition and defers
registration until its state is final.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A GM flipping Movable (or Visible) back on for a long-frozen item registered
it with a deadline computed from its stale LastMoved, so the scheduler
deleted it on the next tick. The old save-time decay sweep had the same
semantics but hid them behind the save cadence.
Introduce DecayResetTime (CompactInfo-backed, persisted as delta minutes
under a new SaveFlag with an Item version bump): the decay countdown now
runs from the later of LastMoved and DecayResetTime. RestartDecay() stamps
it only when the item can decay and the stamp extends the deadline, so hot
paths with a fresh LastMoved allocate nothing.
- Movable/Visible/Spawner setters restart the countdown instead of
registering a stale deadline
- The region-refusal retry in DecayScheduler restarts the countdown without
rewriting LastMoved, which is reserved for actual moves
- A raw Map assignment (e.g. props) now counts as a move: it stamps
LastMoved and enrolls/withdraws the item, closing the gap where an item
moved out of Map.Internal via the setter never decayed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
### Summary
* Removes player constructed as a requirement for BODs.
* When two items stack and they don't match player constructed flags, the resulting stack loses the flag.
## Problem
The late-wake detector added in #2559 suspends idle sleeping on perfectly healthy hosts. The visible symptom is this Warning firing periodically on stable machines:
> This host returned a 2ms idle wait at least 8ms late 2 time(s) in the last second; idle sleeping suspended for 5000ms
Demoting it to Debug would hide the symptom but not the cost: every one of those lines means the shard dropped idle sleeping for 5s and burned a full core for no reason. The detector is what was mis-tuned.
## Cause 1 — lateness was a count, not a rate
An idle loop performs **~400–500 sleeps per second** (2ms each, bounded by the 8ms wheel tick). The trip condition was `late > 1` across two consecutive one-second samples — a **0.4% tail-outlier rate**. A co-tenant burst, a page fault, or another process changing the system timer resolution clears that bar on a healthy host.
A host that genuinely cannot schedule the process — throttled burstable vCPU — returns *most* of its waits late. Signal and noise were two orders of magnitude apart, and the check sat in the noise.
Now gated on the proportion, with the absolute count kept as a floor:
```csharp
if (late <= _lateWakeThreshold) { _consecutiveBadSamples = 0; return; } // floor
if (late * 100 < sleeps * _lateWakePercent) { _consecutiveBadSamples = 0; return; } // rate
```
New `server.lateWakePercent` (default `10`). The floor is what keeps a window with only a handful of sleeps from tripping on a meaningless percentage; `server.lateWakeThreshold` keeps its existing meaning.
## Cause 2 — GC pauses were charged to the host
`dev-docs/debugging-event-loop.md` already documents that the GC collects preferentially **during idle sleeps** — that is the natural pause point it looks for. So the detector was systematically measuring the GC's chosen pause point and billing it to the host's scheduler. Not an occasional coincidence; a designed-in one.
```csharp
var collections = GC.CollectionCount(1);
NetState.WaitForCompletion(requested);
...
if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections)
```
Gen1 (which counts gen2 with it) rather than gen0 — gen0 pauses don't approach the 8ms `TickRate` bar anyway, and gating on them would discard useful samples. The second read short-circuits behind the overshoot test, so the common path costs **one** `GC.CollectionCount` per sleep: an internal counter read, single-digit nanoseconds, ~500/sec.
## Cause 3 — every backoff logged at Warning
Tiered to the escalation that already existed, since a single suspension is recoverable and not something an operator can act on:
| Backoff | Level |
|---|---|
| 1–2 | `Debug` |
| 3–5 | `Warning` (now includes the sleep count and "for the Nth time running") |
| ceiling | `Error`, unchanged |
| recovery | `Information` (new) |
Each backoff doubles the suspension, so every line is already a distinct escalation step — no further rate limiting needed.
## Drive-by
The `BackoffResetAfterCleanMs` reset only ran on the path to a *new* backoff, making it unreachable for a host that recovered for good — such a host never cleared its escalation or re-armed `_loggedBackoffCeiling`. It now runs on every health sample, which is also what makes the new recovery line reachable.
## Testing
Full solution builds clean, 0 warnings. No tests added: the state is private static in `Core` coupled to `_tickCount` with no injection point, and nothing covered it before — adding a seam purely to test it seemed worse than the gap. Happy to add one if reviewers disagree.
## Why
`PlayerConstructed` is per-instance provenance, and #2574 put it on every crafted item — including potions, arrows and other stackables. Stack operations were written when no item carried provenance of any kind, so they treated two piles of the same graphic as interchangeable.
**Merging** keeps the receiving stack's value. Dropping bought potions onto a crafted stack made the whole pile count as crafted; the reverse order erased it. Which one happened was decided by drag direction alone.
**Splitting** rebuilds one half in `Mobile.LiftItemDupe`, which copies a fixed list of fields rather than going through `Dupe`/`CopyProperties`. `PlayerConstructed` was not on that list, so dragging part of a pile off stripped the new half. Worth calling out: `[IgnoreDupe]` does **not** govern this path — it only applies to `Dupe()`. Reasoning "the field isn't `[IgnoreDupe]`, so it copies" is wrong here.
## Changes
- `Item.CanStackWith` compares `PlayerConstructed`, so crafted and non-crafted never merge into one indistinguishable pile.
- `Mobile.LiftItemDupe` copies `PlayerConstructed` onto the remainder, so a split cannot produce halves that disagree about what they are.
Refusing to merge is the whole fix. A stack has nowhere to record provenance, so the only coherent behaviour is to keep the two piles apart rather than pick a winner.
## What this deliberately does not do
Paths that genuinely **virtualize** an item — pouring from a `PotionKeg`, for one — rebuild it without the flag, and the result is simply treated as not crafted. That is accepted rather than worked around; the alternative is threading provenance through every count-based container, which buys little. The keg stores a `Held` int rather than a stack, so nothing there depends on merging and nothing breaks.
`CommodityDeed` is unaffected — it holds the real `Commodity` item rather than a count, so the flag rides along.
## Player-visible effect
Crafted potions and arrows will no longer stack with bought or looted ones. That is the intended invariant, and it is the reason the flag can be trusted at all.
## Tests
7 new tests in `Server.Tests`: both merge directions, the matching-provenance case, split copying, and the split/re-merge round trip.
`Server.Tests` **822 passing**, `UOContent.Tests` **701 passing**, build clean with 0 warnings.
Follow-up to #2573. That change made `SmallBOD.EndCombine` require a player-crafted item, but it could only read provenance off `BaseArmor`, `BaseWeapon` and `BaseClothing`, because those are the only three classes that track it — hence the hand-enumerated `armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? weapon?.PlayerConstructed ?? false`.
The gap is structural rather than cosmetic. `PlayerConstructed` is set inside each base's `OnCraft`, so it can only ever reach types implementing `ICraftable`. Most craftables do not — the tinkering catalogue alone is largely plain `Item` subclasses — so any rule keyed on "was this actually crafted" has nothing to key on for those types.
## What changed
Provenance moves to `Item` and is stamped centrally in `CraftItem`, immediately after the item is constructed and before the `ICraftable` dispatch, covering both the AOS and T2A craft paths. The three `OnCraft` overrides drop their now-redundant assignment and inherit `Item`'s property, so no call site outside them changes — `Resmelt` and `SalvageBag` still read `armor.PlayerConstructed` and still compile unchanged. `SmallBOD`'s three-way null-coalescing chain collapses to `item.PlayerConstructed`.
`OnCraft` is only ever invoked from `CraftItem` (the other three call sites are `base.OnCraft` chaining), so removing those assignments has no other reachable effect.
## Storage cost: none
`Item`'s `SaveFlag` word is written as a fixed-width `int`, not an encoded one, so occupying bit `0x08000000` changes no record lengths. Items that are not player-constructed serialize byte for byte as before, and crafted ones differ by a single bit in a field already being written.
`Item` itself needs no version bump: a bare `SaveFlag` bit is self-describing, so records written before it existed lack it and read `false`.
## Version bumps
The three content classes do need one, since removing a serialized field changes their layout:
| Class | Version | Field removed |
|---|---|---|
| `BaseArmor` | 9 → 10 | 24 (was last, nothing renumbered) |
| `BaseClothing` | 7 → 8 | 7 (fields 8–10 shift down) |
| `BaseWeapon` | 10 → 11 | 26 (fields 27–30 shift down) |
Each gets a `MigrateFrom` for its previous version that assigns the old bool to the inherited property, so existing crafted armour, weapons and clothing keep their provenance across the upgrade. `Item.Deserialize` runs first and reads the absent bit as `false`, then the migration overwrites it — the generated `Deserialize` calls `base.Deserialize` before dispatching, so the ordering holds. `BaseWeapon` had no migrations file and gains one.
The renumbering is not stylistic: the generator requires contiguous field ordering and rejects a hole with `SG3005: Expected field 'Crafter' with order 7 but found 8`.
New schema JSONs (`BaseArmor.v10`, `BaseClothing.v8`, `BaseWeapon.v11`) are generated by `ModernUOSchemaGenerator` and committed alongside.
## One thing worth a second opinion
The new property is a plain auto-property on `Item`, so it does not call `this.MarkDirty()` the way the codegen setters it replaces did. `MarkDirty` is currently a no-op (`// TODO: Add dirty tracking back`) and no property in `Item.cs` calls it, so this matches the file as it stands — but it is worth noting if dirty tracking comes back.
## Verification
Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`).
Follow-ups to #2559, from a review of the ported idle-sleep/scheduler-health changes.
### Fixes
- **`NetState.IsIdle` omitted `_pendingDisconnects`** — `Slice()` drains five queues; the property checked four. The other deferred work (`_connectingQueue`, alive checks, movement throttle) is time-gated and correctly excluded; the disconnect queue was the only ready-work omission. Impact was bounded (≤ one idle wait of delay), but the property's contract is "sleeping cannot strand pending work".
- **Neither new setting was clamped** (`Main.cs`):
- `server.lateWakeThreshold: -1` made `late <= threshold` false for every sample even at zero late wakes, so from the second sample on, sleeping was re-suspended every second, forever — a permanent full-core spin whose only trace was a nonsense warning ("… at least 8ms late 0 time(s)").
- `server.eventLoopIdleWaitMs: -1` disabled sleeping while the admin gump reported **Healthy** (it tested `== 0`).
- Both now clamp to `>= 0` and log a warning naming the configured value. `-1` is a natural thing to reach for given the sibling key's doc says "set very high to disable".
- **World snapshots were misattributed to `StolenMs`** — `World.Snapshot` ran outside all five profiler phases, so a 3-second save inside a sample read as ~75% stolen, and `debugging-event-loop.md` teaches stolen = "the host ran something else". The diagnostic pointed operators at buying dedicated CPU for their own largest loop-thread stall. Saves now land in a new `WorldSnapshot` phase; `[LoopStats` iterates `PhaseCount` generically, so the report and CSV pick it up with no changes.
- **Admin gump conflated host-forced spin with configured spin** — when the startup probe finds no high-resolution wait support it zeroes the idle wait, after which the gump said "Spinning (configured)" and the operator's config said 2. New `Core.IdleSleepUnsupported` property; the gump now shows "Spinning - host cannot honor short waits" as a distinct fourth verdict. A genuinely configured 0 still reads "configured" (the probe only runs when the configured value was > 0).
- **The backoff-ceiling `Error` logged once per process lifetime** — `_loggedBackoffCeiling` never reset, and at the ceiling the method returns before the `Warning`, so a host that recovered (>60s clean streak) and later degraded back to the ceiling never re-logged the one operator-actionable message. The flag now resets with the clean-streak escalation reset.
- **Removed the unreachable "already suspended, extend" branch** — no sleeps occur while suspended, so `_lateWakes` stays 0 and every suspended sample early-returns before reaching it; with the threshold clamped it can never fire. If sleep gating ever changes, the normal path handles the case by counting a fresh episode.
`dev-docs/debugging-event-loop.md` updated to match (phase list + gump verdict table).
### Verification
- `dotnet build` clean (0 warnings) both normally and with `-p:EventLoopProfiling=true` (the snapshot phase only becomes live IL under the profiling flag).
## Problem
`RunEventLoop` span through its body regardless of whether there was anything to do — ~10% of a desktop core for an empty shard, and ~70% of a core on a 3 vCPU VPS. A process that never idles is exactly what burstable vCPU plans throttle, which is how this surfaced: lag spikes that went away when the operator bought more cores. The spin also denied the GC its natural pause points, so memory climbed until a world save forced a collection — alarming in task manager, harmless in practice, and a recurring source of "is my server leaking?" reports.
## Result
Windows desktop, real world of **190,728 items / 33,158 mobiles**, no players, saves and prebake off, three consecutive runs:
| | Legacy spin | Idle sleeping |
|---|---|---|
| **CPU** | 10.42 – 10.50% of one core | **0.78 – 1.00%** |
| **Tick lag** (peak/15s) | 4–10 ms | 5–11 ms |
**~10× less CPU with tick lag unchanged** — the CPU came free rather than being traded for latency. Slower hosts gain proportionally more. Spin mode (`server.eventLoopIdleWaitMs=0`) independently gained **7× the iterations per core** (1.19M → 8.3M cycles/sec) from the ring's AcceptEx rework.
## How
The loop blocks in `NetState.WaitForCompletion` whenever every queue it drains is empty (all the drains are bounded, so leftovers keep it awake). Receive completions, new connections, and cross-thread `LoopContext.Post` (via the ring's sticky `Wake()`) are all in the wait set, so sleeping adds no latency to any of them. Only timer-driven logic sees wheel lag, bounded by the idle wait.
**Health is measured at the only place sleeping can cause harm.** A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can never miss a deadline — the only failure mode is the host returning the wait late. That overshoot is measured on every sleep (one extra timestamp read; production's entire accounting cost), and an escalating backoff suspends sleeping when it persists. By construction, server work — saves, heavy staff commands, deep timer callbacks — cannot trip it, so the warning means exactly one thing: *the host is not scheduling the process promptly*, with two known remedies (dedicated CPU, or `=0`). Hosts with no high-resolution wait mechanism at all are detected once at startup and spin instead.
**CPS is removed.** `Core.CyclesPerSecond`/`AverageCPS` measured nothing actionable before and became actively misleading once the loop sleeps (the rate is set by the sleep, not by shard health). The admin gump's Performance page now shows the verdict instead: `Healthy` / `Sleep suspended (host)` / `Spinning (configured)`.
## Configuration
| Setting | Default | Meaning |
|---|---|---|
| `server.eventLoopIdleWaitMs` | `2` | Longest idle block. Measured across 1/2/4/8 ms, 2 is where the trade stops being free. `0` = never sleep: ~98% of a core, zero scheduling overhead — for large shards on dedicated CPU. |
| `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before sleeping backs off. Raise for jittery hosts; very high disables the backoff. |
## Diagnostics (compiled out by default)
`dotnet build -p:EventLoopProfiling=true` compiles in `EventLoopProfiler` — every hook is `[Conditional("EVENT_LOOP_PROFILING")]`, so normal builds contain zero profiling IL. The profiling build decomposes each second of wall time into **work (per loop phase) / sleep / GC pause / stolen residual**, keeps ~15 minutes of history in a ring buffer, and the `[LoopStats` command prints the last minute and dumps the full history to CSV. `dev-docs/debugging-event-loop.md` is the diagnosis guide (for humans and AI): what production already tells you, when to flip the profiling build, the signature table for host-steal vs deep-processing vs GC vs wake bugs, why dotnet-trace comes last, and the GC/RAM "leak" misconception.
## Verification
- 815 Server.Tests green; both build configurations compile.
- Docker echo harness green on epoll and io_uring (ping-pong mode); kqueue verified manually on an M1 Max.
- A/B measurements and per-change numbers: `measure/event-loop` branch.
## Notes
The full measurement harness and vendored ring sources used to develop this live on the [`measure/event-loop`](https://github.com/modernuo/ModernUO/tree/measure/event-loop) branch, kept for future loop work.
## Why
An Argon2 verify is **~8.9 ms of frozen world per login attempt** — more than half a 16 ms frame. Failed attempts cost exactly the same as successful ones, by design, so a credential-stuffing flood is a full-cost stall per packet without needing valid credentials. `SetPassword` derives a hash too, so `[password`, the admin gump and account creation each pay the same.
## What the measurement says
Off-loading does not delete the cost, it relocates it. Three things stay on the loop:
| Component | Measured |
|---|---:|
| Inline verify (today) | **8.92 ms** |
| Dispatch to the worker | 210 ns |
| Drain the continuation off `LoopContext` | 13 ns |
| Loop's own work slowed by shared-L3 eviction | **0.05 – 5.44 ms** |
Net gain **3.5 – 8.9 ms** of on-loop time per login. Harness in `ModernUO-Benchmarks` (`Benchmarks/Argon2OffLoop/`): it models the loop as a dependent-load pointer chase swept across working-set sizes, which is an upper bound on cache-latency sensitivity, and copies `EventLoopContext` so the hand-off cost is the real one.
Two results shaped the design:
- **The contention tax peaks in the middle of the working-set range**, not at the top — 5.44 ms at 8 MiB (a quarter of this chip's L3), but 0.76 ms at 30 MiB and 0.10 ms at 256 KiB. A tiny hot set has nothing in L3 to lose; a huge one is already DRAM-bound.
- **Per-login tax falls as concurrency rises** (5.44 → 2.56 → 1.60 ms at 1/2/4 hashers) while *total* loop damage rises. Contention is shared, not additive, so a login rush is not the disaster case — a single login is.
## Why exactly one worker
It is load-bearing three times over, which is also why it must not quietly become a pool:
- **Cost bound.** Off-loop loses to inline only if a hash steals ~82% of the loop's throughput. One hasher contending for one core leaves the loop ~50%. **A single background hasher cannot cost the loop more than the inline verify under any scheduling regime**, which is what lets the measurement hold on hardware we cannot inspect — AMD, VPS, oversubscribed VM. Four hashers drop the loop to ~20% and break it.
- **Memory.** Exactly one hashing arena is live at a time whatever the login volume.
- **Ordering.** Writes apply in dispatch order *only* because a single thread drains FIFO. A second worker would need ordering reintroduced; `WritesApplyInDispatchOrder` fails if that happens.
Throughput is ~110 verifies/sec. Only loop time matters, not login latency, so head-of-line blocking during a rush costs nothing.
## Making every protection safe off-thread
The worker was initially Argon2-only. That was the right call for the wrong reason — it was blamed on Argon2's salt RNG, which is a stateless syscall wrapper and was never a problem. The real blockers were elsewhere, and both are fixed at the source:
| Protection | Was | Now |
|---|---|---|
| MD5/SHA1/SHA2 | shared `HashAlgorithm.ComputeHash`, which carries the running digest across `HashCore`/`HashFinal` through process-wide singletons | static `HashData` into a `stackalloc` span — no state, no allocation, identical bytes |
| PBKDF2 | `Utility.RandomMinMax` → shared `System.Random`, thread-unsafe *and* game state | `RandomNumberGenerator.GetInt32`, matching the salt beside it |
| Argon2 | already safe (`Verify` is static + stackalloc) | unchanged, singleton reused |
Literal digests are pinned in a test **before** the change and still pass after it. These are compared as strings against every account database, so any casing or encoding drift would lock out every SHA and MD5 account at once.
With all three safe, the worker no longer knows which algorithm it runs and the dispatch conditions collapse to "is off-loop available".
## Correctness
- **Phrase derivation** moves to `AccountSecurity.DerivePhrase`, so verification (stored algorithm's rule) and rehash (target algorithm's rule) cannot disagree. Deriving with the wrong one is the shape of the lockout fixed in #2562.
- **Liveness** is checked at dequeue *and* at apply — a connection can drop while queued or while the result sits in the loop queue. A job with no connection attached, such as an admin password change, runs regardless.
- **Queue overflow rejects** a login rather than verifying inline; steering work back onto the loop is what a flood wants. A password change instead falls back to hashing inline, because unlike a login it must not be dropped.
- **Shutdown and crash** both just stop the thread, and pending jobs are dropped. No save is initiated once shutdown begins — saving is the operator's choice up front, via the admin gump's save/no-save variants, and `WaitForWriteCompletion` honours one already in flight — so a write applied during teardown would reach no disk. The crash path needs its own subscription because `HandleClosed` skips `InvokeShutdown` when crashed.
## Bounding
`MaxPending` is 4096 — a backstop, not a flood defense. `SentFirstPacket` holds a connection to one pending verify and the engine caps connections at 4096, so the queue is already bounded by construction and this can only trip if that invariant breaks. A cap low enough to blunt an attack would reject real players first; during a mass reconnect they *are* the queue. Flood defense belongs at the connection layer.
The real DoS improvement is elsewhere: today every attempt stalls the world, and after this a flood occupies one core while the loop keeps ticking.
## Gate
Release builds on 4+ cores. Below that there is no spare core to move work to, so off-loading buys nothing by construction; `DEBUG` is excluded because dev boxes and test shards have few logins. Both modes call the same code — the gate only chooses where it runs.
## Engine change
One property, `AccountLoginEventArgs.Deferred`, so a subscriber can say "no verdict yet". `EventSink.AccountLogin` is `Action<...>` with no continuation, and the packet handler replies in the same call. Approved separately since it touches `Projects/Server/`.
## Docs
`dev-docs/threading-model.md` and the threading skill gain a vetted-workers section. The forbidden-patterns table bans `new Thread`, `ConcurrentQueue<T>`, `Interlocked` and `volatile` in `UOContent`, and its exceptions covered only `Projects/Server/` — the existing Advanced Search fan-out already sat outside it. The new section leads with proving the need (measure on-loop time, not wall-clock; gate on core count; record the measurement), keeps game logic on the loop via chunking, and documents the hand-off protocol in both directions.
## Testing
698 UOContent tests, 810 Server tests, Release build clean.
Covered: verify and rehash outcomes, phrase rules for SHA1/SHA2 vs Argon2, stored-format stability for MD5/SHA1/SHA2, jobs with no connection attached, and dispatch ordering through the real queue. The liveness and ordering guards are mutation-verified.
## What
`Dictionary<K,V>.Remove` and `HashSet<T>.Remove` do not bump the collection's version, so removing an entry during a `foreach` does not invalidate the enumerator. A number of loops were still paying for a `PooledRefQueue`/`PooledRefList` to collect keys and drain them in a second pass. This drops those guards.
## Why it's safe
Verified against .NET 10.0.10 rather than taken on trust, since the documented guarantee covers only `Dictionary<TKey,TValue>.Remove` while several of these call sites are `HashSet<T>` or enumerate `.Keys`/`.Values`:
| Case | Result |
|---|---|
| `Dictionary` foreach + `Remove` | safe, all entries visited |
| `Dictionary.Keys` / `.Values` foreach + `Remove` | safe, all entries visited |
| `HashSet` foreach + `Remove` | safe, all entries visited |
| `Dictionary` foreach + `Remove` **then `Add`** | throws `InvalidOperationException` |
Reflection on `_version` confirms the mechanism: neither `Dictionary.Remove` nor `HashSet.Remove` touches it. Because `Remove` never bumps the version, the `Keys` and `Values` enumerators are just as safe as the dictionary's own, even though only `Dictionary.Remove` documents the behaviour. No entries were skipped in any case.
The `HashSet` half is confirmed by [stephentoub on dotnet/dotnet-api-docs#8177](https://github.com/dotnet/dotnet-api-docs/issues/8177#issuecomment-1167251052): *"Both HashSet and Dictionary have been improved to support removal during enumeration. The docs may just benefit from updating."* The gap is in the documentation, not the runtime.
`Remove` followed by `Add` in the same enumeration still throws. That is the line this PR does not cross.
## Guards removed
`VisibilityList`, `ChampionTitleSystem`, `Channel`, `BombingRun`, `Ruleset`, `PuzzleChest`, `RaceChangeGump`, `StepCache`, `PlayerMurderSystem`, `VirtueSystem`, `ProjectedItem`, `StaminaSystem`, `AIGroupMovement`, `PromotedGuard`, `AutoDenylist`, `LoginAllowlist`, `AntiMacroSystem`, `DetectHidden`.
Both collection kinds are covered: `Dictionary` (including loops over `.Keys` and `.Values`) and `HashSet` (`ProjectedItem._active`, `PlayerMurderSystem._contextTerms`, `StaminaSystem._resetHash`). In `StaminaSystem.ResetTimer` the `Count == queue.Count → Clear()` branch goes away with the queue — it only existed to avoid paying for N individual removes.
Where the collection supports it, `Contains` + `Remove` and `TryGetValue` + `Remove` also collapse into a single lookup (`if (list.Remove(x))`, `if (m_Pending.Remove(ns, out var state))`).
`Utility.Tidy<K,V>` keeps its two branches: when `K` is serializable the value is not inspected, otherwise the value is. Only the serializable side may be cast, so `Dictionary<Mobile, int>` and `Dictionary<Mobile, string>` stay valid.
## Deliberately unchanged
**`BaseCreature.LoyaltyTimer.OnTick`** keeps its deferred-delete queue. Removing from `World.Mobiles` while enumerating it is safe, but `Mobile.Delete()` is not a `Remove` — it runs `OnDelete`/`OnAfterDelete`, the `OnParentDeleted` cascade over the creature's pack, `DropHolding()`, and region and guild callbacks. Anything in that surface that constructs a `Mobile` is an `Add` into the dictionary being enumerated, which does invalidate it. `BaseHire.PayTimer.OnTick` has the same shape and is likewise untouched.
**Spatial-query buffers** — `GuardedRegion.CallGuards`, `Thunderstorm`, `Exorcism`, `LeverPuzzleController`, `BaseCreature.TeleportPets` — are a different hazard. They buffer the result of a range query because the drain moves or harms mobiles, which mutates sectors mid-enumeration.
**Re-entrant drains.** The `_users` sets in `Firebomb` and the explosion, conflagration and confusion-blast potions look like this pattern but are not: the loop collects, `Clear()`s, and only then runs `Target.Cancel` on each, which can re-enter. `AnimalTrainer` enumerates `pm.Stabled` and drains through `RemoveStabled`, which nulls the `Stabled` field once it empties — safe for an in-flight enumerator, which holds the set reference rather than the field, but subtle enough not to be worth inlining on a cold path.
## Verification
`dotnet build` clean with 0 warnings; 810 Server and 684 UOContent tests pass.
## Why
ModernUO mandated `-dev` packages on production servers for exactly one reason: `DllImport` never
asks for a versioned SONAME, so `libdeflate.so.0` and `libargon2.so.1` sitting in `/usr/lib` went
unfound, and the `-dev` package's unversioned symlink was the only thing making resolution work.
The `-dev` packages ship no library of their own — operators were installing headers and a static
lib on machines that compile nothing.
Fixed in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13), so
this picks them up and stops asking.
```
LibDeflate.Bindings 1.0.3 -> 1.0.4
Argon2.Bindings 1.17.0 -> 1.19.0
```
## zstd is dropped too, on every platform
ZstdNet bundles `libzstd` for `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64` and win, and
nothing shells out to the CLI. Verified: the 15 `ManagedArchive` round-trip tests pass in a
container with no `zstd` package installed and `which zstd` empty. Removed from the README, the
macOS `brew install`, and CI — so the macOS runners now prove it rather than us assuming it.
## NativeLibraryChecker asks a different question
It asked *"is package X installed"* via `dpkg -l` / `rpm -q`. That is what forced `-dev`, and no
hardcoded name works for ICU anyway — its apt package is release-specific (`libicu70` on Ubuntu
22.04, `libicu76` on Debian 13). It now asks *"will the loader find this"*: `NativeLibrary.TryLoad`
on the unversioned name, then `libfoo.so.N` descending through the range the runtime accepts.
It deliberately does not consult a package database or `ldconfig -p`. Both answer a different
question than "will `dlopen` succeed" — see the ICU section below for how that bit.
## What was wrong with the ICU check
`libicuuc` was **inherited, not derived**. It came from translating the old package-name check into
a library probe, without establishing which library that should be. Reviewing it turned up three
defects, all of which could report ICU present on a host where the runtime then refuses to start:
- **`libicui18n` was never probed.** The only ICU names in `libSystem.Globalization.Native.so` are
`libicuuc` and `libicui18n`. `libicudata` arrives as a dependency of `libicuuc`, and
`libicuio`/`libicutu`/`libicutest` are never referenced — so that is the complete list, and both
are checked now.
- **No version floor.** The runtime's `MinICUVersion` is 60, but the probe accepted down to
`.so.0`. RHEL/CentOS 7 ships ICU 50, which passed and then aborted at startup.
- **The `ldconfig` fast path bypassed the range.** A cache line for `libicuuc.so.50` still matches a
`libicuuc.so` prefix test, so the floor was unenforceable through it. It also trusts a stale
cache — observed reporting a deleted `libdeflate` as present. Removed in favour of asking the
loader directly, which reads the same cache but answers the real question, and which also deletes
the musl special-case (`ldconfig -p` exits 0 on musl while producing nothing usable).
Worth knowing when this goes wrong in the field: **missing ICU does not throw, it `FailFast`s** —
SIGABRT, exit 134, uncatchable. The process starts cleanly and dies later at whatever line first
touches a culture, so the stack rarely implicates ICU.
## tzdata is a separate prerequisite, and nothing was checking it
The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads
`/usr/share/zoneinfo`. It is data rather than a library, so no loader probe finds it, and slim
container images routinely omit it. Without it every lookup except `UTC` throws
`TimeZoneNotFoundException` and `GetSystemTimeZones()` returns 1 entry instead of ~419.
There is no per-zone packaging to opt into — it is ~2 MB for the whole set. The one split that does
exist is a trap rather than an optimization: Debian 12 and Ubuntu 24.04 move the legacy aliases into
`tzdata-legacy`, so plain `tzdata` has `America/New_York` and `EST5EDT` but is **missing
`US/Eastern` and `Asia/Calcutta`**. A shard configured with a legacy alias throws even though tzdata
is installed. Documented, with both fixes.
## Why `InvariantGlobalization` stays false
Dropping ICU entirely by turning on invariant mode looks tempting and is not safe. Because
`Directory.Build.props` also sets `PredefinedCulturesOnly=false`, invariant mode does **not** throw
`CultureNotFoundException` — it silently hands back invariant data. Measured on .NET 10:
| Behaviour | With ICU | Invariant mode |
|---|---|---|
| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data |
| de-DE decimal separator | `,` | `.` |
| `1234.5` as de-DE | `1.234,5` | `1,234.5` |
| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) |
| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` |
| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` |
| UTF-8 round-trip of non-ASCII | unaffected | unaffected |
Number parsing and formatting produce wrong values with no error, and culture-sensitive sort order
silently becomes ordinal. Encoding is not the mechanism — UTF-8 round-trips fine either way.
## Documentation
The rationale now lives in `dev-docs/platform-prerequisites.md` rather than in comments, so it is
discoverable without reading the build tool: what each dependency is for, what breaks without it,
per-distro package names, the ICU floor, the `tzdata-legacy` split, and why the check asks the
loader instead of the package manager.
README drops `libicu-dev`. Matching the runtime package by pattern (`'^libicu[0-9]+$'`) is
version-independent without pulling in headers, so **no `-dev` package is required on any supported
distribution** — which was the point of the whole change.
## CI now proves the claim instead of contradicting it
The dnf job already installed runtime packages only. The apt job installed `libicu-dev`, which ships
the unversioned `libicuuc.so` symlink — so every probe succeeded on the first attempt and the
versioned-SONAME fallback this PR depends on was never exercised. Switched to the pattern match,
verified to resolve exactly one package on jammy (70), bookworm (72), noble (74) and trixie (76).
Added an assertion that the unversioned symlinks are absent. Without it the suite silently stops
testing anything the moment a base image starts shipping one. Verified against all eight matrix
distributions — none ship them — and confirmed the step fails as intended when a symlink is planted.
## Audit of every other native entry point
Checked whether anything else has the same hazard. It does not:
| Import | Verdict |
|---|---|
| `ws2_32.dll` — `SocketHelper` | Always present on Windows |
| `libc` — `SocketHelper` | **Verified safe**, see below |
| ZstdNet → `libzstd` | Bundled for every RID |
| IORingGroup | No native library; raw syscalls |
| ICU | Loaded by the .NET runtime itself, which probes versioned suffixes |
`libc` deserved a hard look, because `libc.so` *is* a `libc6-dev` linker script while the real
library is `libc.so.6` — the same shape as the bug being fixed. It is not affected. Measured in a
container with no `libc6-dev`:
```
/usr/lib/x86_64-linux-gnu/libc.so ABSENT
/lib/x86_64-linux-gnu/libc.so.6 present
TryLoad("libc") LOADED <- resolves where "libdeflate" would not
TryLoad("libc.so") not found
getpid() -> DllImport("libc") WORKS
```
Confirmed on Alpine/musl as well. No code in this repo registers a `DllImportResolver`, and nothing
else P/Invokes.
## `--check-prereqs`
New flag. `Program.cs` only ran the SDK check in non-interactive mode — `NativeLibraryChecker` was
reachable only through the Spectre-driven guided flow, so there was no way to verify a deployment
target from a script or a container. It is what made the container verification below possible, and
it prints the exact ICU package for the running release via `apt-cache`.
It renders through the same `PrerequisiteChecker` the guided menu uses, rather than a second
hand-rolled table that could drift from it. Spectre drops ANSI styling on its own when stdout is not
a terminal, so redirected output stays clean; the console width is widened in that case so the
install hints, which are shell commands meant to be copied, do not gain a newline mid-command.
```
╭───────────────────────────╮
│ Checking native libraries │
╰───────────────────────────╯
✔ libicuuc (Found)
✔ libicui18n (Found)
❌ libdeflate (Not found)
❌ tzdata (Not found — every zone except UTC will throw)
⚠️ Install the missing dependencies. The -dev/-devel packages are not required:
sudo apt-get install -y libicu74 libdeflate0 tzdata
```
Exit code carries the machine-readable half: 0 when everything resolves, 1 when anything is missing.
## Verification
Against 1.0.4 and 1.19.0: build plus **810 Server.Tests and 642 UOContent.Tests**, on Windows and
on Linux with **only** `libdeflate0` and `libargon2-1` installed — with the absence of the
unversioned symlink asserted first so the run could not pass for the wrong reason.
`--check-prereqs` verified in containers on Debian and Alpine across every state that matters: all
present, each dependency removed individually, tzdata removed, a deliberately stale `ldconfig`
cache, and ICU downgraded to `.so.50` to confirm the floor rejects it. Package resolution and the
absence of unversioned symlinks checked on all eight CI distributions.
## Problem
`IORingGroup`'s `WindowsManagedRIOGroup.DequeueRioCompletions` stackallocs `RIORESULT[256]` (6144 bytes) and runs **once per game-loop iteration** — `NetState.Slice` → `RingSocketManager.ProcessCompletions` → `PeekCompletions` → `DequeueRioCompletions`.
The 1.0.8 package was compiled with the `.locals init` IL flag set, so every one of those calls memset the full 6 KiB before `RIODequeueCompletion` overwrote the entries it actually filled.
An EventPipe profile of a near-idle shard (3 vCPU VPS, world saves off, one player logging in and moving around) put `System.Buffer.ZeroMemoryInternal` — called directly from `DequeueRioCompletions` — at **~2.8% of main-thread samples**, and it was the dominant frame in several 60–127 ms game-loop stalls.
## Why our existing attribute didn't cover it
`Projects/Server/Module.cs` and `Projects/UOContent/Module.cs` already declare `[module: SkipLocalsInit]`. That attribute is a **compile-time** directive: it clears the flag in the IL of the assembly being compiled, and does not cross assembly boundaries. It never applied to the package.
Verified by reading the shipped IL (`MethodBodyBlock.LocalVariablesInitialized`):
| Assembly | attribute | methods with `.locals init` |
|---|---|---|
| `Server.dll` | present | 0 of 5439 |
| `IORingGroup` 1.0.8 | **absent** | **158** |
| `IORingGroup` 1.0.9 | present | **0 of 389** |
## Testing
Built and tested against the locally-built 1.0.9 package (temporary local feed, not committed):
- `dotnet build -c Release` — **0 warnings, 0 errors**
- `Server.Tests` — **810 passed, 0 failed**
- `UOContent.Tests` — **637 passed, 0 failed**
- Confirmed the `IORingGroup.dll` deployed to `Distribution/` is the fixed build (0 of 389 methods zeroing)
Only the `<PackageReference>` version changes; no source changes on this side.
## Why
The shard owner, on a Starlink CGNAT address, was blocked by the imported reputation blocklist.
The cause was not CrowdSec. The address was a literal line in `ip-blocklist.txt`, so `BlocklistFilter` denied it at accept and then promoted it — and clearing the CrowdSec decision could not fix it either, because the file entry re-reports within `promoteSuppression` of every reconnect attempt.
This is structural, not a one-off. Reputation feeds list shared consumer address space constantly: on CGNAT one public address fronts many subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else behind it is blocked with them. Where leases rotate, a listing says little about whoever holds the address now. Around 1,000 Starlink addresses sit in the current list.
So exemptions go where they cost nothing, and escalation is driven by what a connection actually does.
## Generator — `tools/Export-IpBlocklist.ps1`
`-AllowlistFile` takes multiple paths, subtracted from the merged set before the output is written. Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set:
- `ip-allowlist.txt` — operator exemptions, created once and **never rewritten**
- `ip-allowlist-<name>.txt` — a carve-out you built, regenerable and copyable between shards
**Subtraction is range-correct.** An allowlisted address inside a blocked CIDR splits that CIDR around the hole rather than being silently ignored. This also fixes `-ExcludeAnonymizers`, which parsed CIDR entries into `$anonCidr` and then only ever subtracted singles.
**No carve-out ships.** A carve-out names a real network, and which ones a shard should exempt depends on where its players actually are — so publishing one would make that policy call for every shard and put a specific provider's address space in the repo. The script builds them on request instead:
```powershell
.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
```
Carve-outs are **discovered, not configured**: every `ip-allowlist*.txt` beside the output is subtracted, by the generator and by the shard, so a file an admin adds needs no config edit and no code change. Each carries an `asn=` marker in its header, which is how `-RefreshCarveouts` rebuilds it without the script keeping a list of anyone's networks; a hand-written allowlist has no marker and is never rewritten.
Prefixes come from **announcements, not ownership records**, because registry data disagrees with what is actually routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and `206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink.
Editing an allowlist bypasses `-MinInterval`, so a just-added exemption isn't indistinguishable from the allowlist not working. A Starlink carve-out, if you build one, costs **~4,300 IPs + ~144 CIDRs of 4.2M (0.10%)**.
## Allowlists
**`FileAllowlist`** reads the same files the generator subtracts, so an operator entry means "leave this address alone" for real. Subtraction alone only covers being *blocked*; behavioural detections never consult the blocklist, so without this a carve-out was quietly routed around — one scanner behind a shared address was enough to get everyone behind it contributed and firewalled, with nothing in the shard's own config explaining why. Reading the files also means an entry applies on the next reload rather than the next regeneration, which is what matters when someone is complaining now.
**`LoginAllowlist`** is earned by authenticating, with a 90-day TTL because an address that logged in years ago is a stranger. Its own store rather than `Account.LoginIPs`, which has no timestamps and cannot be backfilled. An entry is evidence rather than a licence: 10 suppressed contributions in an hour revokes it, and a fresh login forgives the tally.
Both are consulted **only after the blocklist has already matched**, so a normal accept pays nothing for them and the accept gate stays allowlist-free. `BanExemptions` combines them behind `BanChannel.IsExempt` and suppresses escalation only — every local defence still applies.
Two limits, both deliberate and documented in the class: `LoginAllowlist` **cannot bootstrap** (an entry is only earned by getting in, so it never repairs an existing false positive), and it is weakest on rotating CGNAT. That is why `FileAllowlist` is the fix for those, and why it is manual.
## Behavioural detection
| Reason | Trigger |
|---|---|
| `silent-connect` | Reaped after 5s having sent **zero bytes** |
| `invalid-seed` | Opened with a zero seed |
| `foreign-protocol` | Positively identified as HTTP, TLS or SSH |
**`ForeignProtocol` inverts the test.** Asking "is this a good UO client?" cannot work: `LoginEncryption.ClientDecrypt` is a byte-for-byte stream XOR, so a legitimate client with encryption enabled when the shard expects none sends a structurally perfect connection whose payload is noise. "Speaks HTTP" is safe where "unreadable" is not — however misconfigured a UO client is, it never sends `GET / HTTP/1.1`.
Nothing assumes arrival framing. TCP has no message boundaries, so a rule of the form "these bytes must arrive together" is broken by construction and drops real players on poor links. A prefix match with too few bytes to confirm waits for more. A four-byte seed can legitimately spell `GET ` (the address 71.69.84.32) or `0x16 0x03 0x0?` (22.3.x.x), so confirmation requires the request line to continue in printable ASCII or an actual ClientHello inside a plausible record — a real client's fifth byte is a packet id (`0x80`, `0x91`, `0xEF`), none of them printable, so those collisions fall through.
Everything is keyed on **bytes-received rather than elapsed time**. A connection that sent something and ran out of time is far more likely a slow link than an attack, and banning those produces the worst failure mode available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being firewalled off.
## `AutoDenylist`
A short-lived local hold (15m) on behavioural detections, as `IConnectionFilter` + `IBanReporter` over one store so the engine detection sites never reach into content.
This closes the gap where a flood pays for a socket, buffer and `NetState` slot per connection while waiting for the OS bouncer — the verdicts that matter most are reachable only *after* reading bytes — and it is the entire defence on a shard running no bouncer, which is the default config. Not persisted: a holding pen that survives restarts is a ban without a ban's review.
Cost: one dictionary lookup on a usually-empty dict per accept.
## `BanReasons`
Centralises the reason slugs. `IsBehavioral` is an **opt-in** set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption or entering a local denylist.
This caught a real bug during review: the first cut of the exemption swallowed `manual` admin bans (`Commands.cs`, three sites in `AdminGump`) for any allowlisted address.
## Fixes found in review
- **`BanConfiguration.Settings` was null until `Configure()` ran**, while the reap path dereferences it every `Slice()`. A harness driving `NetState.Slice()` directly hit an NRE that presented as flaky because it depended on whether an earlier test had already called `Configure()` — which is why it failed on some CI platforms and not others. Now starts at the record's defaults, with idempotency tracked by a flag; this also removes the same latent NRE from the pre-existing rate-limit path.
- **`-AllowlistFile` was typed `[string]`** while documented and used as a list, so passing two paths would have collapsed them into one string.
## Layout and docs
Content network code moves out of `Misc/` into `UOContent/Network/`, one concern per folder — `AutoDenylist/`, `Blocklist/`, `CrowdSec/`, `Firewall/`, `LoginAllowlist/`, `Packets/`. **Namespaces are untouched**, so these are pure file moves (git tracks all 16 as renames).
`dev-docs/ip-bans-and-allowlists.md` documents the subsystem, leading with the operator process for unblocking a player — including the three things that look sufficient and are not: deleting the CrowdSec decision alone, editing `ip-blocklist.txt` by hand, and `cscli allowlists` alone. `.gitignore` covers the new config files.
## Testing
Build clean. **Server.Tests 810 passed**, **UOContent.Tests 637 passed**, zero warnings. This branch adds 38 tests; the rest of the delta is main's, since this is rebased on current `main`.
New coverage: TTL boundary and renewal, private-address exclusion, manual-ban-never-exempt, unopted-reason-never-exempt, strike revocation, quiet-window reset, login forgiveness, file-allowlist CIDR coverage, file-allowlist not spending the earned list's strikes, denylist expiry-on-read, cap enforcement, lapsed-entry reclaim, HTTP/TLS/SSH identification, seed-collision fall-through, and encrypted-login-is-not-foreign.
Generator verified end-to-end against live feeds: a clean run ships no carve-out, `-AddCarveout starlink -Asn 14593` fetches and collapses 213 prefixes to 115 ranges in 0.1s over 4.2M entries, `-RefreshCarveouts` rediscovers it by its `asn=` marker, a hand-written allowlist is left untouched, and deleting a carve-out drops it rather than having it rewritten. CIDR splitting verified exhaustively: a single-IP hole in a /24 leaves exactly 255 of 256 addresses blocked.
## Operator note
Existing installs are unaffected until the generator next runs, which creates `ip-allowlist.txt` and nothing else. To unblock someone: add the address to that file and delete any live CrowdSec decision — the existing ban outlives the config change. The shard picks the entry up on its next reload, so re-running the generator is optional.
A shard whose players are on CGNAT (satellite, mobile, or an ISP short on IPv4) will likely also want `-AddCarveout`; see `dev-docs/ip-bans-and-allowlists.md`.
## Also included: a latent CI failure this PR surfaced
`fix(tests): serialize test classes that rent through STArrayPool` touches a property-list test file that has nothing to do with this feature. It is here because it was failing macOS CI, and it is trivially cherry-pickable out if you would rather it went to `main` on its own — **which may be the better call, since it is failing `main` today.**
CI has since gone green with it applied.
`STArrayPool` is single-threaded by design and its bucket cache is a plain `static`, not `[ThreadStatic]`, with a check-then-act initialize in `Return()`:
```csharp
var cacheBuckets = _cacheBuckets ?? InitializeBuckets();
```
Two threads both see null, both initialize, and the loser trips `Debug.Assert(_cacheBuckets is null)`. Anything renting from it has to stay off parallel test threads — which is what the `DisableParallelization` collections are for.
- `ObjectPropertyListReentrancyTests` and `ObjectPropertyListNestedBuildTests` (added in #2555) build property lists, which rent the interpolation buffer, but were not in the sequential collection — unlike `PropertyListInvalidationDuringBuildTests` in the same file. This is a **latent failure already on `main`**; it is timing-dependent, so it shows on some platforms and not others.
- `AutoDenylistTests` (added here) has the same exposure: its cap tests reach `AutoDenylist.Sweep`, which rents a `PooledRefList` without `mt`. The blocklist tests need no marking because `BlocklistSnapshot.Build` asks for the `mt` pool explicitly.
No production change — `STArrayPool` is the right pool on the game loop, where both `Sweep` and the property list actually run.
## Deliberately not included
Waiting for a fragmented four-byte seed at `AwaitingSeed`. It looked like a bug but the disconnect is a deliberate defence: only pre-0xEF clients reach it (0xEF goes through `HandlePacket`, which already waits for its 21 bytes), and waiting converts an instant drop into a full 5s slot hold for a client sending one or two bytes, or a loris dribbling a byte every few seconds. Against a fixed 4096-entry `MaxConnections` table that trades capacity that matters for a fragmentation case a reconnect already fixes.
## The bug
Any property getter reached from `GetProperties` that calls `InvalidateProperties` takes the tooltip build down with it:
```
System.ArgumentNullException: Value cannot be null. (Parameter 'array')
at Server.ObjectPropertyList.AppendStringDirect(String value)
at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)
```
`InvalidateProperties` rebuilds **in place** — `Reset()`, then `GetProperties()` again on the same instance — and `Reset()` does two destructive things to a build already in flight:
1. **It returns the pooled interpolation buffer.** The compiler rents it in the handler ctor and returns it in the closing `Add`, so *every hole is evaluated while it is live*:
```csharp
var handler = new InterpolatedStringHandler(1, 2, list); // InitializeInterpolation() RENTS
handler.AppendFormatted(pl.Rank.Title); // <-- getter runs HERE
handler.AppendLiteral("\t");
handler.AppendFormatted(faction.Definition.PropName);
list.Add(1060776, ref handler); // consumes span, RETURNS
```
```
GetProperties(list)
├─ InitializeInterpolation() -> _arrayToReturnToPool = Rent(256) buffer LIVE
├─ « hole 1: pl.Rank.Title »
│ └─ PlayerState.Rank.get (lazy recompute)
│ └─ Invalidate() -> InvalidateProperties() -> m_PropertyList.Reset()
│ └─ Dispose(): Return(buf); _arrayToReturnToPool = null buffer GONE
└─ handler.AppendFormatted("Knight")
└─ _arrayToReturnToPool.AsSpan(_pos..)
└─ ArgumentNullException (Parameter 'array')
```
It surfaces as `ArgumentNullException` rather than `NullReferenceException` because the `Range` overload of `AsSpan` must read `array.Length`, so the BCL null-checks and names the parameter `array`.
2. **It rewinds the packet cursor**, so properties already written are overwritten by the nested pass — a silently corrupted tooltip even where the buffer survives.
## The fix: refuse, don't recover
There is no correct recovery, and retrying the build would only hide the defect. A nested invalidation now logs an error with a stack trace, **throws in `DEBUG`** so it gets found and fixed, and in `RELEASE` returns without touching the list — a possibly stale tooltip, but no crash, no corrupted packet, and nothing leaked back to the pool. Getters that genuinely must invalidate should defer:
```csharp
Timer.DelayCall(InvalidateProperties);
```
The guard flag lives on the `ObjectPropertyList`, not the entity: it is that list's own lifecycle, it costs nothing (both `Item` and `ObjectPropertyList` absorb it in existing padding, and the list is allocated lazily), and it stays correct when builds for different entities nest.
Base instance sizes are unchanged from `main`: Item 128 B, Mobile 792 B, ObjectPropertyList 72 B, PlayerMobile 1216 B.
`PropertyList` also publishes the list into `m_PropertyList` **before** building it rather than assigning through `??=` afterwards, so a nested `InvalidateProperties` sees the build in progress instead of recursing into a second throwaway list whose work is discarded.
`ObjectPropertyList` re-rents its scratch buffer instead of spanning a null array, so a stray `Reset()` from any other caller degrades rather than aborting `GetProperties`.
## Factions `PlayerState`: maintained, not lazily computed
The getter that surfaced this is now a plain field read — the whole `if (m_InvalidateRank)` block and the flag itself are gone:
```csharp
public RankDefinition Rank => m_Rank;
```
`UpdateRank()` recomputes at each point an input actually changes:
| Site | Why |
|---|---|
| `RankIndex` setter | this player's index changed |
| end of `KillPoints` setter | two paths write `m_RankIndex` directly, bypassing the setter; runs once the swap bookkeeping and `ZeroRankOffset` have settled |
| `Faction.AddMember` | *after* the insert — the member count is not settled during the ctor |
| `FactionState` load | once ordering and `ZeroRankOffset` are final |
Supporting fixes this forced out:
- **Both ctors seed the lowest rank.** Nothing recomputes on read any more, so `Rank` has to be usable immediately — including for members that never get a `RankIndex` assigned, which is *every member with no kill points*. Without this, `Rank.Title` NREs.
- **`Rank` always resolves.** Ranks are ordered by `Required` descending ending at `0`, so a *negative* percent (`RankIndex` out of sync with `ZeroRankOffset`) matched nothing and left `m_Rank` null. It no longer divides by a zero `ZeroRankOffset` either.
- **A pre-existing staleness bug.** The `KillPoints` setter writes `m_RankIndex` directly in two places, so the cached rank was never refreshed when a player crossed zero kill points.
All six readers of `Rank` were checked; none relied on the old side effect.
One behaviour change worth flagging: rank refreshes are now **eager** where they used to be lazy, so a `KillPoints` change invalidates each swapped player as it happens. The swap loops break as soon as ordering is satisfied — typically 0–2 swaps — but it is on the path that runs on every faction kill.
## Documentation
The rule is written down so it is enforceable rather than folklore:
- **CLAUDE.md** audit rule 19
- **`dev-docs/property-lists.md`** — new "Never Invalidate From Inside `GetProperties`" section with the failing/passing pattern
- **`dev-docs/claude-skills/modernuo-property-lists.md`** — key rule + anti-pattern
- **`dev-docs/claude-skills/modernuo-code-audit.md`** — rule 19, ERROR severity
## Tests
- `ObjectPropertyListReentrancyTests` — `Reset()` and `Dispose()` re-entered mid-hole (both red against `main` with the exact exception above), nesting behaviour, and the new contract: `DEBUG` throws, `RELEASE` survives, and the build is never retried into a loop.
- `FactionRankTests` — `Rank` is populated before anything reads it, tracks `RankIndex` without a read, is stable across reads, and still resolves when `RankIndex` is out of sync with `ZeroRankOffset`. Red-verified: removing the ctor seed fails the first one.
793/793 `Server.Tests` and 608/608 `UOContent.Tests` pass.
## Noted, not addressed here
`~ObjectPropertyList()` returns the rented array to `STArrayPool<char>.Shared` from the **finalizer thread**, and that pool is single-threaded by design. Left alone as a separate concern.
## Summary
Two related fixes on the outbound path:
1. Consume **IORingGroup 1.0.8**, which allows more than one send in flight per socket, and expose the two settings that go with it.
2. Stop `NetState.Send` silently discarding packets when the send buffer fills — including an out-of-bounds write reachable in that state.
## 1. Send-path stall (RIO)
RIO reports send completion on **acknowledgement**, not on copy, so a completion cannot arrive sooner than one round trip. With one send in flight, `PostSend` refused to post again until the previous completion arrived — capping a connection at **one send per RTT** whenever it had data queued.
Measured on a 50ms-RTT production shard:
| | before | after |
|---|---|---|
| in-game latency, data flowing | **101–146 ms** | **48–51 ms** |
| p95 | ~135 ms | 52.8 ms |
| samples > 70 ms | 20 | **0** |
The control that confirms the mechanism: server-side post→completion was **unchanged** at median 92ms across both runs. The ACK-binding is inherent to RIO and did not move; only its propagation into application latency did.
Two things worth recording, because they explain why this went unnoticed:
- As little as **6 bytes** of queued data held the gate shut, so it reproduced in empty areas, not just crowded ones.
- The same measurement at loopback RTT is **microseconds**, so local testing could never surface it.
New settings, both restart-time:
- **`network.maxOutstandingSends`** (default 32) — sends in flight per connection. Honoured by RIO only; other backends complete sends on copy and report 1. Costs a request-queue and completion-queue slot per send, **not another buffer**, since every outstanding send addresses a different range of the same registered buffer. Worst-case added latency is roughly `completion RTT / value`.
- **`network.sendBufferSize`** (default 256KB) — per-connection send buffer, coerced to a power of two of at least the platform allocation granularity. This is the lever for the disconnects below, and the per-connection memory ceiling.
## 2. Send buffer full
`NetState.Send` had three failure modes once the buffer filled, none of them visible:
| writable | behaviour |
|---|---|
| `0` | `GetSendBuffer` returned false → **packet dropped**, no log, no disconnect |
| `4 … needed-1` | `Compress` returned 0 → `CommitWrite(0)` → **packet dropped** the same way |
| `1 … 3` | `safeOutputLength = (nuint)output.Length - 4` **underflows** → hot-loop bounds check never trips → **writes past the span** |
The first two leave a client connected while quietly missing game state, which is undiagnosable from either end. The third corrupts the in-flight region of the ring buffer, and is reachable precisely when a connection is congested, since callers only check for non-zero space.
`Compress` now refuses an output too small to bound, and `Send` reports exhaustion instead of dropping — logging and disconnecting with **needed / writable / unacked / capacity**. Those numbers separate a slow client holding the buffer from a buffer genuinely too small for the shard, which is the case that warrants raising `network.sendBufferSize`.
## Testing
`NetworkCompressionBoundsTests` covers the underflow using sentinel bytes around the output window. **Verified to fail without the guard** (4 failures from overwritten sentinels), confirming the out-of-bounds writes were real rather than theoretical.
Full suites green: **788 Server.Tests**, **597 UOContent.Tests**, Release build clean against the published 1.0.8.
## Notes for reviewers
- Upstream change: modernuo/IORingGroup#9.
- The buffer-full path is now *loud* where it used to be silent. If a shard has been quietly dropping packets under load, this will surface as disconnects — that is the intended outcome, and the log line says which setting to raise.
- Follow-up under discussion: promoting a connection to a larger buffer instead of disconnecting, which looks feasible on a live connection since buffers are referenced per-operation rather than bound to the request queue.
### Summary
Moves inventory insurance out of `Mobile`/`PlayerMobile` into its own system at `Projects/UOContent/Engines/Insurance/`, wires it into the feature flag system, and makes disabling it actually disable it everywhere.
### Changes
**New `Server.Engines.Insurance.Insurance` system**
* Owns its own `Configure()`, seeding from the existing `insurance.enable` setting (default `Core.AOS`), so no config migration is needed. `Mobile.InsuranceEnabled` is gone, along with its line in `ExpansionConfiguration`.
* `CanInsure`, `GetInsuranceCost`, `ToggleItemInsurance`, `AutoRenewInventoryInsurance`, `CancelRenewInventoryInsurance` and `OpenItemInsuranceMenu` move here from `PlayerMobile`, which keeps four one-line shims for the context-menu callbacks.
* Every entry point is gated on `Insurance.Enabled`, and the death-time state is only allocated when insurance is on — a shard without insurance pays nothing for it.
**Feature flag integration**
Insurance is now a first-class feature flag: `ServerFeatureFlags.InsuranceEnabled`, registered under the `insurance` key in `FeatureFlagManager.SyncStaticFlag`, so it can be inspected and toggled through the normal flag command/gump rather than only at boot. `Insurance.Enabled` reads through to the flag, so there is one source of truth for every consumer.
**Fixes a memory leak from PvP**
`PlayerMobile.m_InsuranceAward` was a `Mobile` field assigned on every death and never cleared, so every player permanently pinned a strong reference to the last player who killed them. Killers were kept alive by their victims indefinitely.
Death-time insurance state now lives in a `Dictionary<Mobile, InsuranceContext>` owned by the insurance system: the entry is created in `OnBeforeDeath` and removed in `OnDeath`, so nothing outlives the death that created it.
**Removes insurance fields from every PlayerMobile**
`m_InsuranceAward`, `m_InsuranceBonus` and `m_NonAutoreinsuredItems` were carried by every `PlayerMobile` whether or not the shard ran insurance. All three are gone; the equivalent state is allocated per-death, only for players who actually die with insured items, only when insurance is enabled.
**Stale `Insured` flags are inert when insurance is off**
`Item.Insured` is a persisted flag, so items stay marked after a shard turns insurance off. Every read path now checks the flag first, so those items behave exactly as if they were never insured:
* `Item.CheckBlessed` / `Item.IsStandardLoot` — they drop again instead of acting blessed
* `Item.AddLootTypeProperty` — no more phantom "insured" tooltip
* `PlayerMobile.FindItems_Callback` — not yanked out of nested bags on death
* `DestroyEquipment` — no longer immune
* `ClothingBlessDeed` — no longer reports "that item is already blessed"
**Gumps promoted out of `PlayerMobile`**
`ItemInsuranceMenuGump`, `ItemInsuranceMenuConfirmGump` and `CancelRenewInventoryInsuranceGump` were private nested classes reaching into `PlayerMobile` privates. They are now public types in `Engines/Insurance/Gumps/`, talking to the insurance system through its public API. `ItemInsuranceMenuGump.ToggleSelected()` replaces the confirm gump's reach-in to the parent's `_items`/`_insure` arrays.
### Behavior changes
* The per-item "You lack the funds to purchase the insurance" message on failed auto-renewal is no longer sent during death; players get the single 1061115 summary instead. Marked with a TODO pending a decision on whether the per-item message should spam.
* The killer's insurance bonus is deposited once at the end of death processing rather than 300 gold at a time per insured item, and the "gold has been deposited" message is now conditional on the deposit succeeding. Same total.
### Drive-by cleanups
`PoisonImpl.IncreaseLevel` -> `Poison.IncreaseLevel`, a redundant `is NetState { } ns` pattern, `new List<Item>(Items)` -> collection expression, alignment of the `SyncStaticFlag` switch arms, and some comment/formatting fixes in `PlayerMobile`.
Reshapes IP banning around one idea: **core owns the question, content owns every answer.**
Core gains a single accept-path seam — `IConnectionFilter` — and loses everything that used to implement one. The firewall moves to UOContent, a new file-backed blocklist joins it there, and CrowdSec is repositioned from an in-app enforcer to a contribute-first reporter.
## The seam
```csharp
public interface IConnectionFilter
{
string Name { get; }
void Configure();
void Start(CancellationToken token);
void Stop();
bool ShouldDeny(IPAddress address);
}
```
The accept path went from hardcoded branches to one question:
```csharp
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
```
Filters register during the Configure sweep. The registry is a plain array walked by an indexed loop — no enumerator, no closure, no allocation — and the first denial short-circuits. An interface dispatch is noise next to the `accept()` syscall, so pluggability costs nothing measurable on the path that has to survive a DDoS.
Whatever a hit implies — persisting, promoting to an OS bouncer, contributing to the ban channel — is the filter's business, not the accept path's.
A filter that throws is **unregistered and the connection fails open**. A filter that faults once faults for every subsequent connection, so leaving it registered means an exception and a log line per accept — exactly the amplification an attacker wants — and a broken filter must not be able to deny everyone either.
This deliberately does **not** reuse `EventSink.InvokeSocketConnect`: that fires later and allocates a `SocketConnectEventArgs` per connection, which is what the accept path avoids for rejected traffic.
## What ships behind it
**`firewall`** (UOContent) — the existing admin-curated set. Collapsed from `Firewall` + `AdminFirewall` + a threaded enforcer into one single-threaded store with **zero concurrency primitives**: the accept path, admin gump, TTL expiry and boot load all run on the game loop. Persists to `Configuration/firewall.json` with automatic migration from the legacy `firewall.cfg`. No behavior change for operators — same namespace, same gump, same commands.
**`blocklist`** (UOContent) — new. Holds a millions-strong list in-app and **demand-pages** hits up to CrowdSec, which promotes them to the OS firewall.
The motivation is concrete: CrowdSec's Windows bouncer cannot load the ~3.9M IPs that 91 community feeds produce, but it handles ~100k fine. So the millions live in-process behind a binary search, and only addresses that *actually connect* get promoted. A `PromotedGuard` suppresses re-reporting an address until the bouncer picks it up.
The list is parsed straight from UTF-8 file bytes with no per-line string allocation, off the game loop, and published as an immutable snapshot swapped through a single `volatile` reference. Reloads yield to world saves.
**`tools/Export-IpBlocklist.ps1`** — the producer. Requires PowerShell 7 and runs on Windows, Linux and macOS; Windows PowerShell 5.1 is refused up front via `#requires`. Merges a thin, non-overlapping feed set into one de-duplicated, bogon-filtered file. Parsing runs in a compiled `Add-Type` hot loop (~1s for ~4M lines instead of minutes). Written to a `.tmp` sibling and swapped with `File.Replace`, so the shard never reads a half-written list, and a total feed outage refuses to overwrite a good list with an empty one. Re-running is idempotent — it exits without downloading anything while the list on disk is younger than `-MinInterval` (default 2h, the anchor feed's own refresh period), so a misconfigured scheduler can't hammer upstream.
## CrowdSec: contribute-first
`IBanReporter` + `BanChannel` fan locally-decided bans out to external systems. `CrowdSecReporter` (UOContent) posts to LAPI `POST /v1/alerts` and retracts via `DELETE /v1/decisions`.
Reporting is **enqueue-only** on the accept path: a bounded, coalescing channel drained off-loop with bounded retry, counted drops on overflow, and a flush on shutdown. Under a DDoS the accept path never does synchronous or lock-contending per-IP work.
### Why not pull decisions from CrowdSec?
The original design streamed decisions into an in-app snapshot and enforced them at the accept gate. That's the wrong layer: by the time the shard sees the connection, the TCP handshake and socket setup are already paid for. `cs-firewall-bouncer` drops the same traffic **at the kernel**, and it's what CrowdSec is built to do. So the shard now contributes what it uniquely knows (rate-limit trips, blocklist hits from real connection attempts) and lets the OS enforce.
The one thing the OS can't do — hold millions of entries on Windows — is exactly what the in-app blocklist covers, and it feeds the same pipeline.
## Threading policy
`CLAUDE.md` rule #3 is rewritten as an explicit three-part policy, with rule #10 restated in tandem:
- Anything touching game state runs **only** on the main loop.
- Heavy work that *needs* game state must be **chunked** across ticks, never threaded.
- Heavy work that does *not* need game state (large-file parse, external I/O) **must** run off-loop **and must yield to world saves**.
Results come back via an immutable snapshot swapped through a single `volatile` reference, or `Core.LoopContext.Post` — never by letting the scheduler decide where heavy work runs. Both new subsystems follow it.
## Shared primitives
`SortedRangeIndex<T> where T : IBinaryInteger<T>` — coalesced disjoint interval arrays plus a binary search. The firewall, the blocklist, and (as of this PR) core's reserved-network tables all use it.
Coalescing is a correctness requirement, not an optimization: multi-feed lists nest CIDRs (`/24` containing a `/32`), and a search that inspects only the rightmost run whose minimum is ≤ the value is sound **only** over disjoint runs. That bug was caught in review and is covered by regression tests.
`IPAddressUtility` collects the allocation-free `IPAddress` ↔ `UInt128` conversions and CIDR parsing that were previously scattered or duplicated.
## Config
| File | Owner | Keys |
|---|---|---|
| `Configuration/bans.json` | core | `reportRateLimitTrips`, `autoBanDuration` |
| `Configuration/blocklist.json` | content | `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
| `Configuration/crowdsec.json` | content | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` |
| `Configuration/firewall.json` | content | persisted firewall entries (migrated from `firewall.cfg`) |
Everything is inert by default. CrowdSec self-disables without credentials; the blocklist self-disables until its file exists. A shard that changes nothing sees no behavior change.
## Notes for review
- **Core no longer references `Firewall` or `IFirewallEntry` anywhere.** `NetworkUtilities` used to build its reserved-network tables out of `CidrFirewallEntry`, which coupled core to the firewall for something unrelated to banning; those are now a `SortedRangeIndex<UInt128>`, same semantics and public API.
- **`BanChannel.Stop()` no longer persists the firewall** — a contribution coordinator has no business saving an enforcement store. That's the firewall filter's `Stop()`.
- **A dead `whitelisted` parameter was dropped** from the blocklist gate: it was hardcoded `false` at its only call site, and no whitelist concept exists in core.
- **The blocklist filter is an instance, not a static.** The static version forced its tests onto the sequential collection with a reset hook; they now run in parallel.
- `dev-docs/networking-packets.md` documents the seam for content authors, plus a known wart in the `IPAddress` ↔ `UInt128` normalization flagged for a follow-up PR.
- The generator was verified on Linux, macOS and Windows under a temporary CI matrix (since removed). It caught two portability bugs — a Windows-only path separator, and a culture-sensitive duration parse that read `2.5` as `25` on comma-decimal locales and *silently* turned a 2.5h cooldown into 25h — plus a third that made the script unparseable on Windows PowerShell 5.1. The source is ASCII-only for that last reason: `#requires` is only honored once a file parses, so non-ASCII in a BOM-less script produces parse errors instead of the version message.
## Tests
**1344 pass** (782 `Server.Tests`, 562 `UOContent.Tests`). New coverage: filter registry (registration, short-circuit, fault-disable), blocklist parsing/CIDR/coalescing, snapshot reload markers, promote-guard TTL, ban-channel fan-out, CrowdSec alert building/dedup/flush-on-stop, and the generator's output-format contract pinned against the reader.
`ObjectPropertyList.AppendFormatted<T>(value, format)` treated **any** `{value:#}` as the cliloc marker (emitting `#<value>`). But cliloc numbers are integers — a `float`/`double`/`decimal` formatted with `#` is the standard custom-numeric (`#` = digit placeholder) format, not a cliloc reference, so those were being mis-marked.
Gate the marker on an integer value type:
```csharp
if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte)
```
Now `{someFloat:#}` formats normally (passes `#` through to `TryFormat`); the marker/standard-format ambiguity narrows to the harmless `{0:#}` **integer** case (`#0`). Existing `AddLocalized(int)` / `{value:#}` (all `int`) are unaffected.
Adds `ObjectPropertyListSpanAddTests.HashFormat_OnlyMarksIntegers`: `int {value:#}` → `#<value>`; `double {value:#}` → `42.0.ToString("#")` (`"42"`, no `#`).
Reduces the world-save freeze window from ~740ms to ~78ms (measured on a synthetic 10M-entity / 1.7GB world, 24 cores, through the real pipeline classes) by removing the per-entity handoff between the game loop and the serialization workers, fixing how large indivisible payloads are scheduled, rewriting the BufferWriter hot path, removing per-entity placement state entirely, and finally replacing the global serialized-types tracking with a per-file type table (idx v4) that also shrinks idx files by ~21% and speeds the background write phase. The pipeline has also been validated end-to-end on live-copy worlds in the multi-million-entity range, where the freeze is drain-bound (real `Serialize()` costs far more CPU per byte than synthetic writes) — the same structural wins hold, and entity/file round-trips are byte-clean across both load paths.
## The problem
The freeze window is `max(main-thread handoff, slowest worker drain)`:
1. **The producer was the bottleneck.** The main thread round-robined every entity through per-worker `ConcurrentQueue`s — two interlocked ops per entity, ~740ms of freeze floor at 10M entities before any serialization happened.
2. **Round-robin distributes count, not cost.** "Deep" systems (50MB generic persistence blobs) and "thick" entities (100K-item storage keys) landed on arbitrary workers, producing lopsided drain times on large worlds.
3. **Worst-case scheduling.** `GenericEntityPersistence.Serialize` pushed its self-payload *after* all entities, and generic persistences sort last in the registry — so the biggest indivisible blobs started serializing at the very end, extending the freeze by their entire duration.
## The fix
**Commit 1 — chunked handoff + LPT scheduling + heap pre-sizing:**
- Pooled 4096-entity chunks published to one shared queue; workers pull chunks and load-balance dynamically (a worker busy with a thick entity simply takes fewer chunks).
- `Persistence.SerializeAll` pushes systems largest-first (LPT) using the previous save's payload size (or loaded file size on first boot); self-payloads get dedicated single-entity chunks so they overlap the entity stream instead of ending it.
- Worker heaps pre-size from the loaded save's `.bin` totals, eliminating copy-on-grow inside the first save's freeze.
- `SpinWait` backoff in the drain loop (never `Sleep(1)`), per-worker balance stats logged in debug builds (the call site is compiled out of Release), 1MB snapshot write buffer.
**Commit 2 — workers iterate the dictionaries directly + main thread joins the drain:**
- `GenericEntityPersistence` publishes 4096-slot ranges over its dictionary's backing entries array; workers serialize occupied slots (`value != null`) directly through a `ShadowEntry<TValue>` struct mirroring the runtime's private `Entry` layout. Safe because the dictionary is frozen during `Saving` (mutations divert to the pending safety queues).
- The layout is **proven at startup before any code reads through it**: validation measures the true `Entry` stride via precise allocation accounting (guaranteeing all shadow reads are in-bounds), then verifies every key/value of a churned, resized, freelist-exercised dictionary — reading value slots as raw pointer bits only, never materializing a managed reference until the layout is proven. If a future runtime changes `Dictionary` internals, validation fails with a logged warning and saves fall back to the (fully maintained) enumerate-and-push path.
- The main thread joins the drain via an inline worker after publishing, instead of idling — worth a full worker share, proportionally more on low-core hosts.
**Commit 3 — 2.2x faster BufferWriter write path, single-pass short strings:**
- PGO already devirtualizes and inlines every `IGenericWriter.Write` callsite (interface vs concrete measured identical) — the real per-write cost was the non-inlinable `Index` setter (range-check throw path + per-write high-water tracking) plus span bounds checks. Writes now reserve capacity once, then do an unaligned store through a ref with a raw index increment; the high-water mark folds at Seek/Resize instead of per write.
- Class-level implementations of the hottest default interface methods keep nested writes inlined (a DIM re-dispatches on `this` even at a devirtualized callsite).
- Strings of 85 chars or fewer encode once into a stack scratch instead of walking the string twice (`GetByteCount` + `GetBytes`). Byte output is identical.
- Measured: 34.4 → 15.7 ns/entity on a generated-style write mix; end-to-end freeze ~99ms → ~74-82ms.
**Commit 4 — branch-free fallback push loop:**
- A bare `foreach { PushToCache(entity); }` runs at 2.3ns/entity; the same loop carrying a per-entity heavy-check runs 2.3x slower — the cost is the fatter loop body defeating tight-loop codegen. Entity-level >1MB payloads are rare enough to ride in shared chunks; system self-payloads (the large ones) are still explicitly scheduled largest-first.
**Commit 5 — drop the 9-byte per-entity placement state; snapshots write from worker segment logs:**
- Every `ISerializable` carried `SerializedThread/SerializedPosition/SerializedLength` so `WriteSnapshot` could gather each entity's bytes from the worker heaps in dictionary order. But the idx records absolute positions — bin order is free — so the snapshot is now written in worker-heap order and the join inverts: workers log segments (owner, slot range, heap start) plus one length per record as they serialize; positions are implicit because a worker's writes are contiguous, and identity comes from re-walking the same snapshot slots in the same order (stable until `PostWorldSave`).
- Chunks are persistence-homogeneous (the partial chunk publishes at each `SerializeAll` boundary) so segments route to files by owner with zero per-entity state. Self-payloads keep placement as three private fields on the handful of persistence instances.
- Net: 9 bytes (plus padding) of resident state removed from every item, mobile, guild, and account on every shard; three interface-property stores per entity leave the drain hot path (stamping dirtied one cache line per entity mid-freeze — the lengths log is one sequential stream); each segment's bytes hit the bin as a single span write instead of one copy per entity, speeding the background write phase; and `IGenericSerializable` shrinks to just `Serialize(IGenericWriter)`. Transient cost: ~4 bytes per entity in pooled per-worker logs, released after each write. The save format was unchanged at this point (idx v3, same loader); the v4 bump comes later in the branch.
**Commit 6 — staged file writes replace memory-mapped snapshot writing:**
- `MemoryMapFileWriter` is removed. `FileBufferWriter` composes through the full `BufferWriter` raw write path into a pooled staging block that drains to the file as large sequential positional writes (`RandomAccess.Write`); seeks flush the block and move the file offset, so backwards patches (the idx entity count) become small positional writes.
- Memory-mapped composition paid a soft page fault on every composed page plus unpredictable dirty-section teardown stalls at dispose — measured ~4x slower end-to-end than staged writes at snapshot sizes.
**Commits 7–11 — idx v4: per-file type table replaces SerializedTypes.db and all runtime type tracking:**
- Previously every `Write(Type)` from every worker enqueued into a shared `ConcurrentQueue<Type>` during the freeze (interlocked writes on a shared cache line, millions of mostly-duplicate entries), the background phase drained and deduped it all into a `HashSet`, and the snapshot recomputed `xxHash64(Type.FullName)` once per entity record (~5.4M redundant hashes per save on a large world) to write 9-byte tag+hash idx records plus a global `SerializedTypes.db`.
- The db's only real job was diagnostics: the string name behind "Type `<X>` was not found. Delete all of those types?" during idx loading. That map now lives in the idx itself: each `GenericEntityPersistence<T>` keeps an insertion-ordered `Type -> ushort` table, hydrated at `AddEntity` and on every deserialize path — one dictionary `TryAdd` per entity add on the game thread, amortized across gameplay, and provably immutable while the background writer reads it (adds divert to the pending queues during saves).
- idx v4 layout: the table (names only) is written before the records; records reference it by 2-byte index, shrinking from 33 to 26 bytes (−21%). The loader resolves each table name **once** (`FindTypeByHash(ComputeHash64(name))` — semantically identical to v3 resolution, `TypeAlias` included) into a constructor array, and each record becomes an array index instead of an 8-byte hash read plus dictionary probe. The unresolved-type prompt now surfaces once per type, with the name.
- Deleted outright: `World.SerializedTypes`, the drain/dedupe pass in `WriteFiles`, `BufferWriter`'s type tracking (its `Write(Type)` is now pure — payload format unchanged: tag byte + xxHash64), `FileBufferWriter`'s typeSet parameter, `Persistence.WriteSerializedTypesSnapshot`, and the adhoc db write. SerializedTypes.db is no longer produced.
- **Backward compatibility:** v0–v3 saves (including their SerializedTypes.db and legacy tdb files) load exactly as before, and every legacy load path hydrates the new table so the first v4 save after an upgrade is complete. Stale db files in existing save folders are simply ignored. Verified live: a v3 save boots, saves as v4 (Items.idx −20.2% on a dev world), and reloads with identical entity counts.
## Measured (synthetic 10M entities / 1.7GB, 64+64+32MB system blobs, 24x 2MB thick entities, dense write profile, 24 cores)
| Metric | Before | After |
|---|---|---|
| Steady-state freeze | ~740 ms | **~78 ms** |
| Main-thread publish cost | ~740 ms | **~0.1 ms** |
| Steady-state allocations | 0 | 0 (by iter 2) |
| Worker byte-load spread | 2x | ~1.15x |
The freeze is now bound by pure serialize throughput (payload / cores).
## Tests
- 779 Server.Tests + 501 UOContent.Tests pass.
- New across the branch: chunk fill/flush/owner-boundary tests, pool reuse/clear tests, an end-to-end multi-worker drain through the real wake/push/flush/pause protocol, a 50K-entry churn equivalence test for the shadow iteration re-walk (the exact pairing the snapshot writer relies on), byte-level BufferWriter output/position pins, `RuntimeLayoutIsSupported` so a silent fallback on a future runtime upgrade fails loudly in CI, and a full snapshot **round-trip test** that serializes 25K entities plus a self-payload through real workers, writes the idx/bin from the segment logs, and reloads them through the standard loader (now in v4 format).
- For idx v4 specifically: `FileBufferWriter` staging/drain/seek-patch and oversized-item tests, type-table registration tests, a hand-written v4 fixture proving an unresolvable type name skips only its own records through the console confirmation flow, and a hand-written **legacy v3 fixture** proving old saves still load and hydrate the type table for their next save.
## Trade-offs
- Chunk scheduling is nondeterministic, so worker heaps ratchet to each worker's max-ever draw rather than a fixed share. With slot ranges the balance is tight (~1.15x), so the effect is small; a shared slab pool remains an option if production shows retention creep.
- Entity-level heavy items inside slot ranges are serialized wherever they're encountered (no LPT for them); worst-case tail is one thick entity's serialize time (~ms). System self-payloads — the large ones — are still explicitly scheduled largest-first.
- Snapshot-write error granularity is per segment rather than per entity (heap-bounds bugs were the only thing the per-entity catch ever caught; idx metadata reads keep per-record granularity).
- idx v4 is a save-format version bump: old saves load unchanged through the preserved legacy paths, but saves written by this branch require this loader. Per-persistence type tables cap at 65,535 distinct entity types per boot (hard throw, orders of magnitude of headroom), and a type's table slot persists until restart even if its last entity is deleted — a few stale name entries per file, by design.
## Problem
On headless Linux deployments (systemd service, Docker without a TTY, `nohup`), the ModernUO process pegs a full CPU core even when idle. It does not reproduce on Windows because that runs with an interactive console.
## Root cause
`ConsoleInputHandler` runs a background thread (named "Console Input Handler") that loops on `Console.ReadLine()`. When stdin is **not** an interactive terminal, `Console.ReadLine()` returns `null` at end-of-stream **immediately** on every call, so the loop `continue`s in a tight spin — one core at 100%.
Reproduced in a container running the actual distribution: the "Console Input Handler" thread sat at ~90% CPU on a headless boot; with a blocking stdin it dropped to idle.
## Fix
1. **Detect headless once at startup:** `Core.Headless = Console.IsInputRedirected`.
2. **Extract a testable `ConsoleInputPump`** that owns the input stream: per line read, it *atomically* (under one lock) either delivers the line to a waiting prompt or dispatches a console command, and it **ends on EOF instead of spinning**. Cleanup runs unconditionally in a `finally`, so a pending prompt is always released (never hangs). Replaces the old `async void` loop and the fragile `_expectUserInput` / two-`AutoResetEvent` / `_input` handshake.
3. **`ConsoleInputHandler` becomes a thin headless-aware facade** over the pump. Headless: the reader thread never starts (`Console input disabled (headless: stdin is not a TTY).`), and `ReadLine()` throws a fatal `HeadlessConsoleInputException`.
4. **Data-gating and first-boot prompts** (deserialization "delete bad types? y/n", save-conflict, config/expansion setup) now route through `ConsoleInputHandler.ReadLine()`, so a headless server crashes fatal with a clear message instead of reading `null` (previously an NRE or a silent wrong branch).
Design decision (model b): headless servers are expected to be supplied with configuration/save data (including the owner account); interactive prompts when headless are fatal by design.
## Testing
- New `ConsoleInputPumpTests` (5 tests): EOF ends the loop without spinning; command dispatch; a pending prompt receives the next line; EOF while a prompt is pending completes it with `null` (no hang); a throwing command lookup does not hang a pending prompt. The tests synchronize on real pump state (no `Thread.Sleep`), so they are deterministic on slow CI.
- Full `Server.Tests`: no new failures introduced.
## End-to-end verification (Docker, real distribution)
| | Console Input Handler thread | Container CPU |
|---|---|---|
| Before fix (headless boot) | ~90% | ~199% (2 cores) |
| After fix (headless boot) | **not started** | **~11%** |
After the fix, a headless boot logs `Console input disabled (headless: stdin is not a TTY).`, loads the world normally, and idles instead of spinning.
## Problem
Items dropped on the ground never decay. Corpses do, which makes the breakage look selective — but corpses are unaffected only because `Corpse.BeginDecay` runs its own `InternalTimer` and never touches `DecayScheduler`. Ordinary items are the only things that depend on the scheduler.
## Root cause
`Item.MoveToWorld` called `SetLastMoved()` — which triggers `UpdateDecayRegistration()` — at the *top* of the method, before detaching the item from its parent and before assigning the new map. `CanDecay()` reads `Decays`, `Parent`, **and** `Map`, so registration was evaluated against the item's *pre-move* state.
Because the `Item` constructor sets `m_Map = Map.Internal`, and `Mobile.Lift` calls `item.Internalize()` to put an item on the cursor, registration was consistently one step behind:
| State | Tracked for decay? | |
|---|---|---|
| Item on the ground | **No** | never decays |
| Item held on the cursor | **Yes** | backwards |
Nothing corrected it afterwards: the later `m_Map = map` assigns the field directly, bypassing the `Map` property setter, and that setter does not refresh registration either. With no parent, `RemoveItem` (which *does* re-register) never runs.
World load masked this — `ItemPersistence.PostDeserialize` re-registers every item against its final state, so decay appears to work for items that survive a restart. Only freshly dropped items are affected.
**Fix:** stamp `LastMoved` up front so decay math stays correct, then call `UpdateDecayRegistration()` once the parent, map, and location are final.
## Audit of the rest of the call sites
All 16 `SetLastMoved()` call sites were reviewed. `SetLastMoved()` must keep refreshing registration — `LastMoved` feeds `ScheduledDecayTime` and therefore which bucket an item belongs in — but it may only run once parent/map are final. The vendor, house, lift and drop sites already satisfy that. The rest of this PR fixes the ones that did not, plus what the audit turned up:
- **`Item.Deserialize`** stamped via `SetLastMoved()` before the version data was read, registering against an unread `Map`/`Parent`. Safe only by accident (the `Item(Serial)` ctor leaves flags at 0, so `Decays` is false), and it cost an unregister per item per world load. Now stamps only; `PostDeserialize` does the registration.
- **`Item` constructor** registered then immediately unregistered every item — the `Movable` setter saw `m_Map` still null, so `CanDecay()` was true. Also removes a `Configure`-order landmine: constructing an `Item` before `DecayScheduler.Configure()` would have thrown in `Shared.Start()`.
- **`Container.Destroy`** stamped `LastMoved` immediately before `MoveToWorld`, which now stamps it itself.
- **`Unregister` was documented O(1)** but scanned twelve buckets and did a linear `PriorityQueue.Remove`, on every construction, deserialize and move. Items now record where they are tracked in `Item.DecaySlot` (1 byte), so untracked items — the common case — leave in O(1).
- **A refused decay silently dropped the item.** `ProcessActiveQueue` deleted on `OnDecay() == true` but did nothing when a region refused, leaving the item dequeued, untracked and on the ground forever. It now restarts the decay clock; re-registering as-is would spin, since `ScheduledDecayTime` is already past.
## Two content bugs of the same class
The decay system replaced a polling sweep. Under polling, a `Decays`/`DecayTime` override could read live state every pass. Under a registration model it cannot — the scheduler drops items that stop being eligible, but nothing enrols one that becomes eligible while untracked.
- **`TreasureChestLevel1-4`** overrode `DecayTime` as `Utility.Random(15, 60)` — a fresh roll on every read. `ScheduledDecayTime` is read repeatedly (to bucket, to re-bucket on rotation, to test whether due), so those reads disagreed: the chest re-bucketed every tick and decayed early instead of after its intended interval. Now rolled once per chest. Distribution unchanged — `Utility.Random(from, count)` is RunUO's `from + Next(count)`, so this is 15–74 minutes, as before.
- **`StrongBox`** overrode `Decays` with a live check on `_house`, `_owner.Deleted` and `IsCoOwner`. Nothing notifies the box when any of those change, so it was never enrolled and the override never decayed anything — and it could not have: `HouseRegion.OnDecay` refuses a secured item inside a standing house, and the box is in `Secures`. Decay was never the mechanism here.
- A strongbox is only ever its owner's. Without a house, or without an owner still co-owning that house, it would be a free container anyone could loot, so `Validate()` destroys it. The old check missed exactly those two cases — it required a non-null owner and treated a null house as valid. A deleted owner deserializes back as null, which `IsCoOwner` rejects. The now meaningless `Decays`/`DecayTime` overrides and an unhelpful `Console.WriteLine` are gone.
`DecayScheduler` now documents both constraints.
## Tests
`DecayRegistrationTests` (Server) covers world placement, lift/drop, container round-trip, cursor-held (must *not* track), `Container.Destroy` spill, `DecaySlot`/structure agreement, refused decay, and a full decay lifecycle driven through the scheduler. `TreasureChestDecayTests` (UOContent) locks `DecayTime`/`ScheduledDecayTime` stability across all four chest levels.
To make the lifecycle testable deterministically, `DecayScheduler` gains `internal` members (visible only via existing `InternalsVisibleTo`): `IsRegistered()`, `ProcessTick(now)` — extracted from `OnTick()` with no behaviour change — and `ResetForTests()`.
Red/green verified. Without the `MoveToWorld` fix, 5 of 6 of the original tests fail, including `ItemOnGround_ActuallyDecaysAfterDecayTime`, which shows a ground item never decays even after a full simulated hour. Without the chest fix, 8 of 8 chest tests fail. Without the refused-decay fix, that test fails.
Server.Tests 737/737 and UOContent.Tests 509/509 pass.
## The bug
#2522 rewrote the outgoing huffman table in `NetworkCompression.cs` and transposed symbol `0x19`'s code from `0x1CE` to `0x12E` (both 9 bits, so the length distribution — and the Kraft sum — stayed valid, which is why nothing obvious tripped).
The real damage is that it broke prefix-freeness. `0x12E` is `100101110`, and symbol `0x0D`'s 8-bit code is `10010111` — a proper prefix of it. The client's decoder walks the tree bit by bit, so it hit a valid leaf at `0x0D` after 8 bits, emitted the wrong byte, and then reframed every subsequent code.
That is exactly what the reporter's capture shows. Server sends `BF 00 0C 00 19 02 00 00 00 01 00 00`; the client's post-decompression stream reads `BF 00 0C 00 0D 55 00 00 01 00 00` — the literal `0D` is the mis-decoded `0x19`, and the packet is now one byte short, so framing desyncs from there on.
## Impact
Any outgoing packet with byte `0x19` anywhere in its body (serials, coordinates, hues, lengths, text) corrupted the stream. Because the desync is in framing rather than a single field, the client silently stops applying server updates while still being able to send — no disconnect, no error.
`StatLockInfo` (`0xBF` subcommand `0x19`) is sent during login, so it reproduces on essentially every connection. This is also #2526: "can only walk a few steps, then the client stops responding" is the same desync, not a VPS sizing problem.
## Fix
One entry, restored to the canonical value:
```diff
- 0x9, 0x191, 0x9, 0x12E, 0x7, 0x03F, ...
+ 0x9, 0x191, 0x9, 0x1CE, 0x7, 0x03F, ...
```
## Validation of the whole table
Rather than eyeball 257 entries, I diffed the current table against **every revision of it in this repo's history** — all 34, back through the renames to the original import. All 34 agree with each other, and `0x19` is the sole disagreement with #2522's rewrite. No other entry has ever changed.
I also validated the table structurally: all 257 lengths in `[2,11]`, every value fits its declared bit-length, Kraft–McMillan sum exactly 1, and no code is a prefix of any other. It passes on all counts now, and the prefix check is what located the bug in the first place.
Both checks were one-off validation scripts, not committed — see below.
## Test
A single known-answer test (`~10ms`) that compresses all 256 symbols and asserts the exact output bytes. The expected bytes were generated from the canonical table, *not* from the implementation, so the test isn't circular. Any single wrong table entry changes the output, so it pins all 256 entries plus the terminal code, and it exercises the encoder end to end.
A round-trip test would **not** catch this class of bug — encoder and decoder built from the same table agree with each other even when the table is wrong. The contract being violated is with the client's hard-coded tree, so the expected bytes have to come from outside the implementation.
The structural prefix-free check and a second `StatLockInfo` vector were deliberately dropped after they'd served their purpose: the table is now verified and effectively frozen, so the structural check was guarding a constant, and the `StatLockInfo` vector is a strict subset of the all-symbols one. What remains covers the risk that's still live — `Compress` is a hand-unrolled bit-packing loop that will get optimized again, and this is the guard against that rewrite silently corrupting the wire format, which is precisely what happened here.
Verified the test fails when the bug is reintroduced and passes when fixed. Full `Server.Tests` suite green: 727 passed.
## At a glance
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
// Accumulate any number of free-text lines. On dispose the block flushes via
// AddChunked, splitting across as many OPL properties as needed so none can
// overflow the legacy 2D client's per-property buffer (which would crash it).
using var block = list.TextBlock();
if (luck > 0)
{
block.Add($"Luck Bonus: +{luck}%"); // zero-alloc interpolation
}
block.Add("Cannot be repaired".AsSpan()); // plain text, no string allocation
// Already holding a '\n'-joined string? Skip the builder and chunk directly:
// list.AddChunked(description);
}
```
## What
Adds a safe path for emitting **variable-length, free-form (non-cliloc) tooltip text**:
- **`ObjectPropertyList.Add(ReadOnlySpan<char>)`** overloads — append raw text with no string allocation. Makes the old single-arg `Add(string)` redundant (a `string` binds to the span overload implicitly), so it's dropped.
- **`AddChunked(ReadOnlySpan<char>)`** on `IPropertyList` — splits newline-joined text at `\n` boundaries across as many passthrough-cliloc properties as needed, so no single property exceeds the cap.
- **`OplTextBlock`** (`ref struct`) + **`IPropertyList.TextBlock()`** — an ergonomic builder that accumulates `\n`-joined lines (with a zero-alloc interpolated `Add($"...")` overload) and flushes via `AddChunked` on dispose. Usage: `using var block = list.TextBlock();`.
- **`MaxArgumentLength` (504)** — per-property cap with a hard backstop that clamps + logs anything that slips through.
## Why
The legacy 2D client copies each OPL property's text into a fixed ~512-char (1024-byte) buffer. A single property longer than that smashes an adjacent world object's vtable on the client heap and crashes the client. `AddChunked`/`OplTextBlock` keep multi-line content safely under the cap instead of risking one oversized `Add`.
## Docs
- `dev-docs/property-lists.md` — new "Multi-Line Free Text" deep-dive section; corrected the stale `IPropertyList` listing.
- `dev-docs/claude-skills/modernuo-property-lists.md` — condensed pattern + anti-pattern.
## Tests
9 tests pass (`OplTextBlockTests`, `ObjectPropertyListSpanAddTests`): line joining, empty-line skipping, no-line no-op, zero-alloc interpolation, and long-content chunking staying under `MaxArgumentLength`. Full `UOContent` build is green, confirming dropping `Add(string)` breaks no call sites.
## Summary
`Rectangle3DConverter.Write` corrupts a rectangle's Z range for certain bounds. The omit-z guard was:
```csharp
var writeZ = value.Start.Z is > sbyte.MinValue and < sbyte.MaxValue
|| value.End.Z is > sbyte.MinValue and < sbyte.MaxValue;
```
This drops `z1`/`z2` whenever **both** Z bounds sit at/outside the sbyte extremes — but `Read` reconstructs absent Z as exactly `z1 = -128, z2 = 127` (depth 255). So any rectangle that trips the omit condition without *being* that sentinel round-trips to depth 255 and is corrupted.
The concrete case: a **homeRange-style** bound `Start.Z = -128, End.Z = 128` (depth 256, the full vertical range used by spawners) gets `z1`/`z2` omitted on write, then reads back as depth **255** — silently losing the top z-level on every re-serialize.
(Spotted while working on #2505; spawners now prefer the `homeRange` form so most square bounds avoid this path, but any non-square `spawnBounds` or other `Rectangle3D` JSON is affected.)
## Fix
Omit Z only for the exact sentinel `Read` produces (`z1 == -128 && z2 == 127`); write it for anything else:
```csharp
var writeZ = value.Start.Z != sbyte.MinValue || value.End.Z != sbyte.MaxValue;
```
`Read` is unchanged, so existing `regions.json` rectangles that omit Z keep loading identically.
## Tests
New `Rectangle3DConverterTests`: round-trips the homeRange depth-256 case, the depth-255 sentinel, and ordinary/edge z values; asserts the sentinel omits Z while homeRange bounds write it. Server.Tests 717/717, UOContent.Tests 487/487.
## Summary
Removes the dated `DynamicJson` JSON helper and migrates spawner JSON (de)serialization to a polymorphic `record SpawnerDto` hierarchy. `DynamicJson` was the last remaining consumer (regions moved off it in #1400).
The key correctness improvement: **System.Text.Json deserializes plain DTO records, never a live `Item`.** Previously, deserializing directly into an `Item` meant STJ constructed world-registered objects *before* the data was validated — a malformed/hand-edited spawn file could leave orphaned spawner Items in the world save. Now a parse failure is GC-only; `dto.ToSpawner()` constructs the spawner only from a fully-validated DTO and self-cleans on failure.
## What changed
- **New:** `SpawnerDto` (abstract) + `SpawnerDataDto` / `RegionSpawnerDto` / `ProximitySpawnerDto`, each marked with a reusable `[JsonDiscoverableType]` opt-in attribute. Auto-discovered at the `Configure` phase — no manual registration list (avoids the regions `Register<T>()` footgun), open to custom spawner subtypes.
- **Symmetric mapping:** `BaseSpawner.ToDto()` (export) ⇄ `SpawnerDto.ToSpawner()` (import). `ToSpawner()` deletes-and-rethrows on any failure, so the importer can never orphan an Item.
- **`SpawnerJsonSerializer`** wires `$type` polymorphism on the `SpawnerDto` root with loud collision/constructibility validation.
- **Import/export commands** rewired to the typed DTO path (reflection `FindTypeByName`/`CreateInstance` removed).
- **Data migration:** the 109 `Distribution/Data/Spawns/**` files moved from `"type"`→`"$type"` and legacy `homeRange`→`spawnBounds`. The runtime still *reads* legacy `homeRange` for external files. The `homeRange→spawnBounds` formula is proven equivalent to the runtime conversion (`BoundsEquivalenceTests`, hr=0/1/3/7).
- **Deleted:** `Projects/Server/Json/DynamicJson.cs`.
Sparse export output matches the legacy `ToJson` (nullable DTO properties + `WhenWritingNull`). Binary world-save serialization is untouched.
## Tests
- DTO round-trip per spawner type; sparse-default omission; legacy `homeRange` read; export/import file round-trip.
- `Import_MalformedFile_LeaksNoWorldItems` — proves a mid-array parse failure constructs zero world Items.
- `AllSpawnFilesLoadTests` — every migrated spawn file deserializes and builds.
- Duplicate-discriminator validation.
- UOContent.Tests 485/485, Server.Tests 710/710, build clean.
## Follow-up (not in this PR)
`Rectangle3DConverter.Write` (in `Projects/Server/`) omits `z1/z2` when `Start.Z == -128`, so a future server-side export of a `homeRange`-style spawner round-trips depth 256→255. Pre-existing and out of scope here (Server change); the migrated data reads correctly. Worth a separate small converter PR.
## Problem
A creature equipped with two items that resolve to the **same layer** can crash the legacy EA 2D client (use-after-free). Equipment `Layer` comes from tiledata (`Layer = (Layer)ItemData.Quality`), so a **two-handed weapon and a shield both resolve to `Layer.TwoHanded`**. `Mobile.FindItemOnLayer` even documents the invariant: *"We only allow 1 item per layer. Its an implicit contract."*
## Root cause
- `SendMobileIncoming` (0x78) **dedupes by layer** (the `layers` span) and sends only the first item per slot — so a static creature is fine.
- But the per-item **`SendEquipUpdate` (0x2E)** and the item **OPL** sends do **not** dedupe. On any equip/property delta (`Item.ProcessDelta`) they fire per item and leak the second same-layer item on its own.
- The client then holds two items on one equipment slot; when that slot is torn down (e.g. a large group of such creatures and the player runs out of range → mass remove) the legacy 2D client frees one and dereferences it → UAF. ClassicUO bounds-checks and is unaffected, but the server is emitting an invalid, self-contradictory stream either way.
## Fix
Make per-item equip/OPL sends honor the same first-item-per-layer rule `SendMobileIncoming` already uses:
- `Item.IsDupedEquipLayer()` — `m_Parent is Mobile m && m.FindItemOnLayer(m_Layer) != this` (reuses the existing helper; true when an earlier item already holds this items layer).
- `Item.ProcessDelta`: early-return before the per-client loop for a duped equipped item (skips the EquipUpdate and OPL to everyone).
- `Mobile` lift-reject re-show: skip the EquipUpdate and OPL for the dupe.
No behavioral change for valid equipment (distinct layers → never duped). For the invalid duped-layer case the second item was already omitted by the 0x78 packet; this just stops it leaking back via the per-item paths.
## Problem
Houses and boats (multis) were pathed correctly only by **delegation to the slow path**: `StepCache.TryGetMask` returns `Fallthrough_Multi` for any multi-covered cell, and `GetSuccessors` ran `CheckMovement` **8× per cell** (each re-resolving the tile stack via `GetStaticAndMultiTiles`) — a sustained per-step cost near every house/boat. There was also no automated test pinning multi pathfinding.
This branch is the full multi-pathfinding effort in phases on one branch.
## Phase 1 — characterization tests (the oracle)
Implementation-agnostic invariants: a cache-on≡cache-off whole-path invariant, a per-cell sweep vs `CheckMovement` over footprint+halo (incl. destination Z), hand-verified routing (around walls, demolish-reopens, foundation-redesign-honored), classic-house / foundation / boat fixtures, non-vacuity guards. These gate every later phase byte-for-byte.
## Phase 2 — live single-pass synthesizer
`StepProbe.ComputeMultiMaskAt` synthesizes a covered cell's full 8-direction `StepMask` in one pass (the existing surface/step logic over `GetStaticAndMultiTiles` instead of 8× `CheckMovement`). `GetSuccessors` routes `Fallthrough_Multi` cells through it. No new cache, no `.swb` change. **~1.5×**, zero added allocations.
## Phase 3 / 3.1 — warm per-`multiID` interior cache (airtight)
`MultiMaskCache` caches each fixed multi's local-frame `StepMask` for **interior** cells (cell + all 8 neighbours covered → terrain-neighbour-free → position-invariant), keyed by `multiID & 0x3FFF`, built lazily from the MCL. Interior cells become ~20 ns lookups.
The cache is gated on a **per-instance footprint-clean flag** (`BaseMulti.PathInteriorCacheState`): an instance whose whole footprint terrain is below its floor (`maxTerrain < minFloor`) serves from the cache; a **dirty** instance (terrain intrudes — a contrived/GM placement) **degrades to live-synth, never a wrong mask**. This closes a cross-instance soundness gap (the cached mask depends on neighbour terrain too) found in a holistic review. The gate resets whenever the footprint's world-terrain relationship can change — **location, map, or ItemID** (a boat's heading swaps the MCL).
**Boats are cached too.** Their per-`multiID` deck masks are movement-invariant (built once per heading), so a sailing boat never rebuilds them; only the cheap clean-flag rescan repeats per move (and only when pathed near). Narrow existing boats have little interior; wide galleons (`multi.mul`) would gain Castle-class. `HouseFoundation` (per-instance runtime `DesignState`) is the one type that stays on the live path.
## Verification
- `UOContent.Tests` **454/454**, `Server.Tests` **708/708**, 0 failures.
- The Phase-1 oracle (`MultiPathInvariantTests`, cache-on ≡ cache-off) stays **byte-identical** with the synthesizer + interior cache active.
- Tests pin: footprint-cleanliness (clean vs sunk), dirty/cluttered placement degrades to live-synth while still pathing, clean placement serves, and the gate resets on move/ItemID change.
## Performance (modernuo/ModernUO-Benchmarks#8, full-fixture)
Houses at **Green Acres** (flat staff region → clean footprints, the legit-placement case):
| Route | Slow path | Phase 3.1 (interior cache) | Speedup |
|-------|----------:|---------------------------:|--------:|
| `around_a` (29 steps) | 238.3 µs | **49.1 µs** | **4.85×** |
| `around_b` (29 steps) | 224.3 µs | **49.5 µs** | **4.53×** |
~130 of ~167 multi cells/route serve from the cache (~20 ns) vs 37 live-synth. Per-cell, the slow path's 8× `CheckMovement` grows with multi complexity (GuildHouse ~857 ns → Castle ~1,194 ns), the synthesizer is a flat ~780 ns, and the cache serve is ~20 ns — so big/tall multis (and wide galleons) gain most. Identical allocations throughout.
## Summary
`Mobile.SayTo(Mobile to, int number, string args = "")` always sends the localized message using `SpeechHue`. This adds a parallel overload that accepts an explicit `hue`:
```csharp
public void SayTo(Mobile to, int number, int hue, string args = "") =>
to.NetState.SendMessageLocalized(Serial, Body, MessageType.Regular, hue, 3, number, Name, args);
```
It mirrors the existing localized overload exactly, only substituting the caller-provided `hue` for `SpeechHue`, so content can send a cliloc message to a single mobile in a chosen color without dropping down to `NetState.SendMessageLocalized` directly. This restores the hued-cliloc `SayTo` that RunUO/ServUO content commonly relied on (e.g. `SayTo(from, 1042205, 0x3B2)`).
## Notes
- Purely additive; no behavior change to existing call sites.
- No overload ambiguity: `SayTo(m, num)` and `SayTo(m, num, "args")` still bind to the existing overload; `SayTo(m, num, hue)` binds to the new one (the third positional arg is `int` vs `string`).
- Null-safe to the same degree as the existing overload (`SendMessageLocalized` guards via `CannotSendPackets()`).
## Test Plan
- [x] `dotnet build Projects/Server` — 0 warnings, 0 errors.
- One-line additive overload mirroring an existing (untested) method; no existing `Mobile.SayTo` unit tests to extend. Happy to add coverage if preferred.
## Summary
Fixes the pathfinding step-cache (`.swb`) prebake so it bakes **once** and skips when a valid cache already exists, instead of re-baking on every boot. The root cause was the staleness fingerprint hashing mutable in-memory tile data rather than the on-disk files. This PR makes the fingerprint a pure function of the client data files and separates dynamic multis (houses/boats) from the static cache.
> This branch builds on the `ConfigurePrompts` first-boot-prompt unification (commit `5df8d0bd`, also included here) — that commit accounts for the `ServerConfiguration.cs` and `dev-docs/server-lifecycle.md` changes in the diff.
## The bug
With `pathfinding.prebakeMaps` set, the cache re-baked on **every** boot. The `.swb` staleness fingerprint hashed the live `TileData.LandTable`/`ItemTable` flags, which the server patches at runtime (`ItemFixes`, `LOSBlocker`, `PotionKeg`, `CTF`) at nondeterministic lifecycle points (Initialize-phase methods share a priority; static ctors fire lazily). So a fingerprint stamped at runtime (`[PathBake`) never matched the one recomputed during startup `Initialize()`, and the cache rebaked every time.
## Changes
**1. Fingerprint the files, not the in-memory tables** (`fix`)
Hash `tiledata.mul` (cached, computed once) plus the per-map `.mul`/`.uop` files — never the runtime-mutated `TileData` tables. The fingerprint is now lifecycle-stable. Existing `.swb` files rebake once after deploy, then stay stable.
**2. Compute the fingerprint once per boot** (`refactor`)
`Configure()`'s `AutoLoadAtStartup()` already opens and fingerprint-validates a reader for every up-to-date `.swb`. `Initialize()` now skips baking any map that already has an open reader (`StepCache.HasLazyReader`) instead of recomputing the fingerprint a second time.
**3. Bake static-only; route multis to the live path** (`refactor`)
Multis (houses/boats) are dynamic, so they're no longer baked into the static chunk cache — they were tagged with `BuiltMultisVersion`, a non-persisted session counter, which made persisting them unsafe (false matches / wasted re-bakes).
- Chunks bake land + `statics.mul` only.
- At query time, any cell whose sector (or its 1-cell halo) contains a multi routes to `Fallthrough_Multi` → the existing live, multi-aware `CheckMovement` path. The halo prevents a cell proposing a walkable edge into a neighbouring wall; interior (multi-free) cells pay one sector lookup.
- Adds `Sector.HasMultis` (one engine accessor); `.swb` format → v9 (rejects old multi-baked files); new `Fallthrough_Multi` telemetry.
- Behaviour-preserving: multis use the same live path the engine used before the cache existed.
**4. Comment polish** (`style`) — no behaviour change.
## Testing
All green: **92** pathfinding (incl. a new fingerprint-stability test and a multi-halo fallthrough test), **423** UOContent, **708** Server.
## Follow-ups (not in this PR)
- **Background bake worker** — make `[PathBake` and the boot prebake non-blocking (game thread serves tile reads to an off-thread worker).
- **Per-multi MCL cache** — cache walkability in each multi's own frame (keyed by multiID, movement-invariant) so houses/boats get a fast path instead of the live fallback.
- **House-pathfinding equivalence tests** — the one area not yet covered by a dedicated automated test; multi pathing is currently correct by delegation to the live path.
Stacked on #2475 (the `ConfigurePrompts` phase). Base will switch to `main` once #2475 merges.
## What
Move the engine's own first-boot prompts — data directories, listeners, server name, expansion + map selection — out of `ServerConfiguration.Load` and into **`ServerConfiguration.ConfigurePrompts()`** (`[CallPriority(0)]`), so **all** first-boot prompting (engine and content) runs through the single `AssemblyHandler.Invoke("ConfigurePrompts")` phase. `Load` now only reads/creates the config file.
## Why it's safe
- **Assembly loading uses `AssemblyDirectories` (default `./Assemblies`), not `DataDirectories`** — so assemblies load fine before the now-later data-dir prompt. This is the linchpin that makes the move possible.
- **`UOClient.Load()`** (client-file discovery via `Core.FindDataFile`) needs `DataDirectories`, so it moved *with* the data-dir prompt into `ConfigurePrompts`.
- **`Core.Expansion`** is now assigned in `ConfigurePrompts` (every non-mocked boot). Nothing between `LoadAssemblies` and that phase reads it — type initializers run lazily on first use, not during `LoadAssemblies`.
- **`[CallPriority(0)]`** keeps the engine prompts (including map selection) ahead of content prompts such as the pathfinding pre-bake (priority 50), preserving "after map selection".
- `Main.cs` already invokes the phase — **no startup-ordering edit** here.
## Tests
`Server.Tests` **708/708**, `UOContent.Tests` **418/418**, build clean. Fixtures are unaffected: they call `Load(true)` (now just reads config) and set expansion/data dirs directly; `ConfigurePrompts` is gated on `m_Mocked`.
## ⚠️ Needs first-boot runtime verification
`Main.cs` startup ordering is **not** covered by the fixture-based suite (the fixtures bypass `Main`). Please boot once with a fresh `modernuo.json` to confirm the first-boot prompt sequence (data dirs → … → expansion/maps → pathfinding pre-bake) and that `Core.Expansion` resolves correctly. Docs updated in `dev-docs/server-lifecycle.md`.
## Summary
Adds authentic **T2A-era (pre-UO:Third-Dawn) packet-based crafting menus**, enabled via the **`t2aCraftMenus` server setting** (read once at startup; default **`!Core.UOTD`**, so a pre-UO:TD shard gets them automatically). When enabled, double-clicking a crafting tool opens the classic `0x7C`/`0x7D` item-list menu — skill- and material-filtered — instead of the modern gump, covering all 8 tool/skill crafts (blacksmithy, tailoring, tinkering, carpentry, alchemy, bowcraft/fletching, inscription, cartography). It is **not** a runtime/admin-flippable feature flag.
This is the **definitive, reconciled** branch and **supersedes**:
- **#2181** (Delphi — `T2A_CraftingMenus`): the original effort.
- **#2381** (Jack/UOLL — `t2a_crafting_menus`): the research-grounded superset (Delphi's base + 12 corrections), rebased onto current `main`.
Original authorship is preserved across the cherry-picked history: foundation commit **@Delphi79**, mechanic fixes **@jackuoll (Jack Ward)**, reconciliation/fixes/docs mine.
## How it was built
1. Cherry-picked Jack's 13 commits onto current `main` (superset of Delphi's; only 2 trivial FeatureFlags conflicts).
2. Applied targeted fixes (below) with tests.
3. Full convention audit, build, and test pass.
Grounded in independent historical research plus Jack's deep dive. Maintainer reference: `dev-docs/t2a-crafting.md`.
## Mechanics (highlights)
- Double-click tool → target resource → skill/material-filtered menu → craft. Resource pre-selection per skill; make-last by targeting the tool.
- **Stacked-gem jewelry:** target a gem stack → the **full stack** is consumed and the piece is named by count ("a 1000 diamond ring"); count persists (`BaseJewel` serialization **v4 → v5**, new `_gemCount`).
- **Tool-less inscription & cartography** (skill-list invoked; no pen/sextant); inscription consumes reagents+scroll on success and failure, mana only on success.
- **Tailoring matching-hue consumption:** targeting hued cloth/leather consumes only that hue. Crafted items take color from their **`CraftResource`** (not the dyed hue), so dyed leather/cloth don't tint the product; in T2A only colored ingots/ore color items (metal armor/shields).
- **Half-resources on failed non-scroll crafts** (pre-UO:TD).
- **Maker's mark** always prompted for exceptional items, via the shared `QueryMakersMarkGump`.
- Server-side menu infra changes are additive (`ItemListEntry.CraftIndex`, `Entries` setter, `HasSent`).
## Notable changes on top of the cherry-pick
- **Toggle is a startup server setting, not a feature flag.** Removed `ContentFeatureFlags.T2ACraftMenus` (and its admin-flippable plumbing); the value is read once via `ServerConfiguration.GetSetting("t2aCraftMenus", !Core.UOTD)` into `T2ACraftSystem.Enabled`. Since the default tracks the era and it can't be flipped at runtime, there's no incoherent "menus-on / UO:TD-era" state.
- **Stacked-gem consumption (B3a/B3):** consume the full `PendingGemCount` (was deliberately consuming 1 while naming by the stack), null-safe gem type, plain-piece fallback + message. New `T2AJewelGemCraftTests`.
- **Convention audit:** `new List<Item>()` → `PooledRefList<Item>` on the hue-aware consume path; removed dead code.
## Decisions & deviations
- `make-last` kept as **QoL** (post-T2A gump-era feature).
- `half-on-failure` (non-scroll) kept as a **reconstruction** (not OSI-confirmed).
- **Stacked-gem** behavior set per shard authority (overrides the "single gem" reconstruction).
- **Cooking** out of scope (no T2A crafting menu existed for it).
- **No colored items from dyed materials:** crafted color comes from the `CraftResource` type. Pre-AOS leather has no colored variant, so leather is always uncolored; weapons retain resource color only in AOS+ (unchanged, intended).
## Test plan
- Automated: `dotnet build ModernUO.slnx -c Debug` clean; `dotnet test Projects/UOContent.Tests` → **421 passed** (incl. 3 new jewelry tests).
- Manual (needs a running T2A shard + client):
- [ ] Each of the 8 skills opens the correct menu; empty-menu guard fires.
- [ ] Make-last repeats the last craft (jewelry re-prompts gem).
- [ ] Jewelry consumes the full targeted gem stack and names by count.
- [ ] Cartography consumes blank maps only with T2A enabled / maps+scrolls when disabled.
- [ ] Tailoring consumes only the targeted-hue material; crafted items are not tinted by dyed cloth/leather.
- [ ] Maker's-mark prompt on exceptional.
- [ ] Failed non-scroll craft consumes half resources.
- [ ] Inscription: reagents+scroll on success/failure, mana only on success.
- [ ] T2A disabled: gump crafting unchanged.
## Credits
Co-authored-by: @Delphi79
Co-authored-by: @jackuoll
## What
On **first boot** (right after map selection), offer to pre-bake the pathfinding `.swb` cache for the selected maps. This removes first-pathfind-after-boot latency and is now cheap — ~18 MB/facet after the v8 format work (the old ~565 MB is gone). The answer persists in `modernuo.json` as **`pathfinding.prebakeMaps`** (default **false**): asked exactly once, and skipped on headless/CI boots (redirected input) where operators can set the flag directly.
## How — a generic startup phase, not pathfinding hardcoded in the engine
The clean-console (pre-Serilog) prompt window is inside the engine startup, but UOContent isn't loaded until after `ServerConfiguration.Load`. So rather than coupling the engine to pathfinding, this adds a generic lifecycle phase:
- **`Main.cs`**: new `AssemblyHandler.Invoke("ConfigurePrompts")` — runs **after** `LoadAssemblies` (so content can participate) but **before** the first `logger.Information` (so console prompts aren't interleaved with the async console sink). The first log line moves below it. Any class can hook in with `public static void ConfigurePrompts()` and self-gate on first-boot state. No `ServerConfiguration` or pathfinding coupling added to the engine.
- **`PathCacheCommands.ConfigurePrompts()`**: the first-boot prompt (interactive-only, flag-absent-only); persists the answer.
- **`PathCacheCommands.Initialize()`** (`Invoke("Initialize")` phase, after the tile matrix loads — which the bake walks): when the flag is set, bakes any map whose `.swb` is **missing or stale** (tile-data fingerprint mismatch, via `StepCache.ComputeLiveFingerprint` / `TryReadFingerprintFromFile`). A fresh cache is a no-op, so only the first boot — or a post-client-update boot — pays the several-minute cost.
## Docs
Fixed the now-stale "~565 MB / ~1.5–2 GB / do not bake by default" section in `dev-docs/pathfinding.md` (it's 17.9 MB for Trammel, tens of MB for all six facets after v8), added a "First-boot pre-bake prompt" section, and added the `pathfinding.prebakeMaps` lever row.
## Verified
- `dotnet build UOContent -c Release` → 0 errors (rebased on #2474).
- Pathfinding/StepCache tests: **90/90 pass**.
- Bootstrap streamlining of the startup phases is intentionally left as a follow-up.
## Problem
Running the full `UOContent.Tests` suite, the test host **hangs ~2.5 minutes at shutdown and then crashes** (`Test host process crashed` / run aborted). The tests themselves are fine — they complete in ~1s — but the process can't exit.
Captured via `--blame-hang` dump. The blocking thread:
```
System.Threading.WaitHandle.WaitOne()
Server.SerializationThreadWorker.Sleep() SerializationThreadWorker.cs:54 (_stopEvent.WaitOne())
Server.SerializationThreadWorker.Exit() SerializationThreadWorker.cs:61
Server.World.ExitSerializationThreads() World.cs:429
Server.Tests.UOContentFixture..ctor()
```
### Root cause
Both collection fixtures (`UOContentFixture` and `PathfindingTestFixture`) each run the **full process-global ModernUO bootstrap**. `World.Load()` is guarded to run once per process, so the **second** fixture's `World.Load()` is a no-op and does **not** respawn the serialization workers — but `World.ExitSerializationThreads()` is **not** guarded, so the second fixture calls `Exit()` on workers whose threads have already terminated. `Exit()` → `Sleep()` → `_stopEvent.WaitOne()` then blocks forever (a dead thread never sets the event). The first collection's tests run; the second collection's fixture deadlocks in its constructor; the host eventually gets killed.
This is why single-collection (filtered) runs were fine — only one fixture ever bootstraps — but the full suite hangs. It's not a parallelization race: even strictly sequential, the second fixture deadlocks.
## Fix
**(a) Engine — idempotent `SerializationThreadWorker.Exit()`**
A second `Exit()` is now a safe no-op instead of a permanent block. Only the owning (main) thread calls `Exit()`, so no synchronization is needed, and the single-call production shutdown path is unchanged.
**(b) Tests — one shared bootstrap, strictly sequential collections**
- New `TestServerBootstrap.EnsureInitialized()` runs the superset global init **exactly once per process** (lock + once-flag).
- `UOContentFixture` / `PathfindingTestFixture` slim down to delegate to it and no longer tear down global state (which the single-bootstrap model owns for the host's lifetime).
- `[assembly: CollectionBehavior(DisableTestParallelization = true)]` so collections never overlap.
## Result
| | Before | After |
|---|---|---|
| Tests run (full suite) | 258 (UOContent collection deadlocked) | **418** |
| Outcome | 2.5-min hang → host crash | **418 passed, clean exit** |
| Wall time | killed | **~7s** |
## Summary
Phase #3b (final roadmap item), stacked on #2470. Compacts the index trailer from 20 to 8 bytes/chunk.
Trammel: 19.2 MB → 17.9 MB. Roadmap total: 565 MB → 17.9 MB (−96.8%).
## Details
- Trailer stores `{ u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength }` per chunk, in record write order; the file offset is dropped and reconstructed by cumulative recordLength from HeaderSize.
- No record reordering, no varint; fixed-stride, TryReadChunk unchanged.
- Also simplifies the accumulated `.swb` code comments across the stack.
- Format v8; v7 files rejected and re-baked once.
## Tests
v8 multi-chunk round-trip (cumulative offset reconstruction) + the v6/v7 suite; full pathfinding suite green; Release build clean.
## Summary
Phase #3a, stacked on #2469. Compresses each chunk record independently with libdeflate (random access preserved).
Trammel: 124.7 MB → 19.2 MB (−85%).
## Details
- Whole-record framing `[u32 UncompressedLen][payload]`; records that don't shrink (tiny Uniform) are stored raw, detected as payload length == UncompressedLen.
- Codec chosen by full-Trammel spike: libdeflate VeryHigh (16.5 MB, 1.83 µs/chunk decompress) over zstd L19/22 (17.4 MB) and managed Brotli q11 (16.4 MB) — best native ratio, fastest decompress, already the repo's packet codec (no new dependency).
- Reuses cached thread-static bindings: `Deflate.Maximum` for bake, `Deflate.Standard` for reads.
- Compression is bake-time only; decompression is one-time per chunk (LRU-cached).
- Format v7; v6 files rejected and re-baked once.
## Tests
v7 unit tests + the v6 suite run through the compression path; full pathfinding suite green; Release build clean.
Fixes#2462
## Summary
Removes the per-object `VirtualHairInfo` heap wrapper for mobile/corpse hair. Hair is now stored **inline** on `Mobile` and `Corpse` as `int _hairItemId` / `int _hairHue` plus a lazily-allocated, **non-serialized** ephemeral `Serial _hairSerial` (in the high virtual-serial range) — and likewise for facial hair. The `VirtualHairInfo` class is deleted, with a **lossless** save migration.
This delivers three things:
1. **Fixes a hair-removal bug.** `Delta(MobileDelta.Hair)` is deferred (it enqueues; `ProcessDeltaQueue` runs later in the tick). The old `HairItemID = 0` setter nulled `_hair` *immediately*, so by the time `ProcessDelta` built the remove packet the equipped virtual serial was already gone — the old `??=` code then re-materialized a **fresh** serial (≠ the equipped one), so clients never removed the right entity, and it left a phantom ItemId-0 object behind. The serial now lives on the entity and **persists across removal**, so remove packets carry the correct serial.
2. **Lightens the entity.** No heap hair object; bald mobiles allocate nothing (the serial is minted lazily only when hair is present). This was the original reason `HairItemID`/`HairHue` exist.
3. **Removes `VirtualHairInfo` entirely**, keeping the high-range virtual serial behavior.
## How
- **Mobile** (manual serialization): inline `_hairItemId/_hairHue/_hairSerial` (+facial); lazy `HairSerial`/`FacialHairSerial`; `ProcessDelta` reads those. Serialization **v36 → v37** — the v30-v37 deserialize is unified, reading the legacy per-hair `VirtualHairInfo` version int only when `version < 37`. Setting item id to 0 clears the hue (matching the old object-nulling) while retaining the serial.
- **Corpse** (codegen serialization): decomposed to `[SerializableField] int _hairItemId/_hairHue` (+facial) + ephemeral serial; **v16 → v17** with `MigrateFrom(V16Content)`.
- **Lossless migration:** the loader validates exact byte length, and the old corpse hair is a presence-bool-gated block, so a tiny **migration-only** `LegacyHairInfo` reader (no runtime role) consumes the legacy `[bool][int ver][int itemId][int hue]` bytes. Frozen `Corpse.v14/v15/v16.json` are retyped to it; `v17.json` describes the new int fields.
- All consumers updated to discrete accessors: `OutgoingMobilePackets`, `CorpsePackets`, corpse subclasses (`MilitiaFighterCorpse`, `SchmendrickApprenticeCorpse`), and the packet test mirrors.
- `VirtualHair.cs` renamed to `OutgoingVirtualHairPackets.cs` (the only type left in it after `VirtualHairInfo` was removed).
## Test Plan
- [x] Full solution build: **0 warnings, 0 errors** (`TreatWarningsAsErrors`).
- [x] `Server.Tests`: **708 passed** (incl. new `RemoveHairUsesEquippedSerial` / `RemoveFacialHairUsesEquippedSerial` proving the serial survives removal + hue clears).
- [x] `UOContent.Tests` corpse/hair: **6 passed** (incl. `CorpseHairMigrationTests` asserting the legacy hair bytes are consumed exactly — the loader's length invariant).
- [x] Generated migration code inspected: V14/V15/V16 readers consume the legacy block byte-for-byte; serial never written to disk.
## Upgrade notes
- Old Mobile (v30–v36) and Corpse (v13–v16) saves load losslessly.
- Minor cosmetic-only change: `SchmendrickApprenticeCorpse` hair/facial-hair RNG draws shift order within each pair (same draw count); irrelevant for a quest NPC corpse.
## Summary
Adds two pieces of pathfinding tooling on top of PR #2448's lazy `.swb` infrastructure:
- **`PathfindRecorder`** — admin-toggled JSONL telemetry capture; one record per `BitmapAStarAlgorithm.Find` call. Output format matches the BDN harness corpus, so production traffic can be captured and replayed in benchmarks without an adapter.
- **Public bake helpers on `StepCache`** — `ComputeLiveTileDataHash`, `TryReadTileDataHashFromFile`, `BakeMap`, `ClearResidentChunks`. Lets the benchmark project (and any future bake utility) drive cache fill + persist without exposing internal types.
The companion BDN harness update lives in [ModernUO-Benchmarks#kb/pathfinding-pr4-bench](https://github.com/modernuo/ModernUO-Benchmarks/tree/kb/pathfinding-pr4-bench): porting `Benchmarks/PathfindInGame/` from the `kb/ai_pathfinding` branch to the API shipped in #2446–#2448.
## What's in this PR
### `PathfindRecorder` (`PathfindRecorder.cs`)
- Holds a single `StreamWriter` open while recording; its internal buffer absorbs per-record writes without per-call `File.AppendAllText`.
- Single `bool` check on the hot path; cheap when disabled.
- Disabling flushes + disposes; an IO failure during write also disables the recorder.
- Server config:
- `pathfinding.recorder.enable` — bool, default `false`. Read on boot via `GetOrUpdateSetting`.
- `pathfinding.recorder.path` — default `<basedir>/Data/Pathfinding/recordings/pathfinds.jsonl`.
- Hooked into `BitmapAStarAlgorithm.Find` — runs once per call, does nothing when disabled.
- Admin command: `[PathRecord [on|off|flush|status]` (default `status`).
### Public cache helpers
- `static ulong StepCache.ComputeLiveTileDataHash()` — wraps the file module's hash function for staleness checks.
- `static bool StepCache.TryReadTileDataHashFromFile(string, out ulong)` — peeks at a `.swb` file's hash field (20 bytes).
- `int StepCache.BakeMap(int, string)` — walks every chunk in the map, populates resident set, saves. Offline / fixture use; blocks for many seconds on a full-map walk.
- `void StepCache.ClearResidentChunks()` — drops chunks + zeros counters but keeps lazy readers open. Lets benchmark loops measure "first query after boot" cost across iterations without the lazy-reader reopen overhead.
## Summary
Adds a binary disk format + lazy reader so the step cache can warm-start from a precomputed file without paying chunk-build cost on the first pathfind through a region. **Resident memory stays bounded by `MaxResidentChunks` regardless of file size** — opening a `.swb` reads only the header + chunk-offset index (~16 bytes per indexed chunk), and individual chunks are seeked + deserialized only when `ResolveMissingChunk` asks for them.
The lazy design (vs. an eager bulk load): a 250 MB bake on a RAM-constrained shard never materializes more than the LRU cap (~40 MB at the default 8192-chunk cap), and unwanted regions never enter memory at all.
Builds on PR #2447.
## What changed
- **`StepCacheFile`** — binary reader/writer module. Writer emits header → chunks (offsets recorded) → index trailer, then patches the header's `IndexOffset` field. Reader is `OpenForLazy(path)` returning a `LazyReader` that holds an open `FileStream` + offset dictionary.
- **`StepCacheFile.LazyReader`** — `TryReadChunk(chunkX, chunkY)` does a single seek + bulk read for one record. `Dispose` releases the underlying stream. Files are opened with `FileShare.Read | FileShare.Delete` so admin tooling can replace them.
- **TileData fingerprint via XxHash3.** The `.swb` header carries a hash of `LandTable + ItemTable` flags. Load rejects any file whose hash doesn't match the running server. Computed via `HashUtility.ComputeHash64` (engine-blessed hasher) — adds a `ReadOnlySpan<byte>` overload alongside the existing `ReadOnlySpan<char>` one for parity.
- **`StepCache.SaveToFile(path, mapId)`** — writes resident chunks for the given map.
- **`StepCache.TryOpenLazyReader(path, mapId)`** — opens the file, validates header, holds the reader for the map's lifetime.
- **`StepCache.ResolveMissingChunk`** — now consults the lazy reader before invoking the runtime baker. A loaded chunk whose `BuiltMultisVersion` doesn't match the live sector falls through to the baker (snapshot was made before a multi was added/removed in that sector).
- **`StepCache.Clear` closes lazy readers.** Test cleanup can delete `.swb` files cleanly.
- **Auto-load at startup.** `PathCacheCommands.Configure()` opens `Data/Pathfinding/<mapId>.swb` as a lazy reader for every map.
- **`[PathCacheSave`** / **`[PathCacheLoad`** — admin commands for the same workflow.
- **`pathfinding.maxResidentChunks` shard-tunable.** Read from `server.cfg` at boot via `ServerConfiguration.GetOrUpdateSetting` (default 8192 ≈ 40 MB). Small shards can tune down; large shards with substantial bakes can tune up to reduce eviction churn. Default is written back to `server.cfg` on first boot, matching the engine pattern used by other settings.
## File layout (v1)
```
Header (48 bytes):
u32 Magic = 0x42575300 ('SWB\0')
u32 Version = 1
u32 MapId
u64 TileDataHash XxHash3 over LandTable + ItemTable flags (HashUtility)
u64 BakeTimestamp informational
u32 ChunkCount
u64 IndexOffset file position where the chunk index begins
Chunk records (fixed size, ~5,393 bytes each, +32 if multi-Z):
u16 ChunkX
u16 ChunkY
u32 BuiltMultisVersion
u8 HasMultiZ
byte WalkMask[256], WetMask[256]
sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256]
[byte MultiZCells[32] when HasMultiZ == 1]
Index trailer (16 × ChunkCount bytes):
(u64 chunkKey, u64 fileOffset)
```
## Memory math
| Scenario | Disk file | RAM at boot | Notes |
|---|---|---|---|
| Empty / no `.swb` files | — | 0 | Silent; cache builds on demand. |
| Admin-curated towns (5K chunks) | 25 MB | 0 + per-query | Index ≈ 80 KB. Resident grows to the configured cap under steady-state queries. |
| Full-map bake (50K chunks) | 250 MB | 0 + per-query | Index ≈ 800 KB. Same configured cap. Cold areas never load. |
| All 5 maps fully baked | 1.25 GB | 0 + per-query | Index ≈ 4 MB total. Same configured cap. |
## Hash choice (FNV-1a → XxHash3)
The original draft used inlined FNV-1a-64. Switched to XxHash3 via `HashUtility`:
- ~30× faster on this workload (~30 GB/s SIMD vs ~2 GB/s byte-by-byte). Boot-time only, so absolute saving is microseconds — the real wins are elsewhere.
- Stronger collision resistance and distribution.
- Drops ~25 lines of inlined hash code; matches the rest of the codebase's hashing pattern.
- Hash is stable as long as `HashUtility`'s `xxHash3Seed` constant doesn't change (already marked `// DO NOT CHANGE THIS NUMBER`).
## Summary
Replaces `FastAStarAlgorithm` with `BitmapAStarAlgorithm`: one cache lookup per cell expansion (8-direction mask + per-direction destination Z) instead of 8 separate `MovementImpl.CheckMovement` calls. Adds the supporting cache infrastructure to back it.
Public API unchanged — `MovementPath` / `Mobile.Move` / `CalcMoves.Find` return the same shapes; the algorithm swap is internal.
## What's in this PR
- **`BitmapAStarAlgorithm`** — A* that issues one `StepCache.TryGetMask` call per cell expansion. Inline fallthrough to the per-cell slow path for multi-Z, off-map, source-Z mismatch, and non-default walkers.
- **`StepCache`** — singleton chunk store keyed by `(mapId, chunkX, chunkY)`. Lazily built on first query, invalidated by `Sector.MultisVersion` mismatch, memory-bounded by sampled probabilistic LRU.
- **`StepProbe`** — computes static-only walkability for a single cell, mirroring `MovementImpl.Check` minus the item / mobile collision phases.
- **`StepMask` / `StepChunk`** — value / storage types for the per-cell results.
- **`CacheEvictionTimer`** — periodic cap backstop (60s interval; early-returns when not over cap).
- **`Map.Sector.MultisVersion`** promoted to `public` so the cache can detect dynamic-static invalidations cheaply.
## Eviction strategy
Sampled probabilistic LRU (Redis-style). Per eviction, sample 5 random keys from a parallel `List<long>` kept in lockstep with the chunk dictionary; evict the oldest of the sample via swap-and-pop. O(1) per eviction regardless of resident count, so sustained cap pressure has no perpetual perf hit.
## Capability handling (interim)
Non-default walkers (non-GM players, creatures with `CanSwim` / `CanFly` / `CanOpenDoors` / `CanMoveOverObstacles`) route entirely through the per-cell slow path via `BitmapAStarAlgorithm.GetSuccessorsSlowPath`. The 2-pass design (cache + capability overlay + dynamic-obstacle pass) lands in the follow-up PR.
## Summary
Phase 3 PR B of the message-interpolation cleanup. Handles the multi-line restructure sites flagged in `dev-docs/string-handling-message-interp-audit.md` (Phase 2). Phase 3.1 (PR #2436) handled trivial sweeps; this PR handles sites that needed an `if/else` hoist or switch restructure to eliminate `string.Format` while preserving exact message text.
Each site previously allocated an intermediate `string.Format(...)` result before passing to the message handler, despite Phase 1 making the handler accept interpolated string handlers natively.
## Sites fixed
- **`Projects/Server/Mobiles/Mobile.cs:7911`** - Title/guild header was using `string.Format` with a conditional template (`"[{1}]{2}"` vs `"[{0}, {1}]{2}"`). Split into `if (title.Length <= 0)` / `else` with direct `$"..."` interpolation.
- **`Projects/UOContent/Engines/ConPVP/DuelContext.cs:1337`** - View-ladder rank text used `string.Format(text, from == pm ? "You" : "They")`. Split into `if (from == pm)` / `else` with direct `$"..."` interpolation in each branch.
- **`Projects/UOContent/Engines/ConPVP/DuelContext.cs:1463`** - Showladder text reused a single format string for both `LocalOverheadMessage` ("You ... are ...") and `NonlocalOverheadMessage` ("`{pm.Name}` ... is ..."). Each call now uses an inline `$"..."` directly; no shared template.
- **`Projects/UOContent/Engines/ConPVP/Gumps/ConfirmSignupGump.cs:518`** - The signup confirmation message used a `switch` expression assigning a literal format string to `fmt`, then `string.Format(fmt, from.Female ? "Lady" : "Lord", timeUntil)`. Converted to a `switch` statement where each case calls `_registrar.PrivateOverheadMessage(...)` directly with an inline `$"..."`. Lady/Lord branching is hoisted to a `title` local.
## Exempted
- **`Projects/UOContent/Engines/ConPVP/Participant.cs:138`** - The `nonLocalOverhead` format string is a parameter passed in by callers of `Participant.Broadcast`. Investigation found 5 call sites in `DuelContext.cs` (lines 782, 802, 1187, 1196, and three at 1535/1564/1608) that pass distinct literal format strings. Refactoring would require changing all 5 callers and the method signature - out of scope for this PR. Marked with a `// Phase 3 audit:` comment per the audit's exemption convention.
## Summary
Phase 3.1 of the message-interpolation optimization series. Fixes 9 of the 28 sites flagged in the Phase 2 audit (PR #2435):
| File | Fix |
|---|---|
| `Commands/StaffAccess.cs:88,99` | Drop redundant `.ToString()` on enum holes |
| `Commands/Handlers.cs:102` | `builder.ToString()` -> `builder.AsSpan()` |
| `World Saves/SaveCommands.cs:71-75` | Merge 3 concatenated `$"..."` into one literal |
| `Server/Items/Item.cs:4213` | Hoist nested ternary `$"..."` to if/else |
| `Engines/Factions/Mobiles/Guards/BaseFactionGuard.cs:140-150` | Convert switch expression to switch statement |
| `Mobiles/Monsters/LBR/Jukas/JukaLord.cs:85` | Restructure `string.Format(toSay.RandomElement(), ...)` into switch |
| `Misc/AttackMessage.cs:30-41` | Inline `AggressorFormat`/`AggressedFormat` constants |
No functional changes. Each site emits identical text; the only difference is that the message string is now built into a pooled char buffer instead of being allocated as a `string` first.
## Summary
Adds a custom `:L` format specifier to `RawInterpolatedStringHandler`. When the format string is `"L"`, the handler lowercases the formatted value's chars in-place after the underlying `ISpanFormattable.TryFormat` / `IFormattable.ToString` path completes. Zero allocation, single-pass.
## Usage
```csharp
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold"
mob.SendMessage($"Welcome, {playerName:L}"); // lowercased
mob.SendMessage($"{count:L} kills"); // ints unchanged ("42")
```
## Motivation
Eliminates the `value.ToString().ToLowerInvariant()` two-allocation idiom that appears across the codebase for any type that goes through an interpolation handler. After this lands, content code can use the `:L` specifier directly instead of helper extensions or per-enum lookup tables.
## Coverage
- `AppendFormatted<T>(T value, string? format)` — generic path (covers IFormattable, ISpanFormattable, .ToString fallback)
- `AppendFormatted(ReadOnlySpan<char> value, int alignment, string? format)` — span path with alignment-aware lowercase range (only the value range is lowercased, not padding)
- `AppendFormatted<T>(T value, int alignment, string? format)` and `AppendFormatted(string? value, int alignment, string? format)` and `AppendFormatted(object? value, int alignment, string? format)` — inherit via delegation
The `format == "L"` comparison is case-sensitive — `:l` (lowercase L) is NOT recognized. `:L` matches the convention of e.g. `:N0` / `:F2` (numeric format specifiers traditionally use uppercase). `char.ToLowerInvariant` is used (not locale-dependent) for predictable game text.
## Future cleanup
Phase 3.3 (#2438) introduced a per-enum `TrophyRank.LowerName()` extension to eliminate `rank.ToString().ToLower()` allocations at 10 ConPVP sites. Once this PR lands, those sites can be simplified to `{rank:L}` and the `TrophyRankExtensions` helper can be removed. Tracked as a follow-up.
## Summary
Phase 1 of a multi-phase optimization to eliminate intermediate string allocations between `$"..."` interpolation and the packet text region for ModernUO's player-facing message APIs.
- Adds `[InterpolatedStringHandler]` overloads to every `Send*`/`Public/Local/Private/NonlocalOverheadMessage`/`Say`/`Emote`/`Whisper`/`Yell`/`SendLocalizedMessageTo` API in `OutgoingMessagePackets`, `Mobile`, and `Item`. Each overload is a 3-line shim that forwards `handler.Text` to the existing span-based path then calls `handler.Clear()` to return the rented `STArrayPool<char>` buffer (matches the established `SpanWriter.WriteAscii(ref RawInterpolatedStringHandler)` precedent).
- Converts `string text/args/affix/name` parameters to `ReadOnlySpan<char>` for consistency with the handler path. `lang` intentionally stays `string` (it's never interpolated and the `??= "ENU"` fallback stays cleaner).
- Adds `int charCount` overloads of the three `GetMaxMessage*Length` helpers so stackalloc sizing can avoid the redundant `ROS<char>` round-trip.
- Moves `Mobile` (17 methods) and `Item` (4 methods) message methods into new partial-class files (`Mobile.Messages.cs`, `Item.Messages.cs`) for organization.
No UOContent call sites change in this PR — existing `string`/`ROS<char>` calls compile unchanged via implicit conversion. Phase 2 (intermediate-string audit) and Phase 3 (cleanup PRs) follow.
## Files
- `Projects/Server/Network/Packets/OutgoingMessagePackets.cs` — `string` → `ROS<char>` for text params, `int charCount` length helpers added, class made `partial`
- `Projects/Server/Network/Packets/OutgoingMessagePackets.Interpolated.cs` (new) — 3 `ref RawInterpolatedStringHandler` extension overloads
- `Projects/Server/Mobiles/Mobile.cs` — message methods extracted (-262 lines)
- `Projects/Server/Mobiles/Mobile.Messages.cs` (new, 463 lines) — moved + ROS-converted methods + 25 handler overloads
- `Projects/Server/Items/Item.cs` — message methods extracted (-93 lines)
- `Projects/Server/Items/Item.Messages.cs` (new, 142 lines) — moved + ROS-converted methods + 4 handler overloads
- `Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs` — 3 new regression tests verifying byte-equivalence for the handler overloads
## Summary
Migrates the Old Guild System (pre-AOS guild stones) gumps from the legacy `Gump` class to `DynamicGump`, following the same pattern used for the Quest gump migration in #2416. All concrete gumps now have private constructors gated by static `DisplayTo` entry points (empty-gump rule), and `Singleton => true` is set across the board so reopening a sibling dialog automatically closes the previous one.
**Migrated gumps:**
- `GuildGump` - main guild dialog
- `GuildmasterGump` - guildmaster functions
- `GuildCharterGump` - charter and website display
- `GuildWarGump` - warfare status (kept as player-facing)
- `GuildWarAdminGump` - war menu (retained as player-facing - reachable from `GuildmasterGump`'s WAR button by guildmasters)
- `GuildChangeTypeGump` - Standard/Order/Chaos selection
**Abstract bases:** `GuildListGump` and `GuildMobileListGump` keep their shared list-rendering chrome inside a single concrete `BuildLayout` on the abstract class and expose a `protected abstract void BuildHeader(ref DynamicGumpBuilder builder)` hook for subclasses (replacing the old `Design()` override). This mirrors the abstract-base treatment used for the ML quest base in the quest-gump migration PR.
**Concrete subclasses migrated alongside the abstract bases:**
- `GuildListGump` subclasses: `GuildAcceptWarGump`, `GuildDeclarePeaceGump`, `GuildDeclareWarGump`, `GuildRejectWarGump`, `GuildRescindDeclarationGump`
- `GuildMobileListGump` subclasses: `DeclareFealtyGump`, `GrantGuildTitleGump`, `GuildAdminCandidatesGump`, `GuildCandidatesGump`, `GuildDismissGump`, `GuildRosterGump`
**Cliloc rule:** Every gump bakes per-instance dynamic content (guild names, member names, war declarations, candidate lists), which would defeat `StaticGump<T>` caching. Per the cliloc rule, all are `DynamicGump`.
**External callers updated:** the prompt files (`GuildAbbrvPrompt`, `GuildCharterPrompt`, `GuildDeclareWarPrompt`, `GuildNamePrompt`, `GuildTitlePrompt`, `GuildWebsitePrompt`), `RecruitTarget`, the `Guildstone` item, and the New Guild System `GuildInfoGump`'s Order/Chaos handler all now go through static `DisplayTo` entry points instead of `new XGump(...)`.
## Summary
Removes per-call heap allocations from `Container`'s consume / find / group hot paths and from `BaseCreature.OnDeath`'s fame/karma tracking. The headline wins: kill the `List<List<Item>>` + `Item[][]` + `int[]` grouping bridges in `ConsumeTotal*` / `ConsumeTotalGrouped*` / `GetBestGroupAmount*`, and kill the per-call `Predicate<Item>` allocations in `FindItemsByType(Type)` / `FindItemsByType(Type[])`.
### `Container.cs`
- `ConsumeTotal`, `ConsumeTotalGrouped`, `GetBestGroupAmount` now share four streaming helpers (`HasAmount`, `TryFindGroupMeetingAmount`, `BestGroupTotal`, `ConsumeSlice`) backed by `PooledRefList` instead of allocating per-group lists and jagged arrays. Two-phase validate-then-consume pattern preserved — all-or-nothing semantics for spell reagents, vendor pay, and crafting still hold.
- `(Type)` / `(Type[])` / `(Type[][])` overload trios collapsed to single `ReadOnlySpan<Type>` + `ReadOnlySpan<int>` implementations. Implicit `T[] → ReadOnlySpan<T>` conversion means UOContent callers compile unchanged.
- Unused overloads deleted: `ConsumeTotalGrouped(Type)`, `ConsumeTotalGrouped(Type[][])`, `GetBestGroupAmount(Type)`, `GetBestGroupAmount(Type[][])`, plus the never-called `TryDropItems` hook and its private `ItemStackEntry` struct.
- Fixes a `PooledRefList` leak in `GetBestGroupAmount(Type[], …)` (missing `using`).
- `m_ContainerData` / `m_Items` / `m_TotalGold` / `m_TotalItems` / `m_TotalWeight` / `ContainerData.m_Table` / `ContainerData.logger` renamed to the underscored convention. `m_Items` cross-file rename for the Container-side references in `Item.cs`; `Item.CompactInfo.m_Items` deliberately left alone (separate effort).
- `CheckHold` parent walk simplified; trivial dispatch methods (`CheckHold` overloads, `OnItemAdded`, `OnItemRemoved`, `OnStackAttempt`) get `[MethodImpl(AggressiveInlining)]`; `Destroy` and `DisplayTo` cache `Items` outside the loop; dead comments removed.
### `Item.Enumerable.cs`
- `FindItemsByType(Type)` previously allocated a `Predicate<Item>` per call (method-group conversion). `FindItemsByType(Type[])` allocated a closure capturing `types`. Both now construct the enumerator with a `Type` / `ReadOnlySpan<Type>` field directly, no delegate.
- `FindItemsByTypeEnumerator<T>` gains two constructors plus a `Matches(T)` helper that picks the right filter inline. Constructor chaining via a private 2-arg seed constructor incidentally fixes a pre-existing bug where `PooledRefQueue` was always rented at capacity 0 because `_recurse` hadn't been assigned yet.
- `(Type[])` overload of `FindItemsByType` becomes `(ReadOnlySpan<Type>)`.
- `EnumerateItemsByType(Type)` / `EnumerateItemsByType(ReadOnlySpan<Type>)` / `ListItemsByType(Type)` / `ListItemsByType(ReadOnlySpan<Type>)` simplified to delegate to the new alloc-free overloads instead of filtering manually.
### `Utility.cs`
- `InTypeList<T>(this T, Type[])` and `InTypeList(this Type, Type[])` switched to `ReadOnlySpan<Type>`.
### `BaseCreature.cs`
- `OnDeath` per-death `List<Mobile>` / `List<int>` / `List<int>` for fame/karma tracking switched to `PooledRefList`.