feat(pathfinding): non-eager TryGetMask + second-touch promotion (#2451)

## 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.
This commit is contained in:
Kamron Batman 2026-06-06 13:11:53 -07:00 committed by GitHub
parent cff9fbda29
commit 9a3d88988c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1664 additions and 163 deletions

View file

@ -14,11 +14,11 @@ namespace Server.Engines.Pathing.Cache;
/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
/// of file size.
///
/// File layout v2 (little-endian, BufferWriter / BufferReader convention):
/// File layout v3 (little-endian, BufferWriter / BufferReader convention):
///
/// Header (48 bytes):
/// u32 Magic = 0x42575300 ('SWB\0')
/// u32 Version = current FormatVersion (2)
/// u32 Version = current FormatVersion (3)
/// u32 MapId
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
@ -35,11 +35,16 @@ namespace Server.Engines.Pathing.Cache;
/// u16 ChunkY
/// u32 BuiltMultisVersion
/// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata trailer follows
/// u8 HasSwimLayer 0 = no shore cells (no swim trailer); 1 = swim trailer follows
/// byte WalkMask[256]
/// byte WetMask[256]
/// sbyte SourceZ[256]
/// sbyte WalkZN[256]..WalkZNW[256] (8 arrays in N,NE,E,SE,S,SW,W,NW order)
/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order)
/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order; baked at WALK source-Z)
/// // Swim layer trailer — only when HasSwimLayer == 1 (chunks containing shore cells):
/// sbyte SwimSourceZ[256] (NoSwimLayerCell sentinel = sbyte.MinValue)
/// byte SwimMask[256] (per-cell swim mask baked at SwimSourceZ)
/// sbyte SwimZN_Layer[256]..SwimZNW_Layer[256] (8 arrays, dest-Z at swim perspective)
/// // Strata trailer — only when HasStrata == 1:
/// u16 StrataOffsetByCell[256] (NoStrata sentinel = 0xFFFF)
/// u32 StrataDataLength
@ -63,15 +68,20 @@ namespace Server.Engines.Pathing.Cache;
internal static class StepCacheFile
{
public const uint Magic = 0x42575300; // 'SWB\0'
public const uint FormatVersion = 2;
public const uint FormatVersion = 4;
/// <summary>
/// Lowest format version this binary can load. Files below this version are treated as
/// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with
/// the current FormatVersion. Bumped to 2 when Tier 4 multi-Z strata landed; v1 had no
/// strata data and is incompatible with the strata-aware lookup path.
/// the current FormatVersion. Bumped to 3 when the swim layer landed (v2 had no swim
/// layer). Bumped to 4 when the baker switched to clearance-aware standable-surface
/// strata: v3 bakes anchored every cell at the land average and so missed walkable
/// static-over-land surfaces (sewer/dungeon walkways, bridges, upper building floors),
/// producing ~98% source-Z fallthroughs on those routes. The on-disk layout is
/// unchanged; only the strata population differs, so the bump exists purely to force a
/// one-time re-bake of stale v3 files on first boot under the new binary.
/// </summary>
public const uint MinSupportedVersion = 2;
public const uint MinSupportedVersion = 4;
private const int HeaderSize =
sizeof(uint) // Magic
@ -87,15 +97,19 @@ internal static class StepCacheFile
// single bulk read per chunk without consulting the next offset.
private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint);
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata trailer).</summary>
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).</summary>
private const int BytesPerChunkBase =
sizeof(ushort) + sizeof(ushort) + sizeof(uint) + sizeof(byte)
sizeof(ushort) + sizeof(ushort) + sizeof(uint)
+ sizeof(byte) + sizeof(byte) // HasStrata + HasSwimLayer
+ StepChunk.CellsPerChunk // WalkMask
+ StepChunk.CellsPerChunk // WetMask
+ StepChunk.CellsPerChunk // SourceZ
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
/// <summary>Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays.</summary>
private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk;
/// <summary>Strata trailer overhead when present: 256×u16 offset table + u32 data length.</summary>
private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint);
@ -201,9 +215,10 @@ internal static class StepCacheFile
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
// Initial estimate: base record + a modest strata budget per chunk. BufferWriter
// grows on overflow, so under-estimating just causes a few realloc/copy cycles
// during the bake — not a correctness issue.
// Initial estimate: base record + a modest strata budget per chunk. Coastline
// chunks add another ~2.5 KB (swim layer) but they're a small fraction of any
// map; the writer grows on overflow so under-estimating just causes a few
// realloc/copy cycles during the bake — not a correctness issue.
var capacity = HeaderSize
+ (BytesPerChunkBase + 256) * (int)chunkCount
+ IndexEntryBytes * (int)chunkCount;
@ -355,7 +370,9 @@ internal static class StepCacheFile
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
var strataData = chunk.GetStrataDataForSerialization();
var hasStrata = strataOffsetByCell != null;
var hasSwimLayer = chunk.HasSwimLayer;
w.Write((byte)(hasStrata ? 1 : 0));
w.Write((byte)(hasSwimLayer ? 1 : 0));
w.Write(chunk.WalkMask);
w.Write(chunk.WetMask);
@ -379,6 +396,20 @@ internal static class StepCacheFile
WriteSBytes(w, chunk.SwimZW);
WriteSBytes(w, chunk.SwimZNW);
if (hasSwimLayer)
{
WriteSBytes(w, chunk.SwimSourceZ);
w.Write(chunk.SwimMask);
WriteSBytes(w, chunk.SwimZN_Layer);
WriteSBytes(w, chunk.SwimZNE_Layer);
WriteSBytes(w, chunk.SwimZE_Layer);
WriteSBytes(w, chunk.SwimZSE_Layer);
WriteSBytes(w, chunk.SwimZS_Layer);
WriteSBytes(w, chunk.SwimZSW_Layer);
WriteSBytes(w, chunk.SwimZW_Layer);
WriteSBytes(w, chunk.SwimZNW_Layer);
}
if (hasStrata)
{
// 256 × u16 offsets, then u32 length-prefixed strata byte array.
@ -403,6 +434,7 @@ internal static class StepCacheFile
r.ReadUShort();
var multisVersion = (int)r.ReadUInt();
var hasStrata = r.ReadByte() != 0;
var hasSwimLayer = r.ReadByte() != 0;
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
@ -428,6 +460,21 @@ internal static class StepCacheFile
ReadSBytes(r, chunk.SwimZW);
ReadSBytes(r, chunk.SwimZNW);
if (hasSwimLayer)
{
chunk.AllocateSwimLayer();
ReadSBytes(r, chunk.SwimSourceZ);
r.Read(chunk.SwimMask);
ReadSBytes(r, chunk.SwimZN_Layer);
ReadSBytes(r, chunk.SwimZNE_Layer);
ReadSBytes(r, chunk.SwimZE_Layer);
ReadSBytes(r, chunk.SwimZSE_Layer);
ReadSBytes(r, chunk.SwimZS_Layer);
ReadSBytes(r, chunk.SwimZSW_Layer);
ReadSBytes(r, chunk.SwimZW_Layer);
ReadSBytes(r, chunk.SwimZNW_Layer);
}
if (hasStrata)
{
var offsets = new ushort[StepChunk.CellsPerChunk];
@ -472,6 +519,19 @@ internal static class StepCacheFile
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
/// <summary>
/// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by
/// <see cref="StepCache"/> when preload is enabled to materialize all chunks
/// upfront instead of on first query.
/// </summary>
public IEnumerable<(int chunkX, int chunkY)> EnumerateChunkCoords()
{
foreach (var key in _offsets.Keys)
{
yield return ((int)(key >> 32), (int)(key & 0xFFFFFFFF));
}
}
internal LazyReader(
FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
uint chunkCount, Dictionary<ulong, (ulong offset, uint length)> offsets