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
|
|
@ -102,6 +102,87 @@ public static class StepProbe
|
|||
return strata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the distinct surface Zs at (x, y) that a default walker (PersonHeight envelope)
|
||||
/// can actually STAND on — each candidate surface (walkable land center + every walkable
|
||||
/// static top) that has PersonHeight of vertical clearance free of impassable statics —
|
||||
/// into <paramref name="zs"/>, ascending, and returns the count.
|
||||
///
|
||||
/// This is the clearance-aware counterpart to <see cref="ComputeStrataAt"/>'s candidate
|
||||
/// gather: it drops surfaces a creature cannot occupy (land under a sewer walkway, ground
|
||||
/// under a low bridge), so the result is exactly the set of standing Zs the slow path can
|
||||
/// resolve to. Two standable surfaces are inherently >= PersonHeight apart (an upper
|
||||
/// surface within PersonHeight of a lower one removes the lower one's clearance), so a
|
||||
/// single ascending pass with an exact-duplicate skip is sufficient.
|
||||
///
|
||||
/// Used by the baker to capture walkable static-over-land surfaces (sewer/dungeon
|
||||
/// walkways, bridges, raised foundations, upper building floors) that the land-anchored
|
||||
/// main mask would otherwise miss.
|
||||
/// </summary>
|
||||
public static int ComputeStandableSurfaceZs(Map map, int x, int y, Span<sbyte> zs)
|
||||
{
|
||||
if (map == null || map == Map.Internal)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Span<int> cand = stackalloc int[16];
|
||||
var count = 0;
|
||||
|
||||
var landTile = map.Tiles.GetLandTile(x, y);
|
||||
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
||||
if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0)
|
||||
{
|
||||
map.GetAverageZ(x, y, out _, out var landCenter, out _);
|
||||
cand[count++] = landCenter;
|
||||
}
|
||||
|
||||
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
|
||||
{
|
||||
if (count >= cand.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||
if (!data.Surface || data.Impassable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
cand[count++] = tile.Z + data.CalcHeight;
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
cand[..count].Sort();
|
||||
|
||||
var n = 0;
|
||||
for (var i = 0; i < count && n < zs.Length; i++)
|
||||
{
|
||||
var cz = (sbyte)Math.Clamp(cand[i], sbyte.MinValue + 1, sbyte.MaxValue);
|
||||
if (n > 0 && zs[n - 1] == cz)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Standable iff the creature's PersonHeight body envelope above this surface is
|
||||
// free of impassable statics. The surface itself never blocks (its top == cz,
|
||||
// which is the envelope floor, not inside it).
|
||||
if (StaticsBlockAt(map, x, y, cz, cz + PersonHeight))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
zs[n++] = cz;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ)
|
||||
{
|
||||
if (map == null || map == Map.Internal)
|
||||
|
|
@ -166,6 +247,45 @@ public static class StepProbe
|
|||
return zCenter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the water-surface standing Z at (x, y) — the Z a swim-only mob would stand
|
||||
/// at on this cell — or <see cref="int.MinValue"/> if no water surface exists. Used
|
||||
/// by <see cref="StepCache"/> to detect shore cells (cells with both walk and swim
|
||||
/// surfaces separated by > StepHeight) and bake their swim layer at swim-perspective Z.
|
||||
/// </summary>
|
||||
public static int ComputeSwimStandingZ(Map map, int x, int y)
|
||||
{
|
||||
if (map == null || map == Map.Internal)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
// Land tile flagged Wet — its center Z is the swim surface.
|
||||
var landTile = map.Tiles.GetLandTile(x, y);
|
||||
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
||||
if (!landTile.Ignored && (landFlags & TileFlag.Wet) != 0)
|
||||
{
|
||||
map.GetAverageZ(x, y, out _, out var landCenter, out _);
|
||||
return landCenter;
|
||||
}
|
||||
|
||||
// Otherwise scan statics for a wet surface.
|
||||
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
|
||||
{
|
||||
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||
if (data.Wet)
|
||||
{
|
||||
return tile.Z + data.CalcHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue