Splits creature speed into two clocks so movement pace can be tuned without touching reaction time:
- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence.
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value.
### How
- Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22).
- `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs.
- `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes.
- `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes.
- The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly.
### Behavior changes
- **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted.
- **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock.
- **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep.
- **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles.
- **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too.
- Removes the vestigial `MoveSpeedMod` (never read, written, or serialized).
- With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before.
### Testing
- Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding).
- In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes.
### Summary
Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461).
### Root causes and fixes
1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash.
2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle.
3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`.
4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive.
5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase.
6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now.
### Reference behavior
RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation.
## Summary
Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save.
The answer to "is it possible everywhere": **yes** — including the one case that looked impossible.
## The GenericPersistence problem, solved
`GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected.
## Converted
- **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`.
- **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`.
- **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version.
**Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them.
## Verification
- Build 0 errors / 0 warnings; **837 + 708 tests green**.
- Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched.
- **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties).
## Notes for review
- `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes.
- BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes.
## Enforcement
`WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract.
## Summary
Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes.
**Updated surface, everywhere it was referenced:**
- `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional).
- `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes.
- New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails.
- `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express.
- `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them).
**Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9.
**Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern.
Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`.
## Summary
Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate.
```cs
// before
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int Charges
{
get => _charges;
set
{
_charges = Math.Clamp(value, 0, MaxCharges);
InvalidateProperties();
this.MarkDirty();
}
}
// after
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;
private bool AllowChargesChange(ref int value)
{
value = Math.Clamp(value, 0, MaxCharges);
return true;
}
```
## How sites were selected
A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately:
- **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these.
- **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight).
- **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field).
Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`).
## Verification
- Build: **0 errors, 0 warnings**.
- **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it.
- **835 + 708 tests green.**
## Behavioral notes (all strict improvements, called out for review)
- Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes).
- Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction.
## Summary
Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently:
1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`).
2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**.
3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.**
## Verification
- Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed).
- **835 + 708 tests green.**
- Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing.
- Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched.
- The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying.
## Notes
- New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations.
- Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks.
## Summary
The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save).
## Design
- **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing.
- **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew.
- **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose.
- **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift.
## Scope
Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests.
## Testing
- Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization.
- End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header.
- Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests).
### Summary
* Upgrades Serialization Generator to v3. This contains numerous bug fixes and a significant performance improvement.
* Bumps other dependencies.
## Summary
A GM flipping `Movable` back on for a long-frozen item made it vanish within one scheduler tick. The setter registered the item with a deadline computed from its stale `LastMoved`, so `ProcessActiveQueue` deleted it almost immediately. The pre-#2311 save-time sweep had the same semantics, just hidden behind the save cadence. The same failure existed for `Visible` and `Spawner` transitions.
`LastMoved` is deliberately left meaning actual movement — it feeds vendor inventory expiry and house moving-crate checks — so the fix does not rewrite it for state changes.
## Changes
- **`DecayResetTime`** (CompactInfo-backed): the decay countdown runs from the later of `LastMoved` and this stamp. `RestartDecay()` stamps it only when the item can decay and the stamp extends the current deadline, so hot paths with a fresh `LastMoved` allocate nothing.
- **`Movable`/`Visible`/`Spawner` setters** call `RestartDecay()` instead of registering a stale deadline.
- **Region-refusal retry** in `DecayScheduler` uses `RestartDecay()` instead of rewriting `LastMoved`.
- **Persistence**: the stamp survives save/load as a `WriteDeltaTime` delta under `SaveFlag.DecayReset` (to become `WriteAnchoredTime` once the save-time anchor is ported) (Item serialization v10), so a restart mid-window no longer deletes the item.
- **`LastMoved` setter** drops a superseded stamp so the `CompactInfo` can collapse instead of being held (~40 bytes) forever.
- **Raw `Map` setter** now counts as a move for parentless items: it stamps `LastMoved` and updates decay registration, closing the gap where an item moved out of `Map.Internal` via the setter never decayed.
- **`LiftItemDupe`**: the remainder of a partially lifted *ground* stack was placed via raw `Location`/`Map` assignments and never enrolled for decay (lingering-trash leak since #2311) — now enrolled via the Map setter. Parented remainders get their map from `AddItem` (parent first, then map), so container splits never transit the scheduler.
### 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.
Two features ran on every shard out of the box, each polling on its own 60s timer for files most shards never generate, neither ever asked for. Fixing that turned into untangling why they shared a config file — and then into the on-loop cost of the three lists behind them.
## Before / after
Measured on the shipped defaults. On-loop numbers are what freezes the world; the tick budget is 8 ms.
| | before | after |
|---|---:|---:|
| Blocklist poll on a shard with no list | every 60s, forever | **none** (opt-in) |
| Manual allowlist poll on a shard with no carve-outs | every 60s, forever | **none** (opt-in) |
| Promote-guard sweep timer | leaked on `Stop()` | stopped, and only started when hits are reported |
| Login allowlist flush, on-loop | O(n) walk + 2 arrays **every 60s**, LOH past ~5,300 entries | reused buffers, **hourly**, zero steady-state allocation |
| Auto-denylist, accept path | 9.1 ns/call | **6.1 ns/call** |
| Auto-denylist, sustained flood at cap (60k rejected) | 26.7 ms | **9.3 ms** |
| Auto-denylist, flood end — **worst single call** | 9.49 ms | **0.05 ms** |
| Auto-denylist cap | 65,536 (stranding 9,895 slots) | **324,449** (exact `HashSet` capacity, ~19 MB) |
The auto-denylist row that matters is the third: the on-loop stall at flood end drops **190×**, because retiring lapsed holds is now the number expiring rather than the number held.
## Why this design
It is built for the shape of attack these shards actually see: **hundreds to a few thousand connections per second**, occasionally tens of thousands, sustained over minutes rather than delivered instantly. Against that shape the cap now covers the whole observed range (50k–250k distinct sources) in memory, and the work of expiring them spreads across the accept calls that were already happening.
There is one case this design is *worse* at than the old one: if every held entry lapses within the same millisecond, retiring them costs ~10.7 ms against the old ~8.9 ms, because the ring's random-access set removals lose to a sequential dictionary scan. Reaching it requires an entire flood to arrive inside one millisecond. **A shard absorbing 324,449 connections in a millisecond is finished at the accept path no matter what this list does** — that is the point where the answer is upstream security and scrubbing (an L4 proxy, edge filtering, a bouncer at the kernel), not a data structure in the game loop. We chose the design that fits the attacks we see and degrades honestly past them, rather than over-engineering for one we do not.
## Blocklist — now opt-in
`BlocklistFilter.Start` only bailed when `_path == null`, which needs `file` to be empty. The default is `"Configuration/ip-blocklist.txt"`, so on any default install both `Task.Run(PollLoop)` and a recurring `SweepGuard` timer started unconditionally, logging *"Blocklist inert: no list at …; polling every 60s"* and then doing exactly that forever.
Adds `"enabled"`, default `false`, using the `_enabled = s.Enabled && <preconditions>` idiom already in `LoginAllowlist` and `AutoDenylist`. **Upgrade is deliberately loud**: a missing key binds to the default, so `LogWhyDisabled()` splits three cases and a shard with a list on disk but no `enabled` key gets a **Warning**, not silence.
## `FileAllowlist` → `ManualAllowlist`, with its own config
Moves to `Configuration/ip-allowlist.json` (`enabled` default `false`, `files`, `reloadInterval`) and into `Network/ManualAllowlist/`, mirroring `Network/LoginAllowlist/`.
It was never a sub-feature of the blocklist. `ManualAllowlist.Contains` has two callers:
| Caller | Could anything else do it? |
|---|---|
| `BlocklistFilter.Evaluate` | **Yes** — the generator already subtracts these files at generation time |
| `BanExemptions.IsExempt` | **No** — sole mechanism for suppressing behavioural ban contributions |
The second reaches `BanChannel.IsExempt` with no blocklist in the path. A shard running **no blocklist** still needs this so the admin's own IP isn't auto-banned by rate-limit detection, so a shared flag couldn't express it — the implication is asymmetric. They still work together via a startup warning when the blocklist is on and the allowlist is not.
On the name: "File" described the storage. The distinction from `LoginAllowlist` is **provenance** — declared by an operator versus earned by authenticating — and "Manual" matches `BanReasons.Manual`. `allowlistFiles` is removed from `BlocklistSettings` outright; blocklists have not shipped long enough for anyone to have set it.
## Login allowlist flush
`Flush()` allocated two arrays sized to the live entry count and copied the whole dictionary into them **on the game loop**, every 60s. `UInt128` is 16 bytes, so past ~5,300 entries that first array was an LOH allocation once a minute, forever. The file write was already off-loop; the walk was not.
Static buffers grown geometrically; the writer owns them until it posts completion back through `Core.LoopContext`, so `_writing`/`_dirty` stay loop state (rule #10). Interval → 1 hour against a 90-day TTL. Clean shutdown writes synchronously via `EventSink.Shutdown`; `HandleClosed` skips `InvokeShutdown` when crashed, so the crash path subscribes separately and only writes when it is actually on the loop thread. Also fixes a pre-existing hole where `_dirty` was cleared *before* the write, so a failed write dropped entries despite the comment promising a retry.
## Auto-denylist: expiry ring
Reclaiming lapsed holds was O(entries held) — every cap-triggered reclaim during a flood walked the whole dictionary to find the few that expired, and `_warnedFull` suppressed the log, not the work.
A hold is **never refreshed** now: the first detection sets the expiry, later ones leave it. That makes insertion order equal to expiry order, so a ring of the same keys is sorted by construction and retiring stops at the first live record. Nothing is lost — the rate limiter runs *ahead* of the connection filters (`NetState.Network.cs`) and reports to the ban channel, so a flooder whose hold lapses is re-held on its next attempt.
Because the ring carries the expiry, the membership side only answers "present?", so it is a `HashSet` — measured at **36 B/slot against the dictionary's 52**. `HashSet` and `Dictionary` share `HashHelpers`, so the from-empty capacity progression is identical (36,353 → 75,431 → 156,437 → 324,449 → 672,827) and the cap still lands on one exactly. The ring is parallel `UInt128[]`/`long[]` rather than an array of structs — `UInt128` forces 16-byte alignment, so a packed pair costs 32 bytes where these cost 24, and the drain reads only the `long[]`.
Rejected after measuring: splitting the drain into a scan loop plus a removal loop (inside noise — both issue N hash removes, and the pointer math was never the bottleneck), and `Dictionary<UInt128,bool>` with tombstoning instead of removal (10% slower *and* unbounded, which breaks the cap).
## Testing
Build clean, 0 warnings. **1,530 tests pass** — 708 UOContent, 822 Server.
Tests were reworked rather than patched: the refresh test inverts to `Repeat_detection_does_not_extend_the_hold`, the obsolete sweep-throttle test is deleted along with the throttle, and four were added for the ring — set/ring parity, release-then-re-hold not being retired by the stale record, exact fill of a non-power-of-two cap, and the moved allowlist config's casing contract. The throttle test added mid-PR was verified to fail without its fix before being deleted.
One commit is comments only (verified: a diff filtered of `//` lines is empty), removing development narration — a `"(Task 2)"` plan reference, `"matching the per-feature JSON config pattern used by X"` across four loaders, a duplicated threading note — and repointing `Firewall` at `dev-docs/ip-bans-and-allowlists.md` instead of a "ban-channel design doc" that does not exist.
Note `Distribution/Configuration/blocklist.json` is gitignored (`.gitignore:14`) and generated from the record defaults on first boot, so the record default *is* the shipped default.
## 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 #2574, which added a `.Migrations.cs` partial to `BaseWeapon`. Pure relocation — no behaviour change.
## The inconsistency
`BaseArmor` and `BaseClothing` already kept their pre-codegen `Deserialize(reader, version)` in a `.Migrations.cs` partial, but left the `OldSaveFlag` enum and the `GetSaveFlag` helper behind in the main class file — even though every call site is in the partial:
| Class | `Deserialize` | `GetSaveFlag` / `OldSaveFlag` | Call sites outside the partial |
|---|---|---|---|
| `BaseArmor` | already in partial | in main file | 0 of 26 |
| `BaseClothing` | already in partial | in main file | 0 of 12 |
| `BaseWeapon` | in main file | in main file | — |
`BaseWeapon` had all three still inline, with its new `.Migrations.cs` holding only a `MigrateFrom`.
## After
All three follow the same layout: `MigrateFrom` newest to oldest, then the pre-codegen `Deserialize`, then `GetSaveFlag`, then `OldSaveFlag`. That moves ~290 lines of legacy read path out of `BaseWeapon.cs` — the file that needed it most at ~3,900 lines — and leaves the main class files describing only how the type behaves today.
## Reviewing this
The diff is large and almost entirely noise, so it is probably not worth reading line by line. Two checks are stronger:
- **Nothing was lost or altered.** Across each `.cs` / `.Migrations.cs` pair, the multiset of non-blank source lines is identical to `main` except for one added comment (below). The relocation was done mechanically and asserted against that invariant rather than by hand.
- **Nothing about serialization moved with the code.** Running `ModernUOSchemaGenerator` after the move emits no new migration files.
The complete set of intentional additions:
- `using System;` in each of the three partials, for the `[Flags]` attribute (implicit usings are not enabled here).
- `// Version 9 (pre-codegen)` above `BaseWeapon`'s moved `Deserialize`, matching the marker `BaseArmor` and `BaseClothing` already carry. Version 9 is correct because `BaseWeapon.v10.json` is its earliest migration schema, so codegen began at 10.
Everything else is blank-line placement.
## Verification
Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`).
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`).
## Problem
`SmallBOD.EndCombine` validates an item's **type**, **material** and **exceptional quality**, but never checks that the item was actually crafted by a player. Any item matching the request is accepted, including one bought straight from an NPC vendor.
https://github.com/modernuo/ModernUO/blob/main/Projects/UOContent/Engines/Bulk%20Orders/SmallBOD.cs#L117-L168
Where a vendor stocks a type a BOD can request, a player can fill the deed by buying the items instead of crafting them, and pocket the reward gold for the difference.
Tailoring is the clearest case. `SmallTailorBOD.CreateRandomFor` guarantees `Material = None` and `RequireExceptional = false` below 70.1 skill, so the rolled deed asks for plain cloth items — and tailor vendors stock several of those directly. A qty-20 Bandana BOD can be filled entirely from vendor stock for a small fraction of the reward gold, with no crafting and no material cost.
The same shape applies anywhere else a vendor-sold type overlaps a requestable BOD type; tailoring is simply where the low-skill deed generator and the vendor inventory overlap most.
## Fix
Add a `PlayerConstructed` check alongside the existing material and quality checks.
```csharp
var playerConstructed = armor?.PlayerConstructed ?? clothing?.PlayerConstructed ??
weapon?.PlayerConstructed ?? false;
if (!playerConstructed)
{
from.SendLocalizedMessage(1045169); // The item is not in the request.
}
```
This follows the pattern already used in `Engines/Craft/Core/Resmelt.cs` (L98-L100, L155-L160) to distinguish crafted from store-bought items, and reuses the same null-coalescing chain style as the adjacent `GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None)` line directly above it.
`PlayerConstructed` is already set in `OnCraft` and serialized on all three bases (`BaseArmor`, `BaseWeapon`, `BaseClothing`), so the flag survives restarts and no serialization change is needed.
## Open question — the message
There is no dedicated cliloc for "this item must be crafted", so I reused **1045169** (*"The item is not in the request."*). It is arguably accurate — a vendor-bought item genuinely is not what the deed asked for — but it is not precise, and a player who does not know the rule will find it confusing.
I would rather flag this than invent a string. If there is a better cliloc, I am happy to switch it.
## Testing
`dotnet build Projects/UOContent/UOContent.csproj` — **0 errors, 0 warnings**.
Not covered: I have not added an automated test, as I could not find existing coverage for `EndCombine` to extend. Happy to add one if you would like it, with a pointer to the preferred pattern.
## Compatibility note
Any *already-existing* vendor-bought item in a player's possession will now be rejected by a BOD. That is the intended behaviour, but it is a visible change for anyone mid-deed. Worth a line in release notes.
### Summary
Players reported an exploit: decipher a treasure map, then run a ClassicUO/Razor organizer agent that pulls the gold out of the chest in small amounts. Each pull spawned more monsters, turning one chest into an unbounded farmable spawn generator.
### Root cause
`TreasureMapChest.OnItemLifted` grants a 10% guardian spawn roll per first-time-lifted item, deduplicated by the instance-keyed `_lifted` set. But a partial lift goes through `Mobile.LiftItemDupe`, which re-adds the stack remainder to the chest as a **brand-new item instance** (engine-side `AddItem`, bypassing the `CheckHold` block on refilling). Every subsequent pull lifts an instance the `_lifted` set has never seen, so each one re-rolls the 10% spawn chance:
- A level 4 chest holds 4,000 gold → pulled coin by coin, ~400 spawned creatures (plus more from reagent stacks), hands-free, per chest.
- Spawns use `guardian: false`, so nothing tracks or caps them.
- Legit full-stack looting yields roughly 5–8 bonus spawns per chest for comparison.
The code is inherited from RunUO, so descendant shards likely share the hole.
### Fix
Mark every item that enters the chest **after the initial fill** as already lifted, via an `OnItemAdded` override gated by a non-serialized `_filled` flag (set at the end of the constructor and in `[AfterDeserialization]`). Ordering makes this exact: `LiftItemDupe` re-adds the remainder *before* the chest's `OnItemLifted` runs, so the lifted original still gets its one legitimate roll while the remainder is pre-marked.
This also covers packing items *into* the chest (e.g., merging gold back in to lift it out again) and bounce-backs — anything not part of the original loot can never grant a spawn roll.
### Tests
- `PartialLift_MarksSplitRemainderAsLifted` — drives the real `Mobile.Lift` path with a 1-coin pull and asserts the split remainder is marked (failed before the fix).
- `ItemAddedAfterFill_IsMarkedLifted` — post-fill additions are marked (failed before the fix).
- `OriginalFillLoot_IsNotMarkedLifted` — original loot keeps spawn-roll eligibility.
Full `UOContent.Tests` suite: 701 passed.
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.
## What
- Bind the login auth id to the account **and** origin address that earned it, make it a CSPRNG draw, expire it after two minutes, and spend it only once its owner presents it.
- Skip the password verify on `GameLogin` (0x91) when the presented id vouches for the submitted username and address.
## Why
A full client login hashes the password twice — `AccountLogin` (0x80) and then `GameLogin` (0x91). At the current Argon2 parameters that is **most of a 16 ms frame each, on the single-threaded game loop**, for every login attempt.
The second verify is redundant. `GameLogin` already requires an id from `_authIDWindow`, and that window is only populated by `GenerateAuthID`, called from `PlayServer` — reachable only after 0x80 has already authenticated the account **in this same process**. ModernUO Gateway has its own auth-id passing mechanism and is out of scope here.
## Why the id needed hardening first
Skipping the verify promotes the id from a correlation token to a bearer token, and it was not one:
- drawn from `Utility.Random` → `BuiltInRng`, a non-cryptographic PRNG
- bound to nothing — `AuthIDPersistence` carried only `Age` and `Version`
- never expiring; `Age` was only read to pick an eviction victim
A guessed id got you nothing while the password was still checked. Without that check it would have been an account takeover, so the id is now a CSPRNG draw, single-use, two-minute TTL, and bound to both the account and the origin address.
What remains is observing a live id on the client's network or machine — which the server cannot defend against under any design, and which already yields the password itself, since the client transmits it in the same handshake.
Network switching mid-login is deliberately unsupported.
## Behaviour
A full verify was always required before this change, and ids never expired, so every "before" is a password check.
| Case | Before | After |
|---|---|---|
| Id absent | Disconnect | Disconnect |
| Address mismatch | Verify | **Disconnect** |
| Account mismatch | Verify | **Disconnect** |
| Expired | Verify | **Verify** |
| Id vouches | Verify | **Skip** |
No case grants access the previous code would have denied. Expiry deliberately falls back to the verify rather than disconnecting — a player can idle, and turning that into a lockout would be a regression for no gain.
## Look, then take
An id is not consumed until the presenter has shown it is theirs. Removing it first would let anyone who lands on a live id burn it, and its owner would arrive to `"Unable to find auth id."` and have to log in again over a packet they had no part in.
The **address is compared before the account**, so a guesser from anywhere else is rejected before a username is ever looked at. That is what makes it safe to leave the id in place on a mismatch: there is no username-enumeration risk to trade against, and the only presenter who could enumerate is already on the victim's own address.
## The window is not a cap
It was 128 entries with the oldest evicted to make room. That is a cap on *concurrent logins*, not a resource bound: 800 people picking a server at once would have live ids discarded and those clients would arrive to `"Unable to find auth id."` — a failed login caused by nothing except other people logging in.
Issuing now sweeps expired entries and lets the window grow if everything in it is still live. Unbounded is safe here: an entry costs a **successful** password verify to create and dies after two minutes, so its size tracks logins genuinely in flight.
Removing an id when its connection drops is not an option, and this was checked rather than assumed — `NetState.cs:787` disconnects the login connection *deliberately*, immediately after the id is issued, and that disconnect is never cancelled. Surviving it is the whole purpose of the id. Expiry is the only correct reclamation.
## Handshake hardening
Choosing a server queues a disconnect, but the queue drains on the *next* slice, so a client pipelining into the same recv buffer can reach the handshake handlers again. Two had no do-once guard:
- `LoginServerSeed` (0xEF) now rejects when `state.Seeded` is already set.
- `PlayServer` (0xA0) now rejects when `state.AuthId != 0` — otherwise a connection that had already spent its id would be handed the spent one back.
Issuing is also idempotent (`EnsureAuthId`), so a connection holds exactly one id by construction and an orphan is impossible rather than something to clean up. The login state machine itself is untouched.
Also fixes a fall-through: the "Unable to find auth id" branch disconnected without returning, then continued with a default entry and nulled `state.Version`.
## Testing
`ConsumeAuthId` is a seam with no `NetState` dependency, so the auth decision is tested directly: vouching, account mismatch, address mismatch, case-insensitive usernames, IPv4-mapped-IPv6, unknown ids, single-use by the owner, **a rejected attempt leaving the id redeemable**, expiry-into-verify, and an 800-id login rush that must evict nobody. Expiry is driven by moving `Core._now`, not by waiting. Every new clause was verified to discriminate by removing it and confirming only its own tests fail.
## Cost
Halves the per-login game-loop cost. This does not make hashing cheaper or move it off the loop — that is gated on a measurement described in `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md`.
> ⚠️ **Rollback hazard — one-way door once logins are taken.** Serialization is unchanged, so a save
> written by this build still *loads* on the previous one. Its contents do not survive the trip: on
> its first successful login each account is rehashed to `$argon2id$`, and the previous build ships
> Argon2.Bindings 1.19.0, whose `Verify` is gated by the verifier's own configured type and answers
> `false` for an `$argon2id$` hash. **After a shard running this build has accepted logins, do not
> roll back past this commit** — every account that logged in is locked out on the older binary, and
> the only recovery is rolling forward again or resetting passwords by hand. Roll back only from a
> save taken before the first post-deploy login.
Requires [Argon2.Bindings 1.20.0](https://github.com/modernuo/Argon2.Bindings/pull/14), now published.
## What
- Consume `Argon2.Bindings` 1.20.0, which resolves the Argon2 type from the stored PHC string rather than from the verifier's own configuration.
- Default to **Argon2id, m=16384, t=1, p=1** — 8.51 ms against the old Argon2i 8 MiB t=3 at 10.11 ms. Cheaper *and* stronger.
- Rehash on a successful login whenever the stored parameters are stale, not only when the algorithm changes.
- Fix `SetPassword`, which derived the password phrase from the outgoing algorithm while storing it under the incoming one.
## Why
**Verification was gated by the verifier's configured type.** `Verify` passed the instance's own `ArgonType` to native `argon2_verify`, whose `decode_string` rejects a disagreeing `$argon2i$`/`$argon2id$` prefix and returns `DECODING_FAIL` — folded into `false`, the same answer as a wrong password. Switching the default type would have locked out every existing account, and `VerifyAndUpdate` could not have migrated them either: it delegates to the same type-fixed `Verify` and never compared `ArgonType`. Fixed upstream in 1.20.0. The pinned legacy-`$argon2i$` test here fails on 1.19.0 for exactly that reason, which is what makes the package bump load-bearing rather than incidental.
**Changing the defaults would otherwise have reached nobody.** Argon2's PHC string embeds `m`, `t` and `p`, so verification uses the parameters stored with each account, not the configured ones — and verification is the hot path. `CheckPassword` only rehashed when the *algorithm* changed, never when its cost parameters did, so on an established shard the new defaults would have applied to new accounts only. `IPasswordProtection.NeedsRehash` closes that: it defaults to `false`, so PBKDF2 and the `HashAlgorithm` protections are untouched — only Argon2 carries its cost inside the stored value.
**`SetPassword` picked the phrase rule from the wrong algorithm.** SHA1 and SHA2 salt the phrase with the username; Argon2 and PBKDF2 do not. It chose the rule from the *outgoing* algorithm while storing under the *incoming* one, so any algorithm change wrote a credential its own next verify could not reproduce. It now assigns `PasswordAlgorithm` first and derives the phrase from that. Note this ordering is load-bearing and invisible — `UpgradingAlgorithm_DoesNotLockTheAccountOut` is what pins it.
## Cost
Verification is re-derivation, so these are login numbers. A full login calls `CheckPassword` twice — `AccountLogin` (0x80) then `GameLogin` (0x91): **~20 ms before, ~17 ms after**, plus a one-time ~8.5 ms rehash on each account's migrating login.
That cost is still paid on the game loop. Moving hashing off-loop is deliberately **not** in this PR — it needs a pending-auth state in the login handlers, bounding of in-flight hashes, and login rate limiting.
## 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.
## Why
`IORingGroup` issues io_uring syscalls directly rather than linking `liburing`, so the package has
never been needed — but we ask operators to install it in the README, install it in CI, and check
for it in `build-tool`.
Verified against the **shipped** `IORingGroup` 1.0.9 assembly, not just the source:
| Symbol | Occurrences in `IORingGroup.dll` |
|---|---|
| `libc`, `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll`, `ws2_32.dll` | present |
| `liburing` | **0** |
| `io_uring_queue_init` — liburing's entry point | **0** |
| `io_uring_setup` — the raw syscall | 1 |
If it linked liburing it would call `io_uring_queue_init` / `io_uring_submit`. It calls neither.
## What changes
Nine lines across three files, removing `liburing-dev` / `liburing-devel` from:
- `README.md` — both the dnf and apt prerequisite blocks
- `.github/workflows/build-test.yml` — both install steps
- `Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs` — the cross-compile target text, the
apt and dnf package lists, and the `ldconfig` fallback map
Nothing else is touched. `zstd` and the `-dev` packages are a separate discussion and a separate PR.
## Risk
None to the build. `liburing` was only ever installed, never linked or loaded — removing it cannot
change resolution behaviour. `build-tool` builds clean.
This was found while investigating why Linux requires `-dev` packages at all; that fix lives in the
binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13) and lands separately
once those publish. This piece is independent and unblocked, hence its own PR.
## 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.