ModernUO/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs
Kamron Batman 9a3d88988c
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.
2026-06-06 13:11:53 -07:00

143 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
}
}