## 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
Fixes two related pet-behavior bugs and the underlying design flaw behind both:
1. **Post-combat erratic** — after a pet killed its `all kill` target it milled around erratically at the kill site (or failed to return to the master) until the player issued `all follow`/`all stop`.
2. **`all stop` returns home** — a pet with a non-zero `Home` walked back toward that location on `all stop` (on ML; on non-ML the old `DoOrderStop` was a no-op, so the sighting there came from a residual `Stay`).
### Root cause
`ControlOrder` and the wild-creature `Home` field were overloaded to express several distinct ideas, mutated/read inconsistently across order transitions:
- `Home` doubled as the controlled-pet "stay anchor" (`HandleStayOrder` set `Home = Location`) but nothing cleared it when the pet left the staying state; `HandleStopOrder` was the only handler that never touched it.
- `DoOrderStop` had dropped RunUO's `Home = Location` re-anchor, so on ML it walked to a stale anchor.
- The post-combat fallback was a fragile `_lastPetOrder` hack in `DoOrderNone` that re-anchored a resumed `Stay` at the corpse.
- Controlled idle-wander bypassed the `CheckIdle()` rest gate that every non-controlled creature uses, so idling pets jittered every AI tick.
## Approach
Separate three concepts that were tangled together:
- **`ControlOrder`** — the active order (may be transient: Come/Attack/Drop).
- **Persistent command** (`PersistentOrder` ∈ `{None, Stay, Follow, Guard}`) — the standing directive a pet falls back to when a transient order completes. Runtime-only (not serialized; reset to `None` on load) and **derived from master proximity on login** (near → Follow, far → Stay).
- **Anchor** (`Home`) — a pure function of the persistent command, set only when that command changes (never on transient transitions or fallback-resume), so it can't go stale.
### Behavior
- **Stop** is resolved immediately from what the pet was doing: Attack/Come → resume the persistent command; Follow/Guard → cancel to idle where it stands; Stay → stay put.
- **Stay** holds its post (returns only if displaced, e.g. after a fight) — no shuffle.
- **Idle** (`None`) is a gentle wander routed through `CheckMove/CanMoveNow/CheckIdle`, so idling pets take the same 15–25s rest periods as other creatures, on both ML and non-ML.
- **Post-combat** the pet resumes its persistent command (a staying pet returns to its original post, not the corpse).
- **Release** without a spawner anchors where the pet stands instead of pathing to a stale anchor.
This restores the RunUO-intended behavior (verified against the RunUO reference) while fixing the ModernUO regressions.
## Tests
New `PetOrderTests` (13 deterministic xUnit tests) cover: anchor lifecycle, the full Stop truth table, report 1 (post-combat return to post), report 2 (no stale-anchor walk-home), frozen-Stay/gated-idle wiring, release fix, derive-on-login, and a non-ML spot-check. The subjective wander *feel* is covered by a manual-QA checklist in the implementation plan.
## Notes
- Engine project (`Projects/Server`) untouched; the one `BaseCreature.cs` change is the `ControlOrder` setter passing the previous order to `OnCurrentOrderChanged`.
- `DoOrderCome` keeps auto-converting to `Stay` on arrival, which under the new model cleanly means "come and hold near me."
- Commits in this PR are temporarily **unsigned** (the signing agent's passphrase cache expired mid-session); happy to re-sign / amend on request.
## 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.
## Summary
Phase #2 of the `.swb` step-cache size-reduction roadmap (after #2465, v5 uniform elision). Stores the 16 base directional Z arrays as masked residuals against each cell's own SourceZ and omits any array that matches its prediction. Lossless, byte-identical reconstruction.
Trammel: 231.9 MB → 124.7 MB (−46%).
## Details
- Predictor: `predict = mask bit ? SourceZ : 0` (matches the baker's 0 on unwalkable directions); residual `Z − predict` via unchecked two's-complement (byte-exact for all inputs); reconstruct `Z = predict + residual`.
- A `u16 ZArrayMask` flags which of the 16 base arrays differ from prediction; matching arrays are omitted and synthesized from mask + SourceZ at read.
- Serializer-layer only: StepChunk, the cache, the algorithm, and the baker are unchanged.
- Format v6; v5 files rejected and re-baked once.
## Tests
21 v6 unit tests; full pathfinding suite green; Release build clean.
## Issue
Fixes#2452. A player with 30 Ninjitsu reported that the Animal Form menu showed **every** form; selecting one above their skill (e.g. Dog, req 40) **consumed mana** and returned "you need at least 40 skill", and afterwards the **gump never reopened** — every recast silently re-attempted the unusable form and drained more mana.
## Root cause
Three linked bugs, all reproduced from the code:
1. **Gump not gated by skill.** `AnimalFormGump.BuildLayout` compared `Skill.Fixed` (which is `Value * 10`, so 30 skill → `300`) against the raw 0–100 `ReqSkill` (Dog = `40`). `300 >= 40` is always true, so all forms were shown. `Morph` itself correctly uses `.Value`.
2. **Mana charged on a no-skill cast.** `Morph` returns `MorphResult.NoSkill` for an under-skilled form, but both call sites (`OnCast`, `OnResponse`) only special-cased `MorphResult.Fail`; `NoSkill` fell through to the branch that deducts mana.
3. **Menu never reopened.** Per OSI ([uo.com](https://uo.com/wiki/ultima-online-wiki/skills/ninjitsu/), [uoguide](https://www.uoguide.com/Animal_Form)), casting while **standing still always opens the selection menu**, and casting while **moving** quick-transforms into the last selected form. ModernUO only opened the menu when `lastAnimalForm == -1`, so once any form was selected a stationary recast skipped the menu.
## Fix
- Add `AnimalForm.CanSelectEntry` (compares `Skill.Value` to `ReqSkill`, plus the talisman check) and use it for the gump's per-entry enable check.
- `OnCast`: standing still always opens the menu; moving quick-transforms into the last form. `NoSkill` no longer costs mana.
- `OnResponse`: handle `Success` / `Fail` / `NoSkill` explicitly so `NoSkill` costs no mana.
## Tests
Adds `AnimalFormTests`:
- `CanSelectEntry` rejects forms above skill, accepts forms at/below skill, and requires a talisman for talisman-gated forms.
- `Morph` returns `NoSkill` (without transforming) when under-skilled, and `Success` when sufficiently skilled.
Verified the gating test catches the regression (reintroducing `.Fixed` fails it). Full solution build is clean; the 5 new tests plus 284 other UOContent tests pass (the pathfinding/AI sequential tests were excluded only because they deadlock under concurrent local runs — they are unrelated to this change).
## Summary
Sub-project #1 of the `.swb` step-cache size-reduction roadmap (`dev-docs/pathfinding.md` § Future work). Adds **uniform-chunk elision** to the `StepCacheFile` format, bumping it **v4 → v5**.
A fully-uniform 16×16 chunk — no strata, **no swim layer**, all 19 base arrays constant (open ocean, Green Acres, void) — serializes to a **~28-byte record** (`KindUniform`) instead of ~5,393, and reconstructs **byte-identically** via `Array.Fill`. Non-uniform chunks use the existing v4 body (`KindFull`) with the swim-layer and strata trailers **fully preserved** — the Kind byte is just prepended.
## Calibrated result (measured, not projected)
Baked Trammel via `SaveToFile`:
| | |
|---|---:|
| Chunks | 114,688 |
| Uniform (swim-aware) → elided | 62.7% |
| Swim-layer chunks (stay Full) | 8.9% |
| Strata chunks (stay Full) | 1.8% |
| Baseline (full records) | 592.2 MB |
| **Actual v5 `.swb`** | **231.9 MB (−61%)** |
The residual is ~150 MB of non-uniform land Z-blocks (targeted by #2 predictive-Z) + ~81 MB of swim-layer trailers (#2/#3). #2 and #3 are separate follow-up PRs.
## Implementation
- `StepChunk.IsUniform()` — false if it has strata **or a swim layer**, else true only when all 19 base arrays are constant (the "all-same" check uses the SIMD-accelerated `ContainsAnyExcept`).
- `StepCacheFile` v5 — `Kind` byte (`KindFull=0`/`KindUniform=2`, 1 reserved); uniform write/read; `FormatVersion`/`MinSupportedVersion` → 5 (v4 files rejected on open and re-baked). No `StepCache`/algorithm/index changes; fingerprint logic untouched.
## Tests
7 `StepCacheFileV5Tests` (uniform round-trip + `<200 B` compactness, varied-full, swim-layer-full, strata-full, swim+strata combined, v4 version-gate rejection) + the existing StepCache/pathfinding suite — **70 pass**, including the prior `SwimLayer_RoundTrips`. An independent review verified write/read symmetry, cast round-tripping, swim/strata preservation, and the version gate (READY TO MERGE).
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
Closes the Cold-cache regression flagged in PR #2450. `StepCache.TryGetMask` no longer eagerly runs `BuildChunk` on the first miss for a chunk that isn't in a `.swb` lazy reader. Instead it returns `Fallthrough_NotBuilt` and the caller (`BitmapAStarAlgorithm`) takes the per-cell slow path. The chunk is only promoted to the bitmap fast path after the **second** miss within a 30-second window, filtering single-touch pass-throughs.
This makes BitmapAStar's worst-case (cold cache + short hops) collapse from **12–47× slower** than FastAStar to **roughly the same**, which is the floor the slow path can deliver. Steady-state warm performance (the actual deliverable) is unchanged from PR-5 — it was always the cache fast path.
## The pet-follow scenario this fixes
A mounted player at ~4 tiles/sec with a pet/hireable following will trigger an NPC pathfind every 100–300 ms. Each pathfind is 1–6 tiles. As the player crosses chunk boundaries (~4 sec/chunk), the pet's first pathfind in the new chunk under the previous behavior triggered a full ~700 µs `BuildChunk` for a chunk the player would leave shortly after. At 50–100 mobiles per shard, this exceeded the 8 ms tick budget. PR-5 BDN data showed scenarios 6–9 (2–8 tile NPC perception) at 2,300–3,700 µs Cold vs FastAStar's 80–200 µs.
Under the new gate:
- First miss → `Fallthrough_NotBuilt` → caller uses slow path (~30–50 µs short path). No `BuildChunk`. No allocation.
- Player keeps moving → chunk never gets a second touch within window → never promoted, no rot.
- NPC patrolling a fixed territory → repeatedly hits the same chunks → second touch within window → promote → cache fast path on subsequent calls.
## What changed
- **`CacheHitKind.Fallthrough_NotBuilt = 6`** + **`CacheStats.FallthroughNotBuilt`** counter. `IsHit=false`, so the caller routes to slow path.
- **`StepCache._chunkMissTracker`** — `Dictionary<long, ChunkMissState>` capped at 4096 entries. State is `(byte missCount, uint lastMissTickStamp)` keyed by chunk key. Window-expired entries reset count to 1; capacity overflow prunes window-old entries first.
- **`StepCache.MissPromotionThreshold`** (default `2`) and **`StepCache.MissPromotionWindowMs`** (default `30_000`) — tunable, can be wired through `ServerConfiguration` if shards want different policy. Setting threshold to `1` restores legacy eager-build behavior (used by tests that prime chunks via single `TryGetMask` call).
- **`StepCache.TryGetMask` miss branch** — try lazy reader first (file-loaded chunks bypass the tracker entirely; an `.swb` represents an explicit prior decision to keep the chunk warm). Otherwise consult the tracker.
- **`BitmapAStarAlgorithm.GetSuccessorsSlowPath`** now layers `IsBlockedByDynamic` on top of `CalcMoves.CheckMovement`. Previously the slow path only ran for `CanFly` creatures and rare cache fallthroughs — `CheckMovement` doesn't iterate same-cell mobiles, so the bitmap fast path's `IsBlockedByDynamic` was the only mobile-blocking check. Now first-touch pathfinds run through the slow path, so the gap had to close.
## Tests
50 pathfinding tests pass (was 47). New / updated:
- **`TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough`** — single TryGetMask call returns `Fallthrough_NotBuilt`, no chunk built, no allocation.
- **`TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds`** — second call inside the 30s window builds + serves.
- **`TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers`** — second call outside the window restarts the count, returns Fallthrough again.
- **`TryGetMask_DistinctChunks_TrackedIndependently`** — counters are per-chunk; one touch on each of two adjacent chunks both stay in fallthrough.
- **`LazyReaderHit_BypassesMissTrackerOnFirstTouch`** — open `.swb` + first touch hits without consulting the tracker. Production with `.swb` loaded skips the gate entirely.
- **`MultisVersion_Bump_TriggersDirtyRebuild`** — updated to reflect the new 3-step flow (Fallthrough → Miss_NotBuilt → Miss_DirtyRebuild).
- Tests that prime chunks via a single `TryGetMask` call (multi-Z, Tier4, lifecycle, parity, BitmapAStar uses-cache) set `MissPromotionThreshold = 1` to opt into eager behavior.
## Expected BDN impact
The Cold column from PR-5's BDN should change as follows once the bench's submodule pointer is updated to this branch:
| # | Scenario | Cold (PR-5) | Cold (PR-6 expected) | FastAStar Cold |
|--:|-----------------|-------------:|---------------------:|---------------:|
| 2 | sewer corridor | 1,627 µs | ~36 µs | 36 µs |
| 4 | causeway | 1,533 µs | ~39 µs | 39 µs |
| 6 | pet 2-tile | 2,364 µs | ~80 µs | 81 µs |
| 8 | npc 5-tile | 3,708 µs | ~140 µs | 141 µs |
| 9 | npc 8-tile | 2,386 µs | ~200 µs | 197 µs |
WarmNoFile and LazyWarm rows should be unchanged — they were always cache-warm. The miss tracker only fires when neither resident chunks nor the lazy reader can satisfy the request.
## Future work (not in this PR)
- **Background-thread bake**: builds outside the game thread so even promoted chunks don't pay the 700 µs build cost on the main thread. Rule 10 (no Task.Run) applies, so this needs careful design — the bake is a pure data transform but main-thread synchronization on chunk-state transitions has to be threaded through. Defer to a follow-up.
- **Long-traverse BDN scenario**: a multi-Find benchmark simulating 50 pet repaths across chunk transitions. Requires restructuring the bench harness; the existing 10-scenario corpus + Cold provider already exercises the gate.
- **Swim sourceZ bake**: scenario 5 (sea serpent) shows 56 B alloc on warm paths because the cache's SourceZ is computed under default-walker rules. Swim creatures fall through to slow path. Independent of this PR.
## Summary
Creatures (pets following, monsters chasing, NPCs approaching) would **oscillate — "pace back and forth really fast"** at concave obstacles (reported at the Britain Inn L-desk: a pet at `(1493,1614,20)` never reaching its master at `(1494,1605,21)`) instead of routing around them.
**Root cause** (the A* pathfinder itself was correct): all goal-seeking funnels through `MoveTo` and `WalkMobileRange` → `MoveTowardsOrAwayFrom`, which step greedily via `DoMove(dir, badStateOk:true)`. `DoMove` returns `true` even when the direct step was blocked and the creature merely **auto-turned and sidestepped** (`MoveResult.SuccessAutoTurn`), and the caller then set `Path = null`, discarding the `PathFollower`. So at a concave obstacle a non-progressing sidestep was mistaken for progress and the creature never committed to a route. (AOS pet-follow runs at `CurrentSpeed = 0.1`, hence the "really fast" shuffle.)
## What changed
- **New centralized `BaseAI.ApproachTarget(target, run, range)` primitive.** A greedy step is committed only when it **fully succeeds (`MoveResult.Success`) and actually gets closer**; otherwise the creature commits to a **persistent `PathFollower`** that routes around the obstacle and is never discarded by a greedy step. The open-terrain fast path (one greedy step, no pathfinding) is preserved. `MoveTo`, `MoveTowardsOrAwayFrom`, and `MoveToWithCollisionAvoidance` all delegate to it — public signatures unchanged, so no AI-class call site changes.
- **Best-distance give-up + idle.** A creature that cannot reach a **stationary** in-range goal stops shuffling and idles after `ApproachGiveUpTicks` (40) ticks without lowering its closest-ever distance; a **moving** goal (active chase) never gives up. It resumes the moment the goal moves.
- **Pathfinder fix (required):** `BitmapAStarAlgorithm.IsBlockedByDynamic` now skips the dynamic mobile-block check **at the goal cell only** (`MoveImpl.Goal`). Previously A* returned `null` whenever the target mobile stood on the goal cell, so creatures could never pathfind *toward* another mobile — only toward empty ground. The follower stops within `range` short of it. Static/item blocking and all non-goal mobile blocking are unchanged.
## Tests
New AI-loop integration tests in `ApproachTargetTests.cs` drive the real `BaseAI` primitives against live Britain Inn map statics: exact-repro pet follow, open-terrain (asserts zero pathfinding), `MoveTo` chase (static + walking-away target), route-around-a-dynamic-wall, and walled-off give-up-and-idle.
- Pathfinding + AI subset: **52/52** pass.
- Full `UOContent.Tests`: **301/301** pass. (Note: the test host lingers on shutdown — a pre-existing infra quirk unrelated to this change; all tests complete and pass.)
- Full solution build: clean (0 warnings / 0 errors).
## Notes
- Branched off `main`; independent of the in-flight step-cache work.
- Out of scope (future work): proactive "SmartAI" look-ahead pathfinding so clever creatures plan a route before walking into the obstacle, rather than reacting after they hit it.
## Test Plan
- [X] In-game: order a pet to `follow`/`come` across the Britain Inn L-desk; confirm it routes around and reaches you instead of pacing.
- [X] Aggro a monster and kite it around a building/treeline; confirm it chases around obstacles.
- [X] Confirm open-terrain following/chasing feels unchanged (no extra latency).
- [X] Confirm a creature with a genuinely unreachable target idles rather than shuffling forever.
## Summary
Multi-Z cells (bridges, stairs, paver-over-ground, multi-floor structures) now carry **per-stratum walkability data** in the cache instead of falling through to the slow path. The data is computed at chunk-build time, persisted in the `.swb` file, and selected at query time by matching the request's `sourceZ` against each stratum's `zCenter` (within `StepHeight` tolerance).
This is the Tier 4 strata feature, deferred from PR #2447 / PR #2448 / PR #2449. Builds on PR #2449's lazy backing store and public bake helpers.
## Wire format change (v1 → v2)
`StepCacheFile.FormatVersion = 2`. `MinSupportedVersion = 2`. v1 `.swb` files are silently rejected at open time (treated as missing) and overwritten on the next `SaveToFile` / `BakeMap`. **No migration** — older files just get re-baked.
The `MinSupportedVersion` sentinel is the model going forward: bump the constant when an incompatible change lands; admins re-bake on the next deploy. No matrix of v1↔v2↔v3 migration logic to maintain.
## What changed
- **`StepProbe.ComputeStrataAt(map, x, y)`** — enumerates walkable standing-Zs at the cell (one per land surface plus one per walkable static), collapses Zs within `2*StepHeight`, runs `ComputeMaskAt` at each surviving Z. Returns `null` for single-Z cells (caller uses the chunk's main mask).
- **`StepChunk`** — replaces the old `MultiZCells` bitmap with a **strata storage pair**:
- `ushort[256] StrataOffsetByCell` (sentinel `NoStrata = 0xFFFF` = "no strata for that cell")
- `byte[] StrataData` packed: `u8 stratumCount`, then `count × 19-byte stratum`
- `sbyte zCenter, byte walkMask, byte wetMask, sbyte walkZ_N..NW (8), sbyte swimZ_N..NW (8)`
- `IsCellMultiZ` derives from `StrataOffsetByCell[cell] != NoStrata` — same semantics, single source of truth.
- **`StepCache.BuildChunk`** — populates strata for cells flagged multi-Z via `SetStrata`. Chunks with zero multi-Z cells pay zero strata overhead (offset array + data array stay null).
- **`StepCache.TryGetMask`** — for multi-Z cells, scans strata with `TryStratumHit`; returns the matching one with `HitKind=Hit`. Falls through to slow path only when no stratum matches the query `sourceZ`.
- **`StepCacheFile`** — v2 serialization with strata trailer per chunk + `recordLength` in index entry. Lazy reader sizes scratch per-chunk-record using the recorded length, growing on demand for multi-Z-heavy chunks. Patches `IndexOffset` on `w.Buffer` (BufferWriter's current backing array) since it grows during variable-size chunk writes.
## File layout v2
```
Header (48 bytes):
u32 Magic = 0x42575300 ('SWB\0')
u32 Version = 2
u32 MapId
u64 Fingerprint XxHash3 over LandTable + ItemTable flags + map files (mapX.mul/.uop, staidxX.mul, staticsX.mul)
u64 BakeTimestamp informational
u32 ChunkCount
u64 IndexOffset position where chunk index begins
Per chunk (variable size):
u16 ChunkX, ChunkY
u32 BuiltMultisVersion
u8 HasStrata 0 = no strata trailer; 1 = strata trailer follows
byte WalkMask[256], WetMask[256]
sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256]
// Strata trailer (only when HasStrata == 1):
u16 StrataOffsetByCell[256] // NoStrata sentinel = 0xFFFF
u32 StrataDataLength
byte StrataData[StrataDataLength]
Per multi-Z cell: u8 count, then count × Stratum (19 bytes)
Index trailer (20 × ChunkCount bytes):
per chunk: { u64 chunkKey, u64 fileOffset, u32 recordLength }
```
## 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
Builds on PR #2446's cache-direct A*. The previous PR conservatively routed players + creatures with capability flags entirely through the slow path. This PR pushes that line: most mobile classes now use the cache, with the right rule set layered on top per-mobile, and the cache fast-path now does the dynamic items / mobiles check that PR #2446 had silently skipped.
## What changed
- **Non-GM players** now use the cache. Diagonal corner-cut applies the strict AND-rule (BOTH cardinal partners walkable) by reading the same source-cell mask byte the creature OR-rule reads — both rules are evaluable from one byte.
- **Creatures with `CanOpenDoors` / `CanMoveOverObstacles`** now use the cache. Reading `MovementImpl` confirmed those flags only affect dynamic items, never static tiles, so they were over-conservatively excluded before.
- **Swim creatures** now use the cache via a capability overlay. `StepProbe` bakes a second rule set (`canSwim=true, cantWalk=true`) producing `WetMask` + `SwimZ_*`. The algorithm composes `effectiveMask = (walkMask & !cantWalk) | (wetMask & canSwim)` per direction; walk Z preferred when both apply.
- **Dynamic-obstacle pass.** Cache fast-path now mirrors `MovementImpl`'s per-cell items + mobiles collision check (`GetItemsAt` / `GetMobilesAt` at the target cell, with `CanOpenDoors` / `CanMoveOverObstacles` / spell-field overrides). This closes a correctness gap from PR #2446 — the cache fast-path was silently skipping dynamic obstacles entirely.
- **`StepCache.TryGetMask` returns `StepMask` struct** instead of 11 out parameters. `HitKind` rolls into the struct with an `IsHit` accessor. Sets up wet/swim without ballooning the call site.
- **`StepChunk.MultiZCells` is lazy-init.** Most chunks are entirely single-Z; allocating the 32-byte bitmap up-front wasted ~256KB at full cap.
- **Admin commands.** `[PathCacheStats` (resident chunks + hit/miss/eviction counters) and `[PathCacheClear` (drop everything, zero counters).
- **Feature flag.** `bitmap_pathfinding_cache` (default true) gates the cache fast-path. Flipped off, every cell expansion routes to `MovementImpl` — equivalent to PR #2446's slow-path-only behavior. Safety net for shipping the new behavior.
`RequiresSlowPath` shrinks to just `CanFly` — flying creatures Z-jump arbitrarily, which the cache's static-Z model can't accommodate.
## 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.
Adds an opt-in 'youngPlayerSystem.enabled' server setting (default true) that, when set to false, disables the Young player system server-wide:
- Account.Young and PlayerMobile.Young getters short-circuit to false.
- New characters no longer receive Young status or a NewPlayerTicket.
- All downstream Young checks (notoriety beneficial-action restriction, CheckYoungProtection, stealing penalties, YoungDeathTeleport, death-item movement, poison immunity, '(Young)' name suffix, CanLogout, renounce-young keyword/gump, BaseCreature.OnDeath fame penalty, OnLogin time-remaining message) become inert.
Setters are intentionally left untouched so serialized account/player flags round-trip cleanly when the setting is later re-enabled.
## Summary
Phase 3.3 of the message-interpolation cleanup. Eliminates the `rank.ToString().ToLower()` two-allocation pattern in ConPVP trophy-award messages.
- Adds `TrophyRank.LowerName()` extension returning a static lowercase string per enum value via switch expression.
- Updates 10 call sites across Tournament, KingOfTheHill, DoubleDom, CTF, BombingRun (2 each - cash and no-cash branches).
The handler now appends a static interned string directly into the packet buffer; no `ToString()` formatter and no `ToLower()` allocation per call. Source comment in BombingRun.cs ("There is no formatting flag for Lowercase, we may need a custom interface to get rid of it") is now resolved at the call-site level.
## 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
- Add InteractiveTeleporter that teleports on double-click
- Add support to decorate command
- Add rope teleporters to the New Haven mines in the decoration file
## Summary
Converts `HouseRaffleManagementGump` from legacy `Gump` to `DynamicGump` with the static `DisplayTo` entry-point pattern.
The gump has paginated entries (up to 10 rows per page), conditional prev/next navigation buttons (vs. inactive image when at page boundary), and per-entry conditional layout (account-bearing vs. raw name). DynamicGump is the right choice.
Constructor is private; `DisplayTo` validates `from` / `NetState` / `stone.Deleted` before constructing. The list+sort runs eagerly in `DisplayTo` so paging math stays consistent across rebuilds.
Builder labels use `$"{value}"` interpolated-string-handler form for zero-allocation text.
Updates the caller in `HouseRaffleStone.ManagementEntry.OnClick`.
## Summary
Converts `RewardGump` and the inner `RewardConfirmGump` from legacy `Gump` to `DynamicGump` with the static `DisplayTo` entry-point pattern.
Both gumps have variable per-instance layout — the reward grid loops over `_rewards` and adds `AddItem(itemID, hue)` / `AddTooltip(tooltipID)` calls whose values are baked into the layout buffer per entry, so a cached static layout would be incorrect. DynamicGump is the right choice and avoids the placeholder dance for non-text values.
Constructors are private; `DisplayTo` validates `NetState`, null `rewards`/`onPicked`, and empty arrays before constructing.
Builder labels use `$"{value}"` interpolated-string-handler form for zero-allocation text.
## Summary
- Converts `HouseGumpAOS` from legacy `Gump` to `DynamicGump` with a private constructor and the static `DisplayTo` entry-point pattern.
- `AddPageButton`, `AddButtonLabeled`, and `AddList` helpers now write directly to the `DynamicGumpBuilder` via `ref` parameters.
- All internal re-display calls and the `HouseSign` / `ConfirmResizeHouseGump` callers route through `HouseGumpAOS.DisplayTo`.
- Field naming updated to underscore-prefix convention (`_house`, `_page`, `_from`, `_list`, `_hangerNumbers`, `_foundationNumbers`, `_postNumbers`, `_houseSigns`).
## Summary
- Converts the legacy pre-AOS `HouseGump` to `DynamicGump` with a private constructor and the static `DisplayTo` entry-point pattern.
- `HouseListGump` and `HouseRemoveGump` now route back through `HouseGump.DisplayTo` instead of constructing the gump directly.
- Updates the `HouseSign` caller accordingly.
Splits BarkeeperGump (DynamicGump) into two StaticGump<T> variants selected by body type — Human (modifiable appearance) and NonHuman (no appearance/gender controls). Each variant gets its own cached static layout via a CRTP base; dynamic per-instance text (rumor messages, keywords, tip message) is filled via slot placeholders in BuildStrings.
Moves PlayerBarkeeper, BarkeeperGump, and BarkeeperTitleGump into a dedicated Mobiles/Vendors/Barkeeper/ folder.
Pulls the Back button on the appearance-categories page out of the ModifyAppearance branch so non-human barkeepers no longer hit a dead end on that page.
BaseCreatures are deleted on death (Mobile.OnDeath calls Delete for non-players), so after save/restart the corpse's _owner reference resolves to null. CorpseNotoriety gated its entire creature branch on `target.Owner is BaseCreature`, falling through to player-corpse logic once the reference vanished. That made monster corpses turn red (body.IsMonster -> Murderer) and innocent NPC corpses turn grey (null is not PlayerMobile -> CanBeAttacked) on the next restart.
Snapshots the relevant owner state into CorpseFlag at corpse creation: OwnerWasBaseCreature, OwnerWasSummoned, OwnerWasAnimatedDead. Folds the standalone _murderer bool into CorpseFlag.Murderer for consistency with Criminal. CorpseNotoriety now consults the flags so the creature branch stays correct without a live mobile reference.
Bumps Corpse serialization to v16 with a MigrateFrom(V15Content) that maps the old Murderer bool onto the new flag. Pre-fix corpses already on disk decay within 7 minutes; their first post-restart color may be wrong, which is acceptable.
Also documents that the schema generator must be run after every version bump (`dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx`) since `dotnet build` does not emit migration JSON files.
## Summary
Migrates 14 ConPVP lobby/tournament gumps from legacy `Gump` to modern `DynamicGump`/`StaticGump<T>`. Layouts move into `BuildLayout(ref DynamicGumpBuilder)`, constructors become private, and validation moves into static `DisplayTo` entry points (empty-gump rule).
**Per-gump base type decisions:**
- `BeginGump` → `StaticGump<BeginGump>`: layout is fully fixed (no dynamic content). All other gumps below are `DynamicGump` because they bake dynamic player names, guild abbreviations, ruleset titles, arena names, tournament participant names, ladder rankings, or per-instance rule modifications. Per the cliloc/dynamic-text rule, dynamic content forces `DynamicGump`.
- `ReadyGump`, `ReadyUpGump` → `DynamicGump` (per-instance participant rosters).
- `AcceptDuelGump`, `AcceptTeamGump`, `ConfirmSignupGump` → `DynamicGump` (challenger/registrar/team names, dynamic rule modifications).
- `PickRulesetGump`, `RulesetGump` → `DynamicGump` (ruleset titles and option labels per instance).
- `ParticipantGump`, `DuelContextGump` → `DynamicGump` (player rosters/team labels).
- `LadderGump` → `DynamicGump` (ladder entries: ranks, levels, guild abbrs, names, wins/losses).
- `ArenaGump` → `DynamicGump` (arena names with active player names).
- `PreferencesGump` → `DynamicGump` (arena name list).
- `TournamentBracketGump` (~1k LOC) → `DynamicGump`. The whole gump is one type-switched view that re-renders on every button press across `Index`, `Rules_Info`, `Participant_List`, `Participant_Info`, `Round_List`, `Round_Info`, `Match_Info`, `Player_Info`. All branches bake per-instance content.
**Refresh-via-this conversions (the big perf wins):**
- `LadderGump`: page +/- now mutates `_page` and calls `from.SendGump(this)` instead of allocating a new `LadderGump`.
- `PickRulesetGump`: ruleset apply / flavor toggle now refreshes via `this`.
- `ParticipantGump`: increase/decrease team size, remove player, target failure all refresh via `this`.
- `DuelContextGump`: failed-start and add-participant refresh via `this`.
- `ConfirmSignupGump`: every signup-validation rejection branch in `OnResponse` and every `AddPlayer_OnTarget` rejection branch refreshes via `this` (was allocating a new gump per branch).
- `TournamentBracketGump`: every navigation button (back/forward, type change, page change, drill-down) mutates `_type`/`_object`/`_list`/`_page` and refreshes via `this`. Previously each click allocated a new 1k LOC gump.
All gumps are `Singleton`, use private constructors with static `DisplayTo` entry points that null-check `NetState` before allocation. External callers in `DuelContext`, `TournamentBracketItem`, `TournamentController`, `TournamentSignupItem`, and the cross-references between `AcceptDuelGump`/`ParticipantGump`/`AcceptTeamGump`/`ConfirmSignupGump` are all updated to use `DisplayTo`. Legacy `m_X` fields renamed to `_x` per coding standards.
## Summary
Migrates the eight concrete New Guild System gumps (CreateGuild, GuildInfo,
GuildMemberInfo, GuildRoster, GuildDiplomacy, WarDeclaration,
GuildAdvancedSearch, GuildInvitationRequest) and three abstract bases
(BaseGuildGump, BaseGuildListGump, OtherGuildInfo) from the legacy `Gump`
class to `DynamicGump`. Layout work moves from constructor-side `AddX(...)`
calls into `BuildLayout(ref DynamicGumpBuilder builder)` — the abstract
`BaseGuildGump` now provides a `BuildContent` callout for shared
tab-strip chrome, and `BaseGuildListGump<T>` adds another
`BuildListExtras` hook so subclasses can paint highlighted titles after
the filter/sort/pagination chrome.
The headline win is the **self-refresh pattern** on the list gumps and
diplomacy advanced search. Previously each filter/sort/back/forward
click allocated a brand new gump via `GetResentGump`. After migration,
those handlers mutate `_filter`, `_startNumber`, `_comparer`, `_ascending`,
or `_display` on the existing gump and call `from.SendGump(this)`,
letting the singleton path in `NetStateGumps.Send` swap in the same
instance with the new layout. The original list is preserved separately
from the per-render filtered/sorted `_displayList`, so refreshes pick up
the latest state without losing the source list.
All guild gumps deal with per-instance dynamic strings (guild names,
member names, war declarations, alliance names), which would defeat
`StaticGump<T>` caching per the cliloc rule, so every concrete subclass
migrates to `DynamicGump`. `AllianceRosterGump` (in `Misc/Guild.cs`)
is a `GuildDiplomacyGump` subclass and inherits the new behavior; its
unused override and stored alliance reference were dropped along with
the now-obsolete `GetResentGump` abstract.
## 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
Migrates the four ConPVP game board (scoreboard) gumps from legacy `Gump` to `DynamicGump`:
- **`BRBoardGump`** (Bombing Run) — variable layout: row-per-team based on `Participants.Count`. Migrated to `DynamicGump`, `Singleton`, private constructor + `DisplayTo`, `SetNoClose()`.
- **`CTFBoardGump`** (Capture the Flag) — variable layout: row-per-team filtered to only teams with a flag. Same migration shape.
- **`DDBoardGump`** (Double Domination) — variable layout: row-per-team. Same migration shape.
- **`KHBoardGump`** (King of the Hill) — variable layout: row-per-team. `sealed`. Same migration shape.
### Refresh-via-this decision
For all four boards, **score data lives on the `*Game` / `*TeamInfo` objects, not the gump**. The gump just renders a snapshot of those values at the moment it is sent. The three call sites per board are:
1. `OnDoubleClick` on the in-world scoreboard item — one-shot manual open.
2. After death/kill score events (in `OnDeath`) — game logic pushes a fresh board to the dying player so they see updated scores.
3. End-of-game broadcast loop — sends final results to every participant.
None of these are button-driven refreshes from inside the gump, and the gump owns no mutable state. Therefore each event allocates a fresh gump (now via `DisplayTo(...)`) rather than calling `SendGump(this)` on a long-lived instance — that pattern doesn't fit when the data source is external. The win comes from `DynamicGump`'s ref-struct builder writing directly to buffers, eliminating the legacy `GumpEntry` list allocations on every send.
### Mechanics
- `: Gump` → `: DynamicGump`; layout moved from constructor to `BuildLayout(ref DynamicGumpBuilder)`.
- `Closable = false` → `builder.SetNoClose()`.
- Constructors are `private`; static `DisplayTo(Mobile, *Game, ...)` validates `mob?.NetState != null && game != null` before constructing.
- Team-section-mode parameter (`section`) preserved on BR / CTF / DD as an optional `DisplayTo` param even though no current caller uses it.
- The four `m_Game` / similar fields are renamed to `_game`; new fields use `_camelCase` per CLAUDE.md §12.
- `AddBorderedText` / `AddColoredText` helpers became `static` and take `ref DynamicGumpBuilder`.
- Updated all 12 internal call sites (3 per file) to go through `DisplayTo`. No external callers.
- No `OnResponse` was defined on any of these gumps (the only button is a close button), so no `RelayInfo` signature changes were needed.
Touches 4 game files, but only the gump classes — game logic (BR death/scoring, CTF flag handling, DD domination, KH king timer) is untouched.
## Summary
Migrates three legacy `Gump`-based dialogs to the modern builder API:
- **PlayerBBGump** (`Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs`) — `DynamicGump`. The bulletin board renders a different post per page (variable per-instance state), so the layout cannot be cached. Now `Singleton`, with a private constructor and a static `DisplayTo` entry point. Scroll/banish/delete/post-props buttons mutate `_page` and self-refresh via `SendGump(this)` instead of allocating a fresh gump on every click. Prompt-driven flows (post message / set title / post greeting) re-enter through `DisplayTo` after the prompt completes.
- **MessageGump** + **OldMessageGump** (`Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs`) — `StaticGump<T>`. Both render a fixed structure (background + body + button) where only the formatted sextant coordinate string varies per SOS bottle. The cliloc IDs that *appear in the gump packet* are constant (`MessageGump` uses 1018326; `OldMessageGump` uses no `AddHtmlLocalized` at all — its message is pre-formatted into a string before construction), so the layout cache is safe per the cliloc rule. The varying coordinate text is fed through an HTML placeholder via `BuildStrings`. Both gumps are now `Singleton` with private constructors and static `DisplayTo` entry points.
- **ShardPollGump** (`Projects/UOContent/Misc/ShardPoller.cs`) — `DynamicGump`. The gump's structure changes both with the number of poll options (variable loop) and with the `editing` flag (admin sees radios + add-option row + result percentages; players see only radios). The dual-purpose view is preserved as a single `DynamicGump` with the `editing` flag still controlling layout shape — staff path verified to still render the editor with the totals header, vote percentages, and "Create new option" radio. `Closable = false` becomes `builder.SetNoClose()`. Now `Singleton`, with private constructor and a `DisplayTo` that returns the gump instance so `EventSink_Login_Callback` can still call `QueuePoll` on it. The cancel/edit re-issue paths use `SendGump(this)` for self-refresh; the queued login flow uses `DisplayTo` so each queued poll gets its own gump.
External callers (`OnDoubleClick`, `PostPrompt`, `SetTitlePrompt`, `ShardPollPrompt`, `EventSink_Login_Callback`, the Timer-delayed queued poll send) all updated to use the new `DisplayTo` entry points. Legacy `m_X` field naming was already absent in two of the three files; the bulletin board fields kept their `_camelCase` names. `dotnet build Projects/UOContent/UOContent.csproj` reports 0 warnings, 0 errors.
## Summary
Migrates the five-step SoulStone wizard and the TreasureMapChest remove-confirmation dialog from legacy `Gump` to the modern builder API.
Per-gump base type:
- **`SelectSkillGump` -> `DynamicGump`** -- the skill picker iterates the player's skill list and emits one button per non-zero skill, so the layout shape varies per instance.
- **`ConfirmSkillGump` -> `DynamicGump`** -- skill name uses `AosSkillBonuses.GetLabel(...)` which returns dynamic clilocs in the `1044060 + (int)skill` range, plus current/cap skill values rendered as text labels.
- **`ConfirmTransferGump` -> `DynamicGump`** -- same dynamic skill cliloc plus per-instance Base/Cap/Stored values.
- **`ConfirmRemovalGump` -> `StaticGump<ConfirmRemovalGump>`** -- only fixed clilocs (warning text, Continue, Cancel), so the layout caches.
- **`ErrorGump` -> `DynamicGump`** -- title and message clilocs are constructor parameters that vary per call site.
- **`TreasureMapChest.RemoveGump` -> `StaticGump<RemoveGump>`** -- fixed-cliloc confirmation prompt (no item list, despite the name); `Closable=false`/`Disposable=false` are now `builder.SetNoClose()`/`builder.SetNoDispose()`.
All six gumps are now `Singleton => true`, have private constructors, and expose a static `DisplayTo` entry point that validates `from`, `NetState`, and the underlying entity before constructing -- prevents the empty-gump leak. Wizard navigation between steps now goes through `DisplayTo` (e.g. `ConfirmSkillGump.DisplayTo(from, _stone, skill)` from the skill picker, `ErrorGump.DisplayTo(...)` from absorption pre-checks, `SelectSkillGump.DisplayTo(...)` from the "make another selection" button on `ConfirmSkillGump` and from `ErrorGump` bounce-back). Because each gump is Singleton, sending the same type again automatically closes any prior instance instead of stacking; the explicit `gumps.Close<T>()` chain on `OnDoubleClick` is preserved so opening the soulstone still resets any orphaned step from another wizard.
`OnResponse` now uses `in RelayInfo info`. All inline `AddX(...)` calls move to `builder.AddX(...)` inside `BuildLayout`. `Skill.Base.ToString("F1")` etc. are converted to `$"{value:F1}"` interpolation passed to `AddLabel(ReadOnlySpan<char>)`. Skill picker pagination still uses client-side `AddPage` / `GumpButtonType.Page` -- no server-state pagination to migrate.
## Summary
Migrates the Quest system gumps from legacy `Gump` to `DynamicGump` / `StaticGump<T>`.
**Renames** `Engines/ML Quests/Gumps/BaseQuestGump` to **`BaseMLQuestGump`** to
disambiguate from the Core quest abstract (`Engines/Quests/Core/QuestSystem.cs`).
Both abstracts now extend `DynamicGump`.
**Abstract bases**:
- `Server.Engines.Quests.BaseQuestGump` (Core) - now `abstract DynamicGump`. Holds constants and a static `AddHtmlObject(ref DynamicGumpBuilder, ...)` helper. Concrete subclasses each provide their own `BuildLayout`.
- `Server.Engines.MLQuests.Gumps.BaseMLQuestGump` (ML, renamed) - now `abstract DynamicGump` with a `protected abstract BuildContent(ref DynamicGumpBuilder)` hook. The base's `BuildLayout` draws shared chrome (background art, frame, label header) and then invokes `BuildContent`; subclasses use `BuildPage`, `SetTitle`, `RegisterButton`, `SetPageCount`, and content helpers (`AddDescription`, `AddObjectives`, `AddObjectivesProgress`, `AddRewardsPage`, `AddRewards`, `AddConversation`).
**Concrete Core gumps** migrated to `DynamicGump`:
- `QuestCancelGump`, `QuestOfferGump` (Core), `QuestObjectivesGump`, `QuestConversationsGump`, `QuestLogUpdatedGump`, `QuestItemInfoGump`, `SheetMusicOfferGump` (Impresario), `PaintedImageGump` (renamed from `PaintedImage.InternalGump`).
**Concrete ML gumps** migrated to `DynamicGump` (extending `BaseMLQuestGump` or directly):
- `InfoNPCGump`, `QuestConversationGump`, `QuestLogDetailedGump`, `QuestLogGump`, `QuestOfferGump` (ML), `QuestReportBackGump`, `QuestRewardGump`, `QuestCancelConfirmGump`, `RaceChangeConfirmGump`.
**`StaticGump<T>` migrations**:
- `ScrollOfAbraxusGump` (Dark Tides). Its layout is a single hard-coded cliloc (1060116) with no per-instance dynamic content - safe to cache.
**Cliloc rule**: Every other quest dialog bakes per-instance cliloc IDs into its layout (quest titles, NPC names, race-specific prompts, era-conditional progress messages, escort destinations). Per the cliloc rule, baking different cliloc numbers into a cached layout would defeat `StaticGump<T>` caching - so all of these are `DynamicGump`.
**Signature changes** to support the migration:
- `QuestObjective.RenderMessage`/`RenderProgress` now take `ref DynamicGumpBuilder builder` (15 overrides updated across Collector, Solen Matriarch, Ambitious Solen Queen, Study of the Solen Hive, Terrible Hatchlings, The Summoning, Uzeraan Turmoil, Witch Apprentice, Emino's Undertaking).
- ML `BaseObjective.WriteToGump` / `BaseObjectiveInstance.WriteToGump` / `BaseReward.WriteToGump` now take `ref DynamicGumpBuilder` (KillObjective, GainSkillObjective, EscortObjective, CollectObjective, DeliverObjective, BaseReward).
**Empty-gump and `Singleton` rules**: All concrete gumps now have private constructors with static `DisplayTo` entry points that null-check the player NetState before allocation. All gumps that shouldn't stack are `Singleton => true`.
**External callers updated**: `MLQuest.SendOffer`/`OnRefuse`, `MLQuestEntry.SendProgressGump`/`SendRewardGump`/`SendReportBackGump`, `MLQuestSystem.QuestGumpRequest` and `ViewQuestsCommand`, `BoonCollector` (Darius/Nedrick), `SirHelper.OnDoubleClick` (no longer caches a single shared `InfoNPCGump` instance - `DisplayTo` constructs one per click and the gump's `Singleton => true` handles deduplication), `RaceChangeDeed.OnDoubleClick`, `PaintedImage.OnDoubleClick`, `ScrollOfAbraxus.OnDoubleClick`, `Impresario.OnTalk`, and `PlayerMobile`'s `BaseQuestGump` alias is now `BaseMLQuestGump`.
**Concrete subclasses found beyond the listed entry points**: `SheetMusicOfferGump` (in Impresario.cs), `PaintedImageGump` (was `PaintedImage.InternalGump`), `QuestObjectivesGump`, `QuestConversationsGump`, `QuestLogUpdatedGump`, `QuestItemInfoGump` (the Core base has these embedded across `QuestSystem.cs`/`QuestObjective.cs`/`QuestConversation.cs`/`QuestItemInfo.cs`).
**Code-standards cleanup**: Renamed legacy `m_X` private fields to `_x` in rewritten files; braces on all control flow.
## Summary
Migrates five legacy `Gump`-derived UI dialogs in the NPC and Skill domains to the modern `DynamicGump` builder pipeline. All five gumps were chosen as `DynamicGump` rather than `StaticGump<T>` because their layout shape varies per instance, and several of them carry per-instance localization numbers (cliloc IDs) that the static cache cannot bake (see CLAUDE.md gump-system rule and `dev-docs/gump-system.md`).
Per-gump rationale:
- **`TownCrierGump` (`Mobiles/Townfolk/TownCrier.cs`)** - DynamicGump. Announcement count varies, expiration text is rebuilt per render via `ValueStringBuilder`, and one button per entry is emitted in a loop.
- **`ClaimListGump` (`Mobiles/Vendors/NPC/AnimalTrainer.cs`)** - DynamicGump. The pet list and resulting background/alpha-region heights vary per stabling player.
- **`AnimalLoreGump` (`Skills/AnimalLore.cs`)** - DynamicGump. Page count itself varies (3 pages pre-AOS, 5 pages on AOS) and several `AddHtmlLocalized` calls use cliloc IDs computed from per-creature data (loyalty rating `1049595 + c.Loyalty / 10`, food preference, pack instinct), which violates the StaticGump cliloc-bake rule.
- **`DisguiseGump` (`Items/Skill Items/Thief/DisguiseKit.cs`)** - DynamicGump. Page count and entry order shift on `from.Female`, `Body.IsFemale`, and `startAtHair`.
- **`CommentsGump` (`Gumps/CommentsGump.cs`)** - DynamicGump. Comment list and pagination depend on `Account.Comments` size; the title label encodes the variable account username string.
All five are now `Singleton => true`, have `private` constructors, and expose static `DisplayTo(...)` entry points that validate prerequisites before constructing - guaranteeing no empty gumps (CLAUDE.md Sec.13). All internal refresh paths (prompts, `OnDoubleClick`, command handlers, target callbacks) were updated to call `DisplayTo` rather than `new XGump(...)`. Legacy `m_`-prefixed fields renamed to `_camelCase` (CLAUDE.md Sec.12), and `DisguiseEntry`'s `m_`-prefixed public readonly fields converted to PascalCase auto-properties. `OnResponse` signatures updated to `in RelayInfo info`. No external callers needed updating - all `new XGump(...)` sites lived inside the same files.
## Summary
Migrates 10 player-facing legacy `Gump` subclasses for holiday and decorative items to the modern `DynamicGump` / `StaticGump<T>` system. All migrated gumps are `Singleton`, use private constructors gated by static `DisplayTo(...)` entry points (empty-gump rule, CLAUDE.md §13), and replace legacy `m_X` fields with `_x` per coding standards.
Per-gump base type and rationale:
- **Mistletoe.cs** — `MistletoeAddonGump` -> `StaticGump<MistletoeAddonGump>`. Fixed re-deed confirmation layout.
- **StValentinesBears.cs** — Renamed `InternalGump` -> `StValentinesBearsGump`, base `StaticGump<T>`. Fixed sign-bear layout with three text entries; legacy `m_Bear` -> `_bear`. Switched legacy `AddTextEntry(..., size)` -> `AddTextEntryLimited(...)` (modern API split).
- **Wreath.cs** — `WreathAddonGump` -> `StaticGump<WreathAddonGump>`. Fixed re-deed confirmation layout.
- **HolidayPottedPlant.cs** — Renamed `InternalGump` -> `HolidayPottedPlantGump`, base `StaticGump<T>`. Fixed plant-picker layout.
- **SnowStatue.cs** — Renamed `InternalGump` -> `SnowStatueGump`, base `StaticGump<T>`. Fixed statue-picker layout. Dropped unused `Mobile from` ctor arg.
- **TapestryOfSosaria.cs** — Renamed `InternalGump` -> `TapestryOfSosariaGump`, base `StaticGump<T>`. Single image, fixed.
- **HouseRaffleDeed.cs** — `WritOfLeaseGump` -> `DynamicGump`. Description HTML is computed per-instance from deed expiration / days-left, so layout text varies per instance. Added `Singleton => true` (was missing implicitly via legacy default).
- **SpecialScroll.cs** — Renamed `InternalGump` -> `SpecialScrollGump`, base `DynamicGump`. **Cliloc rule**: `_scroll.Message`, `_scroll.Title`, `_scroll.SkillLabel` are dynamic cliloc numbers per scroll type — `AddHtmlLocalized` bakes the cliloc number into the cached layout, so `StaticGump<T>` cannot cache it.
- **BallotBox.cs** — Renamed `InternalGump` -> `BallotBoxGump`, base `DynamicGump`. Layout shape varies: variable topic-line count, owner-vs-voter buttons, and vote-tally bars all add/remove elements. Legacy `m_Box` -> `_box`. Updated the `TopicPrompt` callbacks to call `BallotBoxGump.DisplayTo(...)` instead of allocating a new gump directly.
- **AquariumGump.cs** — `AquariumGump` -> `DynamicGump`. Per-page layout depends on each item's `LabelNumber` and (for `BaseFish`) `GetDescription()` cliloc — those vary per fish/decoration. Two `DisplayTo` overloads (auto-detect access vs explicit edit flag) to mirror the original two call sites in `Aquarium.cs`. Legacy `m_Aquarium` -> `_aquarium`.
### Skipped
- **HouseRaffleManagementGump.cs** — Skipped as **staff-only**. Only invoked via `ManagementEntry` context entry inside `HouseRaffleStone.cs`, gated by `from.AccessLevel >= AccessLevel.Seer`. Per task instructions, staff-only gumps are out of scope for this PR.
### External callers updated
- `Aquarium.cs` — Two `new AquariumGump(...)` sites swapped to `AquariumGump.DisplayTo(...)`.
All other migrated inner classes were nested in the same file and only had local references, which were updated to call the new `DisplayTo(...)` static entry point.
## Summary
Migrates the three player-facing travel/moongate gumps from legacy `Gump` to the modern `DynamicGump` system.
- `GoGump` (Gumps/Go/GoGump.cs) → `DynamicGump`. The category-tree layout's row count varies with the current `GoCategory`'s child count and pagination. Refreshes via `SendGump(this)` after mutating `_node`/`_page` instead of allocating a new instance per nav/page click.
- `MoongateGump` (Items/Misc/PublicMoongate.cs) → `DynamicGump`. The destination tab strip and per-map pages are gated by ruleset (sigil bearer, murderer, faction facet) and expansion/young flag, plus the configured map selection. The set of pages and the active-map swap make the layout shape per-instance.
- `MoongateConfirmGump` (Items/Skill Items/Magical/Misc/Moongate.cs) → `DynamicGump`. Per the **dynamic-cliloc rule**, the gump bakes one of two different cliloc numbers (1062050 Felucca-warning vs 1062049 generic confirm) and selects between an AOS and pre-AOS layout shape — both characteristics force `DynamicGump` rather than `StaticGump<T>` because cached layout bytes would otherwise lock in the wrong cliloc/shape.
All three now use a `private` constructor with a `public static DisplayTo(...)` entry point that validates prerequisites before any gump is allocated (empty-gump rule, CLAUDE.md §13). All are `Singleton` and use `SendGump(this)` self-refresh on internal navigation. Updated callers: `PublicMoongate.UseGate` and `Moongate.BeginConfirmation` now call `DisplayTo(...)`.
## Summary
Second PR in the player-facing legacy gump migration. Converts the four Plants system gumps:
- `MainPlantGump`, `ReproductionGump`, `EmptyTheBowlGump` → `DynamicGump`. Layout varies by plant status, growth stage, health, and pollination/resource availability — cannot use cached `StaticGump<T>`.
- `SetToDecorativeGump` → `StaticGump<SetToDecorativeGump>`. Pure confirmation dialog with no per-instance variation.
All four:
- `Singleton => true` (auto-replace previous plant gump on re-open instead of stacking)
- Constructor `private`; entry is static `DisplayTo(Mobile, PlantItem)` per the empty-gump rule (CLAUDE.md §13)
- `OnResponse` self-refresh paths converted from `from.SendGump(new XGump(_plant))` to `from.SendGump(this)` — saves an allocation on every help/info button click and on every "gather resources/seeds/pollen" action
- Helper draw methods take `ref DynamicGumpBuilder builder` instead of mutating instance state
- Renamed legacy `m_Plant` to `_plant` per CLAUDE.md §12
Updated external callers to use the new entry points:
- `PlantItem.OnDoubleClick` → `MainPlantGump.DisplayTo(from, this)`
- `PlantPourTarget.OnTargetFinish` → `MainPlantGump.DisplayTo(from, m_Plant)` (also drops the now-redundant legacy `singleton: true` flag — `Singleton` property handles it)
- `PollinateTarget.OnTargetFinish` → `ReproductionGump.DisplayTo(from, m_Plant)`
## Summary
First PR in a multi-PR migration of player-facing legacy `Gump` subclasses to the modern `DynamicGump` / `StaticGump<T>` system. Plan covers ~95 files across ~14 PRs by system; this PR is the foundation (smallest, isolated, no cross-refs).
- `VirtueGump` → `DynamicGump`: per-instance virtue hues from `GetHueFor()` and the conditional self/other button block prevent layout caching.
- `VirtueStatusGump` → `StaticGump<VirtueStatusGump>`: layout is identical for every player; only used as a navigation hub.
- `VirtueInfoGump` → `DynamicGump`: dynamic cliloc IDs (`1051000 + (int)virtue`, the description cliloc, and the conditional `1052055`/`1052052` footer) cannot be cached by `StaticGump<T>` — cliloc *numbers* are baked into layout bytes, only HTML/label *text* can be deferred to placeholders.
All three:
- `Singleton => true` (replaces previous instance instead of stacking)
- Constructors made `private`; entry points are static (`RequestVirtueGump`, `DisplayTo`) per the empty-gump rule (CLAUDE.md §13)
- `OnResponse` updated to `in RelayInfo info` modern signature
- `VirtueInfoGump` self-refresh button now uses `_beholder.SendGump(this)` instead of allocating a new instance
- Removed the unused `VirtueGumpItem : GumpImage` nested class — replaced with direct `builder.AddImage(...)` calls; preserves the legacy `class=VirtueGumpItem` attribute for packet parity
The special-cased TypeID for VirtueGump (`BaseGump.cs:86`) is preserved because the type's full name (`Server.Engines.Virtues.VirtueGump`) is unchanged.
## 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`.