## 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.
143 lines
5.4 KiB
C#
143 lines
5.4 KiB
C#
using System;
|
||
using Server.Engines.Pathing.Cache;
|
||
using Xunit;
|
||
using Xunit.Abstractions;
|
||
|
||
namespace Server.Tests.Pathfinding;
|
||
|
||
[Collection("Sequential Pathfinding Tests")]
|
||
public class StepCacheParityTests
|
||
{
|
||
private readonly ITestOutputHelper _output;
|
||
|
||
public StepCacheParityTests(ITestOutputHelper output)
|
||
{
|
||
_output = output;
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("britain_inn_dense", 1480, 1610, 32)]
|
||
[InlineData("trammel_open_plain", 1500, 1600, 32)]
|
||
[InlineData("britain_causeway", 1475, 1641, 32)]
|
||
public void CacheMatchesBaker(string label, int xStart, int yStart, int size)
|
||
{
|
||
var cache = StepCache.Instance;
|
||
cache.Clear();
|
||
cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately
|
||
|
||
var map = Map.Maps[1];
|
||
Assert.NotNull(map);
|
||
|
||
var disagreements = 0;
|
||
var samples = 0;
|
||
var multiZ = 0;
|
||
var wetCells = 0;
|
||
|
||
// The cache anchors each cell at the surface a creature actually STANDS on
|
||
// (clearance-aware), not the land average. Query at that same standable Z so the
|
||
// source-Z guard doesn't false-positive (e.g. on a raised causeway or sewer walkway
|
||
// whose surface sits well above the land). Cells with no standable walk surface are
|
||
// skipped — there's nothing for a walker to compare against.
|
||
Span<sbyte> surfZ = stackalloc sbyte[16];
|
||
|
||
for (var x = xStart; x < xStart + size; x++)
|
||
{
|
||
for (var y = yStart; y < yStart + size; y++)
|
||
{
|
||
if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0)
|
||
{
|
||
continue;
|
||
}
|
||
var sourceZ = surfZ[0];
|
||
|
||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||
|
||
var lookup = cache.TryGetMask(map, x, y, sourceZ);
|
||
|
||
samples++;
|
||
|
||
if (lookup.HitKind == CacheHitKind.Fallthrough_MultiZ)
|
||
{
|
||
multiZ++;
|
||
continue;
|
||
}
|
||
|
||
Assert.True(lookup.IsHit, $"Cache returned !ok at ({x},{y}) hitKind={lookup.HitKind}");
|
||
|
||
if (lookup.WalkMask != baker.WalkMask)
|
||
{
|
||
disagreements++;
|
||
_output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{lookup.WalkMask:X2} baker=0x{baker.WalkMask:X2}");
|
||
continue;
|
||
}
|
||
|
||
if (lookup.WetMask != baker.WetMask)
|
||
{
|
||
disagreements++;
|
||
_output.WriteLine($"WET MASK DIFF @ ({x},{y}) cache=0x{lookup.WetMask:X2} baker=0x{baker.WetMask:X2}");
|
||
continue;
|
||
}
|
||
|
||
if (lookup.WetMask != 0)
|
||
{
|
||
wetCells++;
|
||
}
|
||
|
||
if (lookup.WalkZ_N != baker.WalkZ_N || lookup.WalkZ_NE != baker.WalkZ_NE
|
||
|| lookup.WalkZ_E != baker.WalkZ_E || lookup.WalkZ_SE != baker.WalkZ_SE
|
||
|| lookup.WalkZ_S != baker.WalkZ_S || lookup.WalkZ_SW != baker.WalkZ_SW
|
||
|| lookup.WalkZ_W != baker.WalkZ_W || lookup.WalkZ_NW != baker.WalkZ_NW)
|
||
{
|
||
disagreements++;
|
||
_output.WriteLine($"Z DIFF @ ({x},{y}) cache=({lookup.WalkZ_N},{lookup.WalkZ_NE},{lookup.WalkZ_E},{lookup.WalkZ_SE},{lookup.WalkZ_S},{lookup.WalkZ_SW},{lookup.WalkZ_W},{lookup.WalkZ_NW}) baker=({baker.WalkZ_N},{baker.WalkZ_NE},{baker.WalkZ_E},{baker.WalkZ_SE},{baker.WalkZ_S},{baker.WalkZ_SW},{baker.WalkZ_W},{baker.WalkZ_NW})");
|
||
}
|
||
}
|
||
}
|
||
|
||
_output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ} wetCells={wetCells}");
|
||
|
||
// Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
|
||
if (label == "britain_inn_dense")
|
||
{
|
||
Assert.True(samples - multiZ > 0, "expected real cache answers in dense region");
|
||
}
|
||
|
||
Assert.Equal(0, disagreements);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Non-vacuity guard for the swim bake: scans a wide swath of the south-Britain bay
|
||
/// (Atlantic coast) and asserts at least one cell has a non-zero WetMask. Catches the
|
||
/// failure mode where StepProbe silently bakes zero swim output everywhere.
|
||
/// </summary>
|
||
[Fact]
|
||
public void SwimBake_ProducesWetCells_OnKnownWaterRegion()
|
||
{
|
||
var map = Map.Maps[1];
|
||
Assert.NotNull(map);
|
||
|
||
// South Britain → Britain bay, includes Atlantic shoreline. 64×64 = 4096 cells;
|
||
// even a partial coastline straddle should yield dozens of wet cells.
|
||
const int xStart = 1430;
|
||
const int yStart = 1740;
|
||
const int size = 64;
|
||
|
||
var wetCells = 0;
|
||
for (var x = xStart; x < xStart + size; x++)
|
||
{
|
||
for (var y = yStart; y < yStart + size; y++)
|
||
{
|
||
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
||
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
|
||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||
if (baker.WetMask != 0)
|
||
{
|
||
wetCells++;
|
||
}
|
||
}
|
||
}
|
||
|
||
_output.WriteLine($"south-britain swim probe: wetCells={wetCells} of 4096");
|
||
Assert.True(wetCells > 0, "swim bake produced zero wet cells across a 64×64 coastal region");
|
||
}
|
||
}
|