ModernUO/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs
Kamron Batman b852bca41e
perf(pathing): pool the StepCache strata buffer, then clean up the pathing engine around it (#2523)
Started as an allocation pass over `StepCache` and grew into a cleanup of the surrounding pathing engine. Four commits, each independently reviewable; net **−560 lines**.

Build clean (0 warnings). All 122 `Server.Tests.Pathfinding` tests pass.

---

## 1. `perf`: pool the strata buffer, cut a hot-path dictionary lookup

**The headline is that `TryGetMask` — the actual hot path — was already allocation-free.** `StepMask` is a readonly struct, `StaticTileEnumerable` is a `ref struct`, `ChunkMissState` is a struct in a `Dictionary`. So most of this is a bake-throughput and GC-churn win, with one exception noted below.

`BuildChunk` accumulated packed multi-Z strata into a `List<byte>` that grew by doubling (256 → 512 → 1024 → …) and then paid a final `ToArray()`. A full map bake runs it ~114k times. It now writes into a `byte[]` rented from `STArrayPool<byte>.Shared` through a span writer, and hands the chunk one exact-size copy.

**This required fixing a latent out-of-bounds guard.** The record-fit check reserved headroom for **8** strata (`StratumByteLength * 8`) while `ComputeStandableSurfaceZs` can return up to **16** — so a cell could write 305 bytes starting from a 65,383-byte offset. Against a `List` that was benign (it just grew past 64 KB, and emitted offsets stayed under the `NoStrata` sentinel). Against a fixed-size rented buffer it is an out-of-bounds write, so tightening it was a *prerequisite* for the pooling, not a drive-by. The guard is now exact, which additionally proves no emitted offset can collide with `NoStrata == ushort.MaxValue`.

**One genuine query-path win:** `ShouldPromoteAfterMiss` did *two* dictionary lookups per miss — a `TryGetValue`, then an indexer assignment that re-hashes and re-probes. It now mutates in place via `CollectionsMarshal.GetValueRefOrNullRef`. This runs on every uncached chunk touch during A* expansion. The window-expiry branch keeps its explicit early return, so `MissPromotionThreshold == 1` still resets rather than promoting.

Also dropped `StepProbe.ComputeStrataAt` / `ComputedStratum` (dead code, zero callers) and collapsed six 18-argument `new StepMask(0, 0, …, kind)` blocks into `Fallthrough(kind)`.

**Considered and rejected:** pooling the `Direction[]` that `Find` returns. It *escapes* the call — `MovementPath` holds it across ticks while `PathFollower` walks `m_Index` through it — so it cannot be rented-and-returned, and it cannot be borrowed from the shared `BitmapAStarAlgorithm.Instance` without one creature clobbering another's in-flight path. `CheckPath` rate-limits repaths to one per 2s per creature, putting this at roughly 60 KB/sec at 1,000 pathing creatures. Not worth a public API break plus a use-after-return footgun.

## 2. `docs`: rewrite the comments for publication

The comments had accumulated as development notes: internal phase jargon (`Tier 4`, `the Phase-2 synthesizer`), change narration aimed at a reviewer (`which the old ComputeStandingZ anchor missed`, `legacy behavior`), benchmark anecdotes (`benchmarked as near-optimal`, `a ~20 ns lookup`), and paragraphs restating the code.

Rewritten to keep the rationale you cannot recover by reading the code — why the source-Z guard cannot be widened, why multis fall through with a halo, why the promotion gate counts Finds rather than calls, why `ComputeFingerprint` must hash the *files* and not the live tile tables — and drop the history that got us there.

Three comments were **factually wrong**, not just wordy:

- `CacheEvictionTimer` and `CacheStats` documented a class called `StaticWalkabilityCache`. No such class exists — it is `StepCache`.
- `StepCacheFile` declared `File layout v8` while `FormatVersion` is 9, and called the current record layout "the v6 layout" in four places. The layout descriptions are now unversioned so they cannot drift again.
- `StepProbe.ComputeStandingZ` claimed `StepCache` uses it to bake `SourceZ`. It has not since the baker moved to the clearance-aware `ComputeStandableSurfaceZs`; only a parity test calls it.

## 3. `refactor`: simplify `StepCacheFile.Write`, consolidate the format tests

`SaveToFile` walked `_keysList` **twice** — once to count the map's chunks, then again through a `ChunkEnumerator` closure to emit them — because `Write` needed the count up front to size its index array. Both loops had the same root cause. Passing a **span** collapses them: the count is just `span.Length`.

That deletes the `ChunkEnumerator` delegate, the closure over the list enumerator, and **both `InvalidOperationException` throws**, which existed only to police the delegate's "yield exactly `chunkCount` chunks" contract — a contract a span makes unrepresentable.

`Write` now patches the header's `IndexOffset` by seeking back to it rather than reaching into the writer's live buffer with `BinaryPrimitives`. That also retires `IndexOffsetFieldPosition`, a hand-maintained byte offset that had to track the header layout, and sidesteps the stale-array hazard that motivated the manual patch (`BufferWriter` reallocates on growth).

**Tests:** `StepCacheFileV6/V7/V8Tests` were named for the format version that introduced each transform — and the format is now **v9**, so all three names described formats the loader rejects outright. Beyond triplicated builders and plumbing, two things were actually broken:

- The three near-identical rejection tests each cited a `MinSupportedVersion` that had since moved (`"version 5 < MinSupportedVersion 6"`, `"6 < 7"`, `"7 < 8"`). They passed for the wrong reason.
- `AssertBaseEqual` (used by V7 and V8) **silently skipped the swim and strata trailers**. A regression dropping either would not have failed those tests.

Now one `StepCacheFileFormatTests`, named for behavior — predictive-Z elision, compression, compact index — with a single `AssertIdentical` that does check both trailers, the three rejection tests folded into one theory that also covers a future version, and a zero-chunk case the delegate-based writer never had coverage for.

## 4. `test`: consolidate the parity and lifecycle tests

Three files tested "parity" and none of the names said *which*. They were three different layers, and the seams are the useful part, so they are now one `StepCacheParityTests` that names them:

| Test | Compares | Answers |
|---|---|---|
| `ProbeMatchesSlowPath` | StepProbe vs MovementImpl | Is the bake right? |
| `CacheMatchesProbe` | StepCache vs StepProbe | Is it stored and returned intact? |
| `CacheServesReachableWalkStates` | StepCache vs MovementImpl | End to end, over the states A* visits |

Merging removed a duplicated stub `Mobile`, duplicated region seeds, and a filename/class mismatch (`StepProbeParityTests.cs` declared `StaticWalkabilityParityTests`). `SwimBake_ProducesWetCells` moved with it — it lived in the cache parity file but never touched the cache.

Tests reached into `StepCache._chunks` via `GetField` in **9 places**, each rebuilding the key encoding and cell-index arithmetic by hand. `StepCache` now exposes `GetResidentChunk` and `ResidentIndexInSync` alongside the internal test hooks it already had (`LazyReaderHasChunk`, `CurrentFindGeneration`), and the shared arithmetic moved to `PathingTestSupport`. All 9 reflection blocks are gone.

`StepCacheLifecycleTests` is regrouped by what it covers — promotion gate, fallthrough routes, strata, swim layer, eviction — with the `Tier4*` names dropped. Removed `Singleton_IsAvailable`, which asserted an inline-initialized static property was not null; that is the entire 123 → 122 test-count delta.

---

## Verification

Tests were mutation-checked rather than just run, since round-trip and parity tests can pass while a transform silently no-ops:

- Injecting an off-by-one into the `IndexOffset` patch fails **15 of 123** — the format tests are load-bearing.
- Offsetting the cache's cell index by one fails **7 of 10** parity cases, and the 3 that stay green are exactly the ones that do not touch the cache. The layering localizes a fault rather than just reporting one.
2026-07-12 20:02:29 -07:00

85 lines
2.8 KiB
C#

namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Per-cell, per-direction walkability baked by <see cref="StepProbe"/> and stored by
/// <see cref="StepCache"/>. Two rule sets travel together: WalkMask + WalkZ_* for a default
/// walker (cantWalk=false, canSwim=false), WetMask + SwimZ_* for a swim-only mob
/// (cantWalk=true, canSwim=true). Callers overlay whichever applies to the mobile.
/// </summary>
public readonly struct StepMask(
byte walkMask,
byte wetMask,
sbyte walkZN,
sbyte walkZNE,
sbyte walkZE,
sbyte walkZSE,
sbyte walkZS,
sbyte walkZSW,
sbyte walkZW,
sbyte walkZNW,
sbyte swimZN,
sbyte swimZNE,
sbyte swimZE,
sbyte swimZSE,
sbyte swimZS,
sbyte swimZSW,
sbyte swimZW,
sbyte swimZNW,
CacheHitKind hitKind = CacheHitKind.Hit
)
{
public readonly byte WalkMask = walkMask;
public readonly byte WetMask = wetMask;
public readonly sbyte WalkZ_N = walkZN;
public readonly sbyte WalkZ_NE = walkZNE;
public readonly sbyte WalkZ_E = walkZE;
public readonly sbyte WalkZ_SE = walkZSE;
public readonly sbyte WalkZ_S = walkZS;
public readonly sbyte WalkZ_SW = walkZSW;
public readonly sbyte WalkZ_W = walkZW;
public readonly sbyte WalkZ_NW = walkZNW;
public readonly sbyte SwimZ_N = swimZN;
public readonly sbyte SwimZ_NE = swimZNE;
public readonly sbyte SwimZ_E = swimZE;
public readonly sbyte SwimZ_SE = swimZSE;
public readonly sbyte SwimZ_S = swimZS;
public readonly sbyte SwimZ_SW = swimZSW;
public readonly sbyte SwimZ_W = swimZW;
public readonly sbyte SwimZ_NW = swimZNW;
public readonly CacheHitKind HitKind = hitKind;
/// <summary>
/// True when the cache produced a usable answer. False on any Fallthrough_*, where the
/// payload is all zeroes and the caller must resolve this cell via the slow path.
/// </summary>
public bool IsHit => HitKind <= CacheHitKind.Miss_DirtyRebuild;
public bool IsWalkable(Direction d) => (WalkMask & (1 << (int)d)) != 0;
public bool IsSwimmable(Direction d) => (WetMask & (1 << (int)d)) != 0;
public sbyte GetWalkZ(Direction d) => d switch
{
Direction.North => WalkZ_N,
Direction.Right => WalkZ_NE,
Direction.East => WalkZ_E,
Direction.Down => WalkZ_SE,
Direction.South => WalkZ_S,
Direction.Left => WalkZ_SW,
Direction.West => WalkZ_W,
Direction.Up => WalkZ_NW,
_ => 0
};
public sbyte GetSwimZ(Direction d) => d switch
{
Direction.North => SwimZ_N,
Direction.Right => SwimZ_NE,
Direction.East => SwimZ_E,
Direction.Down => SwimZ_SE,
Direction.South => SwimZ_S,
Direction.Left => SwimZ_SW,
Direction.West => SwimZ_W,
Direction.Up => SwimZ_NW,
_ => 0
};
}