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:
parent
cff9fbda29
commit
9a3d88988c
14 changed files with 1664 additions and 163 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue