diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs
new file mode 100644
index 000000000..9027332c7
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs
@@ -0,0 +1,69 @@
+using System;
+using Server.Engines.Pathing.Cache;
+
+namespace Server.Tests.Pathfinding;
+
+///
+/// Shared fixtures for the step-cache tests: the walker the parity tests measure against, the
+/// cell-index arithmetic, and builders for the chunk state several tests inject by hand.
+///
+internal static class PathingTestSupport
+{
+ ///
+ /// Trammel. Every seed coordinate below is a real location on it, so these tests need the
+ /// client's map files; they skip when those are absent.
+ ///
+ public static Map TestMap => Map.Maps[1];
+
+ ///
+ /// A cell in open Britain countryside — flat, walkable in all directions, no statics. The
+ /// default subject when a test needs a chunk to exist and doesn't care what's in it.
+ ///
+ public const int PlainX = 1500;
+ public const int PlainY = 1600;
+
+ /// Index of world cell (x, y) within its own chunk.
+ public static int CellIndex(int x, int y) => ((y & 15) << 4) | (x & 15);
+
+ /// A strata offset table with every cell marked single-Z.
+ public static ushort[] NoStrataOffsets()
+ {
+ var offsets = new ushort[StepChunk.CellsPerChunk];
+ Array.Fill(offsets, StepChunk.NoStrata);
+ return offsets;
+ }
+
+ ///
+ /// Packs a one-stratum record: a count byte, then the stratum itself. Directions not named in
+ /// stay at 0. Mirrors the layout StepCache.WriteStratum produces.
+ ///
+ public static byte[] OneStratum(sbyte zCenter, byte walkMask = 0, byte wetMask = 0, params sbyte[] walkZs)
+ {
+ var data = new byte[1 + StepChunk.StratumByteLength];
+ data[0] = 1; // stratum count
+ data[1] = (byte)zCenter;
+ data[2] = walkMask;
+ data[3] = wetMask;
+
+ // walkZ_N..NW occupy bytes 4..11; swimZ_N..NW follow at 12..19.
+ for (var i = 0; i < walkZs.Length && i < 8; i++)
+ {
+ data[4 + i] = (byte)walkZs[i];
+ }
+
+ return data;
+ }
+
+ ///
+ /// The default static walker. Deriving straight from rather than
+ /// BaseCreature is the point: MovementImpl then sees no creature capabilities (no swim, no fly,
+ /// no door-opening), which is exactly the walker the cache bakes for.
+ ///
+ public sealed class StaticWalker : Mobile
+ {
+ public StaticWalker()
+ {
+ Body = 0xC9;
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
index 3c9d12616..d29b79ef9 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs
@@ -286,19 +286,14 @@ public class StepCacheFileTests
var map = Map.Maps[1];
Assert.NotNull(map);
- // Build a chunk and inject a synthetic swim layer onto cell (1500, 1600).
+ // Build a chunk and inject a synthetic swim layer onto one cell.
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField!.GetValue(cache)!;
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- var chunk = chunks[key];
+ var chunk = cache.GetResidentChunk(map.MapID, 1500 >> 4, 1600 >> 4);
+ Assert.NotNull(chunk);
chunk.AllocateSwimLayer();
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
+ var cellIndex = PathingTestSupport.CellIndex(1500, 1600);
chunk.SwimSourceZ[cellIndex] = -7;
chunk.SwimMask[cellIndex] = 0b0000_1111;
chunk.SwimZN_Layer[cellIndex] = -7;
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
index 436f78c7e..7b630173c 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
@@ -1,48 +1,64 @@
using System.Collections.Generic;
using System.Reflection;
+using System.Threading;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
+using static Server.Tests.Pathfinding.PathingTestSupport;
namespace Server.Tests.Pathfinding;
+///
+/// How the cache decides what to build, what to serve, and what to throw away: the promotion gate,
+/// the four fallthrough routes out of , the strata and swim
+/// layers, and LRU eviction.
+///
[Collection("Sequential Pathfinding Tests")]
public class StepCacheLifecycleTests
{
- [Fact]
- public void Singleton_IsAvailable()
+ /// Resets to a known state and returns the singleton.
+ private static StepCache FreshCache(int promotionThreshold)
{
var cache = StepCache.Instance;
- Assert.NotNull(cache);
+ cache.Clear();
+ cache.MissPromotionThreshold = promotionThreshold;
+ return cache;
+ }
+
+ /// Builds the plain chunk and hands it back for a test to inject state into.
+ private static StepChunk BuiltPlainChunk(StepCache cache, Map map)
+ {
+ cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+
+ var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4);
+ Assert.NotNull(chunk);
+ return chunk;
}
[Fact]
public void Clear_OnEmptyCache_LeavesStatsZero()
{
- var cache = StepCache.Instance;
- cache.Clear();
+ var stats = FreshCache(2).GetStats();
- var stats = cache.GetStats();
Assert.Equal(0, stats.ResidentChunks);
Assert.Equal(0L, stats.Hits);
Assert.Equal(0L, stats.BuildsTotal);
}
+ // ---- promotion gate ----
+
+ ///
+ /// A chunk nothing has shown sustained interest in must not be built. The caller reads
+ /// IsHit=false as "use the slow path", which is the cheaper trade for a pet crossing a chunk
+ /// once: BuildChunk costs far more than the handful of slow-path steps it would save.
+ ///
[Fact]
- public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough()
+ public void FirstTouch_DefersBuild_AndFallsThrough()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
+ var cache = FreshCache(promotionThreshold: 2);
+ var map = TestMap;
- var map = Map.Maps[1];
- Assert.NotNull(map);
-
- // First touch on a chunk that has no resident copy and no lazy reader behind it
- // must NOT eagerly build. Caller (BitmapAStarAlgorithm) interprets IsHit=false as
- // "use slow path" — pets/hireables passing briefly through a chunk avoid the
- // ~700µs BuildChunk cost they'd never amortize.
- var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
@@ -55,25 +71,20 @@ public class StepCacheLifecycleTests
}
[SkippableFact]
- public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
+ public void SecondTouchInsideWindow_PromotesAndServes()
{
TileDataRequirement.SkipIfMissing();
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
- var map = Map.Maps[1];
+ var cache = FreshCache(promotionThreshold: 2);
+ var map = TestMap;
- // First touch defers; second touch inside the promotion window builds + serves.
- // Pinned cell (1500, 1600, z=10): mask=0xC1
- var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.False(first.IsHit);
+ Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
- var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.True(second.IsHit);
- Assert.Equal(CacheHitKind.Miss_NotBuilt, second.HitKind);
- Assert.Equal((byte)0xC1, second.WalkMask);
- Assert.Equal((sbyte)10, second.WalkZ_N);
+ var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+ Assert.True(promoted.IsHit);
+ Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
+ Assert.Equal((byte)0xC1, promoted.WalkMask); // pinned: open plain, walkable N/NE/... per the bake
+ Assert.Equal((sbyte)10, promoted.WalkZ_N);
var stats = cache.GetStats();
Assert.Equal(1, stats.ResidentChunks);
@@ -81,58 +92,53 @@ public class StepCacheLifecycleTests
Assert.Equal(1L, stats.BuildsTotal);
Assert.Equal(1L, stats.FallthroughNotBuilt);
- // Third query of same cell → Hit (chunk now resident).
- var third = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.True(third.IsHit);
- Assert.Equal(CacheHitKind.Hit, third.HitKind);
- Assert.Equal((byte)0xC1, third.WalkMask);
+ // Now resident: a third query is a clean hit, not another miss.
+ var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+ Assert.Equal(CacheHitKind.Hit, hit.HitKind);
+ Assert.Equal((byte)0xC1, hit.WalkMask);
}
+ ///
+ /// Two touches spread wider than the window are not interest, they're coincidence — a chunk
+ /// someone glanced through, then an unrelated creature wandering past minutes later. The count
+ /// restarts rather than accumulating toward a build.
+ ///
[Fact]
- public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers()
+ public void SecondTouchAfterWindow_RestartsTheCount()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
- cache.MissPromotionWindowMs = 1; // 1ms window for testability
+ var cache = FreshCache(promotionThreshold: 2);
+ cache.MissPromotionWindowMs = 1;
- var map = Map.Maps[1];
+ var map = TestMap;
- var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.False(first.IsHit);
+ Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
+ Thread.Sleep(20); // outrun the window
- System.Threading.Thread.Sleep(20); // exceed the window
-
- // Second touch lands outside the window: tracker resets the count to 1, returns
- // Fallthrough_NotBuilt again — chunks the player just glanced through don't get
- // promoted just because they get re-touched minutes later by an unrelated NPC.
- var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
+ var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.False(second.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
Assert.Equal(0, cache.GetStats().ResidentChunks);
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
}
+ ///
+ /// The gate counts Finds, not probes. A single pathfind hits a chunk once per cell it expands
+ /// there, so counting probes would cross any threshold on the second cell and gate nothing at
+ /// all — the deferral would be dead code.
+ ///
[SkippableFact]
- public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
+ public void ManyProbesInOneFind_CountAsOneTouch()
{
TileDataRequirement.SkipIfMissing();
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
- var map = Map.Maps[1];
+ var cache = FreshCache(promotionThreshold: 2);
+ var map = TestMap;
- // Open a pathfind. Multiple TryGetMask calls inside this Find target the same chunk
- // (different cells). The promotion gate counts distinct Finds, not raw probes — these
- // calls must NOT increment the per-chunk counter, even though there are many of them.
- // Without this, A* expansion would trip the gate on the second cell expansion in any
- // visited chunk, defeating the whole point of deferred promotion.
cache.BeginFindGeneration();
for (var i = 0; i < 8; i++)
{
- // All cells are inside chunk (1500>>4, 1600>>4) = (93, 100).
- var lookup = cache.TryGetMask(map, 1500 + i, 1600, sourceZ: 10);
+ // Eight different cells, all inside the same chunk.
+ var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
}
@@ -141,111 +147,101 @@ public class StepCacheLifecycleTests
Assert.Equal(0L, cache.GetStats().BuildsTotal);
Assert.Equal(8L, cache.GetStats().FallthroughNotBuilt);
- // Begin a NEW Find — this is the second distinct touch under the per-Find gate.
- // The chunk now crosses the threshold and promotes.
+ // A second Find is the second distinct touch, and crosses the threshold.
cache.BeginFindGeneration();
- var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.True(promoted.IsHit);
+ var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
Assert.Equal(1, cache.GetStats().ResidentChunks);
Assert.Equal(1L, cache.GetStats().BuildsTotal);
}
+ /// Distinct Finds still don't promote if they straddle the window.
[Fact]
- public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter()
+ public void TwoFindsAcrossTheWindow_DoNotPromote()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
- cache.MissPromotionWindowMs = 1; // 1ms window for testability
+ var cache = FreshCache(promotionThreshold: 2);
+ cache.MissPromotionWindowMs = 1;
- var map = Map.Maps[1];
+ var map = TestMap;
cache.BeginFindGeneration();
- Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit);
+ Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
- System.Threading.Thread.Sleep(20); // exceed window
+ Thread.Sleep(20);
- // Second Find lands outside the window. Even though it's a distinct generation,
- // the elapsed-time check resets the counter to 1, so no promotion.
cache.BeginFindGeneration();
- var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- Assert.False(second.IsHit);
+ var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
Assert.Equal(0, cache.GetStats().ResidentChunks);
}
[Fact]
- public void TryGetMask_DistinctChunks_TrackedIndependently()
+ public void EachChunkIsTrackedSeparately()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 2;
+ var cache = FreshCache(promotionThreshold: 2);
+ var map = TestMap;
- var map = Map.Maps[1];
+ // One touch each, in two different chunks: neither reaches the threshold on its own.
+ Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
+ Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: 10).IsHit);
- // Two different chunks, one touch each — both must defer (each has its own counter).
- var chunkA = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
- var chunkB = cache.TryGetMask(map, 1600, 1700, sourceZ: 10); // different chunk
-
- Assert.False(chunkA.IsHit);
- Assert.False(chunkB.IsHit);
Assert.Equal(0, cache.GetStats().ResidentChunks);
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
}
+ // ---- fallthrough routes ----
+
[Fact]
- public void TryGetMask_OffMap_ReturnsFalseFallthrough()
+ public void OffMapCell_FallsThrough()
{
- var cache = StepCache.Instance;
- cache.Clear();
-
- var map = Map.Maps[1];
-
- var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0);
+ var lookup = FreshCache(2).TryGetMask(TestMap, -1, -1, sourceZ: 0);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
Assert.Equal((byte)0, lookup.WalkMask);
}
+ ///
+ /// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes
+ /// the edges TO its neighbours, so a wall one cell over has to block them.
+ ///
[SkippableFact]
- public void MultiCoveredCell_AndHalo_RouteToFallthrough()
+ public void MultiCoveredCell_AndItsHalo_FallThrough()
{
TileDataRequirement.SkipIfMissing();
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately
- var map = Map.Maps[1];
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
- // A cell far from any multi serves from the static cache.
- Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit);
+ // A cell nowhere near a multi still serves from the static cache.
+ Assert.True(cache.TryGetMask(map, PlainX, PlainY, 10).IsHit);
- // Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a
- // single-entry list is enough to mark the sector as multi-bearing — the fallthrough
- // decision never dereferences the multi, so no real BaseMulti instance is needed.
+ // Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the
+ // fallthrough never dereferences the multi, so a single null entry is enough — no real
+ // BaseMulti needed.
const int mx = 2000;
const int my = 2000;
var sx = mx >> 4;
var sy = my >> 4;
+
var sector = map.GetRealSector(sx, sy);
var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(multisField);
+
var original = multisField.GetValue(sector);
try
{
multisField.SetValue(sector, new List { null });
- // Cell inside the multi sector → routed to the live path.
+ // Inside the multi's sector.
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind);
- // Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo
- // (its mask would otherwise propose an edge into the multi sector).
- var boundaryX = sx * 16 - 1; // last tile of sector sx-1; halo (x+1) reaches into sx
- Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, boundaryX, my, 0).HitKind);
+ // Last cell of the neighbouring sector: its halo reaches across the boundary.
+ Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 1, my, 0).HitKind);
- // Two tiles out → interior of the multi-free sector, unaffected.
+ // One cell further out: halo no longer reaches, so the static cache handles it.
Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind);
Assert.True(cache.GetStats().FallthroughMulti >= 2);
@@ -256,95 +252,38 @@ public class StepCacheLifecycleTests
}
}
+ /// A query too far from the cell's baked Z gets no answer, rather than a wrong one.
[Fact]
- public void MultiZCell_RoutesToFallthrough()
+ public void SourceZFarFromBake_FallsThrough()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
- var map = Map.Maps[1];
+ cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+ var before = cache.GetStats().FallthroughSourceZMismatch;
- // Build a chunk first so it exists.
- cache.TryGetMask(map, 1500, 1600, 10);
-
- // Snapshot current FallthroughMultiZ in case (1500, 1600) is naturally multi-Z
- // in real tile data; we only assert the synthetic injection produces a delta of 1.
- var preInjectionFallthroughMultiZ = cache.GetStats().FallthroughMultiZ;
-
- // Inject a multi-Z bit via reflection on the resident chunk.
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- Assert.NotNull(chunksField);
- var chunks = (System.Collections.Generic.Dictionary)chunksField.GetValue(cache);
-
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- Assert.True(chunks.ContainsKey(key));
- var chunk = chunks[key];
-
- // Inject "this cell has strata but none match the query Z" — proves the cache
- // still falls through to slow path when no stratum can answer.
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
- var offsets = new ushort[StepChunk.CellsPerChunk];
- for (var i = 0; i < offsets.Length; i++)
- {
- offsets[i] = StepChunk.NoStrata;
- }
- offsets[cellIndex] = 0; // points to a 0-stratum-count entry → no match
- var data = new byte[] { 0 };
- chunk.SetStrata(offsets, data);
-
- var lookup = cache.TryGetMask(map, 1500, 1600, 10);
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100);
Assert.False(lookup.IsHit);
- Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
-
- var stats = cache.GetStats();
- Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
+ Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
+ Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
}
+ // ---- strata ----
+
[Fact]
- public void Tier4Strata_MatchingZ_ReturnsHitFromStratum()
+ public void Stratum_MatchingQueryZ_IsServed()
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1;
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
+ var chunk = BuiltPlainChunk(cache, map);
- var map = Map.Maps[1];
- cache.TryGetMask(map, 1500, 1600, 10);
+ var offsets = NoStrataOffsets();
+ offsets[CellIndex(PlainX, PlainY)] = 0;
+ chunk.SetStrata(offsets, OneStratum(zCenter: 42, walkMask: 0b0000_0011, walkZs: [42, 42]));
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField.GetValue(cache);
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- var chunk = chunks[key];
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 42);
- // Inject one stratum at zCenter=42, walkMask=0b00000011 (N + NE).
- // Query at sourceZ=42 must hit and return that stratum's data.
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
- var offsets = new ushort[StepChunk.CellsPerChunk];
- for (var i = 0; i < offsets.Length; i++)
- {
- offsets[i] = StepChunk.NoStrata;
- }
- offsets[cellIndex] = 0;
-
- var data = new byte[1 + StepChunk.StratumByteLength];
- data[0] = 1; // count
- data[1] = 42; // zCenter
- data[2] = 0b0000_0011; // walkMask (N | NE)
- data[3] = 0; // wetMask
- data[4] = 42; data[5] = 42; data[6] = 0; data[7] = 0;
- data[8] = 0; data[9] = 0; data[10] = 0; data[11] = 0;
- data[12] = 0; data[13] = 0; data[14] = 0; data[15] = 0;
- data[16] = 0; data[17] = 0; data[18] = 0; data[19] = 0;
- chunk.SetStrata(offsets, data);
-
- var lookup = cache.TryGetMask(map, 1500, 1600, 42);
Assert.True(lookup.IsHit);
Assert.Equal((byte)0b0000_0011, lookup.WalkMask);
Assert.Equal((sbyte)42, lookup.WalkZ_N);
@@ -352,195 +291,130 @@ public class StepCacheLifecycleTests
}
[Fact]
- public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch()
+ public void Stratum_QueryZOutOfReach_FallsThrough()
{
- // Sanity check: a chunk WITHOUT a swim layer falls through on source-Z mismatch
- // exactly like before. Validates we didn't accidentally serve garbage when the
- // chunk has no shore cells.
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1;
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
+ var chunk = BuiltPlainChunk(cache, map);
- var map = Map.Maps[1];
+ var offsets = NoStrataOffsets();
+ offsets[CellIndex(PlainX, PlainY)] = 0;
+ chunk.SetStrata(offsets, OneStratum(zCenter: 42));
- cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk
- var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
+ // 10 is more than StepHeight from the only stratum, so nothing can answer.
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
- // Same cell but query Z far from baked Z → source-Z guard fires.
- var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 100);
- Assert.False(lookup.IsHit);
- Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
- Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
- }
-
- [Fact]
- public void SwimLayer_InjectedMatchingZ_ReturnsHitFromSwimLayer()
- {
- // Inject a synthetic swim layer onto a resident chunk and verify a query at the
- // swim source Z routes through the swim-layer fallback, returning the swim mask.
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1;
-
- var map = Map.Maps[1];
- cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
-
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField!.GetValue(cache)!;
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- var chunk = chunks[key];
-
- chunk.AllocateSwimLayer();
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
- chunk.SwimSourceZ[cellIndex] = -5;
- chunk.SwimMask[cellIndex] = 0b0000_0011;
- chunk.SwimZN_Layer[cellIndex] = -5;
- chunk.SwimZNE_Layer[cellIndex] = -5;
- // Other directions stay 0 — Mask bits 0 and 1 cover N and NE.
-
- // Query at the chunk's primary SourceZ — primary path serves walk-layer data,
- // swim layer not consulted.
- var bakedSourceZ = chunk.SourceZ[cellIndex];
- var walkLookup = cache.TryGetMask(map, 1500, 1600, bakedSourceZ);
- Assert.True(walkLookup.IsHit);
- Assert.Equal(CacheHitKind.Hit, walkLookup.HitKind);
- // Walk-layer query produces walk-layer walkMask (whatever the bake found), NOT
- // the synthetic swim mask we injected.
-
- // Query at the swim source Z — primary source-Z guard fails (|−5 − bakedZ| > 2
- // assuming baked Z is land surface), swim-layer fallback serves with our mask.
- if (System.Math.Abs(-5 - bakedSourceZ) <= 2)
- {
- // Bake landed near water Z — adjust the test to a clearer swim Z.
- chunk.SwimSourceZ[cellIndex] = (sbyte)(bakedSourceZ - 20);
- }
- var swimLookup = cache.TryGetMask(map, 1500, 1600, chunk.SwimSourceZ[cellIndex]);
- Assert.True(swimLookup.IsHit);
- Assert.Equal(CacheHitKind.Hit, swimLookup.HitKind);
- Assert.Equal((byte)0, swimLookup.WalkMask); // walk = 0 at swim Z
- Assert.Equal(chunk.SwimMask[cellIndex], swimLookup.WetMask);
- Assert.Equal(chunk.SwimZN_Layer[cellIndex], swimLookup.SwimZ_N);
- Assert.Equal(chunk.SwimZNE_Layer[cellIndex], swimLookup.SwimZ_NE);
- }
-
- [Fact]
- public void SwimLayer_InjectedButCellHasNoSentinel_FallsThrough()
- {
- // Chunk has the swim layer (some other cell is shore), but THIS cell is inland
- // (SwimSourceZ = NoSwimLayerCell). Query at non-matching walk Z must fall through,
- // not erroneously match -128 against the query.
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1;
-
- var map = Map.Maps[1];
- cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
-
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField!.GetValue(cache)!;
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- var chunk = chunks[key];
-
- // Allocate layer but leave THIS cell at the sentinel.
- chunk.AllocateSwimLayer();
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
- Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cellIndex]);
-
- var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
- // Query at -128 (the sentinel value) — must NOT match. The guard short-circuits
- // on the sentinel before computing |sourceZ - SwimSourceZ|.
- var lookup = cache.TryGetMask(map, 1500, 1600, sbyte.MinValue);
- Assert.False(lookup.IsHit);
- Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
- Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
- }
-
- [Fact]
- public void Tier4Strata_NonMatchingZ_FallsThrough()
- {
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1;
-
- var map = Map.Maps[1];
- cache.TryGetMask(map, 1500, 1600, 10);
-
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField.GetValue(cache);
- var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
- var chunk = chunks[key];
-
- // Stratum at zCenter=42; query at sourceZ=10 (delta > StepHeight=2). Must fallthrough.
- var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
- var offsets = new ushort[StepChunk.CellsPerChunk];
- for (var i = 0; i < offsets.Length; i++)
- {
- offsets[i] = StepChunk.NoStrata;
- }
- offsets[cellIndex] = 0;
-
- var data = new byte[1 + StepChunk.StratumByteLength];
- data[0] = 1; data[1] = 42; // zCenter=42, all other bytes 0
- chunk.SetStrata(offsets, data);
-
- var lookup = cache.TryGetMask(map, 1500, 1600, 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
}
+ ///
+ /// A cell flagged multi-Z is served only from its strata. If it has none that match — here, a
+ /// zero-count record — it must fall through rather than quietly fall back to the main mask,
+ /// which was baked for a different surface.
+ ///
[Fact]
- public void LruCap_OverflowEvictsToCap()
+ public void MultiZCell_WithNoUsableStratum_FallsThrough()
{
- var cache = StepCache.Instance;
- cache.Clear();
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
+ var chunk = BuiltPlainChunk(cache, map);
+
+ var before = cache.GetStats().FallthroughMultiZ;
+
+ var offsets = NoStrataOffsets();
+ offsets[CellIndex(PlainX, PlainY)] = 0;
+ chunk.SetStrata(offsets, [0]); // a record declaring zero strata
+
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
+
+ Assert.False(lookup.IsHit);
+ Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
+ Assert.Equal(before + 1L, cache.GetStats().FallthroughMultiZ);
+ }
+
+ // ---- swim layer ----
+
+ [Fact]
+ public void SwimLayer_QueryAtWaterZ_IsServedFromTheLayer()
+ {
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
+ var chunk = BuiltPlainChunk(cache, map);
+
+ var cell = CellIndex(PlainX, PlainY);
+ var bakedZ = chunk.SourceZ[cell];
+
+ // Place the water surface well clear of the walk surface, so the primary source-Z guard is
+ // guaranteed to reject the swim query and hand it to the layer.
+ var swimZ = (sbyte)(bakedZ - 20);
+
+ chunk.AllocateSwimLayer();
+ chunk.SwimSourceZ[cell] = swimZ;
+ chunk.SwimMask[cell] = 0b0000_0011;
+ chunk.SwimZN_Layer[cell] = swimZ;
+ chunk.SwimZNE_Layer[cell] = swimZ;
+
+ // At the walk surface, the layer is not consulted at all.
+ Assert.Equal(CacheHitKind.Hit, cache.TryGetMask(map, PlainX, PlainY, bakedZ).HitKind);
+
+ var swim = cache.TryGetMask(map, PlainX, PlainY, swimZ);
+ Assert.True(swim.IsHit);
+ Assert.Equal((byte)0, swim.WalkMask); // a swimmer can't walk
+ Assert.Equal((byte)0b0000_0011, swim.WetMask);
+ Assert.Equal(swimZ, swim.SwimZ_N);
+ Assert.Equal(swimZ, swim.SwimZ_NE);
+ }
+
+ ///
+ /// An inland cell in a chunk that has a swim layer carries the NoSwimLayerCell sentinel. That
+ /// sentinel is sbyte.MinValue, so a query at sbyte.MinValue would match it exactly on a naive
+ /// distance check — the guard has to reject the sentinel before measuring anything.
+ ///
+ [Fact]
+ public void SwimLayer_SentinelCell_IsNeverMatched()
+ {
+ var cache = FreshCache(promotionThreshold: 1);
+ var map = TestMap;
+ var chunk = BuiltPlainChunk(cache, map);
+
+ chunk.AllocateSwimLayer(); // allocated for some other cell; this one stays at the sentinel
+
+ var cell = CellIndex(PlainX, PlainY);
+ Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cell]);
+
+ var before = cache.GetStats().FallthroughSourceZMismatch;
+ var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: sbyte.MinValue);
+
+ Assert.False(lookup.IsHit);
+ Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
+ Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
+ }
+
+ // ---- eviction ----
+
+ [Fact]
+ public void LruCap_EvictsDownToTheCap()
+ {
+ var cache = FreshCache(promotionThreshold: 1);
cache.MaxResidentChunks = 4;
- cache.MissPromotionThreshold = 1;
try
{
- var map = Map.Maps[1];
+ var map = TestMap;
- // Build 5 distinct chunks by querying different sectors.
+ // Five chunks into a cache that holds four.
for (var i = 0; i < 5; i++)
{
- var x = 1500 + i * 16;
- var y = 1600;
- cache.TryGetMask(map, x, y, 10);
- System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
+ cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10);
+ Thread.Sleep(2); // separate their LastTouchedTicks so LRU has something to order by
}
cache.EnforceLruCap();
Assert.Equal(4, cache.GetStats().ResidentChunks);
Assert.True(cache.GetStats().EvictionsByLruCap >= 1L);
-
- // _keysList must stay in lockstep with _chunks. A desync would silently
- // break sampled eviction (KeyNotFoundException on stale keys, or a stuck
- // resident set on missing keys).
- var chunksField = typeof(StepCache).GetField(
- "_chunks",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var keysListField = typeof(StepCache).GetField(
- "_keysList",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
- );
- var chunks = (System.Collections.Generic.Dictionary)chunksField.GetValue(cache);
- var keysList = (System.Collections.Generic.List)keysListField.GetValue(cache);
- Assert.Equal(chunks.Count, keysList.Count);
- foreach (var k in keysList)
- {
- Assert.True(chunks.ContainsKey(k), $"keysList holds key {k} not in _chunks");
- }
+ Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set");
}
finally
{
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs
index 0dc1eb12f..b38c15277 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs
@@ -1,125 +1,126 @@
using System;
+using System.Collections.Generic;
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
+using static Server.Tests.Pathfinding.PathingTestSupport;
namespace Server.Tests.Pathfinding;
+///
+/// The cache is only worth having if it answers exactly as MovementImpl would. These tests pin
+/// that down at each layer, so a failure says which one broke:
+///
+/// StepProbe vs MovementImpl — does the bake compute the right answer?
+/// StepCache vs StepProbe — does the chunk store and return it intact?
+/// StepCache vs MovementImpl — end to end, over the states A* actually visits.
+///
+/// The end-to-end test is the one that matters, but it can only tell you something is wrong; the
+/// two layer tests tell you where. It also measures coverage, not just correctness — a cache that
+/// falls through on everything agrees with the slow path perfectly and is worthless.
+///
[Collection("Sequential Pathfinding Tests")]
public class StepCacheParityTests
{
private readonly ITestOutputHelper _output;
- public StepCacheParityTests(ITestOutputHelper output)
- {
- _output = output;
- }
+ public StepCacheParityTests(ITestOutputHelper output) => _output = output;
- [Theory]
+ // ---- layer 1: the bake agrees with MovementImpl ----
+
+ ///
+ /// Sweeps a region and compares StepProbe's mask against MovementImpl for all 8 directions.
+ /// The probe stores raw masks and leaves the diagonal corner-cut to the caller, so the rule has
+ /// to be applied here before the two are comparable.
+ ///
+ [SkippableTheory]
[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)
+ public void ProbeMatchesSlowPath(string label, int xStart, int yStart, int size)
{
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately
+ TileDataRequirement.SkipIfMissing();
- var map = Map.Maps[1];
+ var map = TestMap;
Assert.NotNull(map);
+ var walker = new StaticWalker();
+ walker.MoveToWorld(new Point3D(xStart, yStart, 0), 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 surfZ = stackalloc sbyte[16];
+ var walkable = 0;
for (var x = xStart; x < xStart + size; x++)
{
for (var y = yStart; y < yStart + size; y++)
{
- if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0)
+ map.GetAverageZ(x, y, out _, out var avgZ, out _);
+ var sourceZ = (sbyte)avgZ;
+ var loc = new Point3D(x, y, sourceZ);
+
+ var probe = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
+
+ for (var d = 0; d < 8; d++)
{
- continue;
- }
- var sourceZ = surfZ[0];
+ var dir = (Direction)d;
+ samples++;
- var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
+ var slowOk = Movement.Movement.CheckMovement(walker, map, loc, dir, out var slowZ);
- var lookup = cache.TryGetMask(map, x, y, sourceZ);
+ // Creature corner-cut: a diagonal needs at least one flanking cardinal.
+ var probeOk = probe.IsWalkable(dir);
+ if (probeOk && (d & 1) == 1)
+ {
+ probeOk = probe.IsWalkable((Direction)((d - 1) & 7)) || probe.IsWalkable((Direction)((d + 1) & 7));
+ }
- samples++;
+ if (slowOk)
+ {
+ walkable++;
+ }
- 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})");
+ if (slowOk != probeOk)
+ {
+ disagreements++;
+ _output.WriteLine($"WALKABLE DIFF @ ({x},{y},{sourceZ}) dir={dir} slow={slowOk} probe={probeOk}");
+ }
+ else if (slowOk && slowZ != probe.GetWalkZ(dir))
+ {
+ disagreements++;
+ _output.WriteLine($"Z DIFF @ ({x},{y},{sourceZ}) dir={dir} slow={slowZ} probe={probe.GetWalkZ(dir)}");
+ }
}
}
}
- _output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ} wetCells={wetCells}");
+ walker.Delete();
+ _output.WriteLine($"[{label}] samples={samples} walkable={walkable} disagreements={disagreements}");
- // Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
+ // The dense region must contain a mix. All-walkable or all-blocked would mean the sweep
+ // agreed about nothing interesting.
if (label == "britain_inn_dense")
{
- Assert.True(samples - multiZ > 0, "expected real cache answers in dense region");
+ Assert.NotEqual(0, walkable);
+ Assert.NotEqual(samples, walkable);
}
Assert.Equal(0, disagreements);
}
///
- /// 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.
+ /// The swim bake must actually produce swim output. A probe that silently returned an empty
+ /// WetMask everywhere would pass every parity test above — walkers would still agree — while
+ /// leaving every swimming creature unable to move.
///
[SkippableFact]
- public void SwimBake_ProducesWetCells_OnKnownWaterRegion()
+ public void ProbeBakesWetCells_OnAKnownCoastline()
{
TileDataRequirement.SkipIfMissing();
- var map = Map.Maps[1];
+
+ var map = TestMap;
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.
+ // South Britain into Britain bay: 64x64 straddling the Atlantic shoreline.
const int xStart = 1430;
const int yStart = 1740;
const int size = 64;
@@ -131,15 +132,240 @@ public class StepCacheParityTests
{
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)
+ if (StepProbe.ComputeMaskAt(map, x, y, sourceZ).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");
+ _output.WriteLine($"south-britain coastline: wetCells={wetCells} of {size * size}");
+ Assert.True(wetCells > 0, $"swim bake produced zero wet cells across a {size}x{size} coastal region");
+ }
+
+ // ---- layer 2: the chunk returns what was baked ----
+
+ ///
+ /// Sweeps a region and compares what the cache serves against what StepProbe computes for the
+ /// same cell. The chunk is built from the probe, so any disagreement is a storage fault — a
+ /// bad cell index, a Z array crossed with another, a guard firing when it shouldn't.
+ ///
+ /// Queries run at the cell's standable surface Z, which is where the cache anchors. Querying at
+ /// the land average instead would trip the source-Z guard on raised terrain (a causeway, a
+ /// walkway) and report a fallthrough that is correct behaviour rather than a fault.
+ ///
+ [Theory]
+ [InlineData("britain_inn_dense", 1480, 1610, 32)]
+ [InlineData("trammel_open_plain", 1500, 1600, 32)]
+ [InlineData("britain_causeway", 1475, 1641, 32)]
+ public void CacheMatchesProbe(string label, int xStart, int yStart, int size)
+ {
+ var cache = StepCache.Instance;
+ cache.Clear();
+ cache.MissPromotionThreshold = 1; // build on first touch: every cell should get a real answer
+
+ var map = TestMap;
+ Assert.NotNull(map);
+
+ var disagreements = 0;
+ var samples = 0;
+ var multiZ = 0;
+
+ Span surfaces = 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, surfaces) == 0)
+ {
+ continue; // nothing for a walker to stand on here
+ }
+
+ var sourceZ = surfaces[0];
+ var probe = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
+ var cached = cache.TryGetMask(map, x, y, sourceZ);
+ samples++;
+
+ if (cached.HitKind == CacheHitKind.Fallthrough_MultiZ)
+ {
+ multiZ++;
+ continue;
+ }
+
+ Assert.True(cached.IsHit, $"cache returned {cached.HitKind} at ({x},{y})");
+
+ if (cached.WalkMask != probe.WalkMask)
+ {
+ disagreements++;
+ _output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{cached.WalkMask:X2} probe=0x{probe.WalkMask:X2}");
+ continue;
+ }
+
+ if (cached.WetMask != probe.WetMask)
+ {
+ disagreements++;
+ _output.WriteLine($"WET MASK DIFF @ ({x},{y}) cache=0x{cached.WetMask:X2} probe=0x{probe.WetMask:X2}");
+ continue;
+ }
+
+ for (var d = 0; d < 8; d++)
+ {
+ var dir = (Direction)d;
+ if (cached.GetWalkZ(dir) != probe.GetWalkZ(dir))
+ {
+ disagreements++;
+ _output.WriteLine(
+ $"Z DIFF @ ({x},{y}) dir={dir} cache={cached.GetWalkZ(dir)} probe={probe.GetWalkZ(dir)}"
+ );
+ break;
+ }
+ }
+ }
+ }
+
+ _output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}");
+
+ // Guard the sweep itself: if every cell fell through as multi-Z, the comparison above never
+ // actually ran and a zero disagreement count would mean nothing.
+ if (label == "britain_inn_dense")
+ {
+ Assert.True(samples - multiZ > 0, "no cell produced a real cache answer — the sweep proved nothing");
+ }
+
+ Assert.Equal(0, disagreements);
+ }
+
+ // ---- layer 3: end to end, over the states A* actually visits ----
+
+ ///
+ /// Flood-fills outward from a known-walkable tile using MovementImpl itself, and demands the
+ /// cache serve — and agree on — every state it reaches.
+ ///
+ /// The fill is what makes this meaningful. MovementImpl returns the Z a step lands on, so each
+ /// reached (x, y, z) is a genuine standing state at its true Z: exactly the set A* would query,
+ /// discovered rather than assumed. It follows stair treads up at their own Zs and climbs onto
+ /// upper floors, so a single seed covers a whole connected structure with no fixed-Z guess to
+ /// get wrong. That matters because the failure this test exists to catch — anchoring a cell at
+ /// the land beneath a walkway instead of the walkway itself — is invisible to any test that
+ /// queries at the land Z, and turned the Britain sewer into a ~98% cache miss.
+ ///
+ /// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a
+ /// raw diagonal bit legitimately differs from MovementImpl's diagonal answer.
+ ///
+ [Theory]
+ // Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one
+ // floods across a wide local area, so a handful covers thousands of states without a map walk.
+ [InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)] // static walkway over impassable land
+ [InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)] // stairs up to multi-Z upper floors
+ [InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // mixed buildings, stairs, raised floors
+ [InlineData("trammel_open_plain", 1500, 1600, 10, 2500)] // flat ground: catches clearance false-positives
+ public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates)
+ {
+ var cache = StepCache.Instance;
+ cache.Clear();
+ cache.MissPromotionThreshold = 1; // build on first touch: every reached state should be answered
+
+ var map = TestMap;
+ Assert.NotNull(map);
+
+ var walker = new StaticWalker();
+ walker.MoveToWorld(new Point3D(sx, sy, sz), map);
+
+ var startIsWalkable = false;
+ for (var d = 0; d < 8 && !startIsWalkable; d++)
+ {
+ startIsWalkable = Movement.Movement.CheckMovement(walker, map, new Point3D(sx, sy, sz), (Direction)d, out _);
+ }
+
+ Assert.True(startIsWalkable, $"[{label}] seed ({sx},{sy},{sz}) is not walkable — bad waypoint");
+
+ var visited = new HashSet<(int x, int y, int z)> { (sx, sy, sz) };
+ var frontier = new Queue<(int x, int y, int z)>();
+ frontier.Enqueue((sx, sy, sz));
+
+ var states = 0;
+ var fellThrough = 0;
+ var disagreements = 0;
+ const int maxLog = 12;
+
+ while (frontier.Count > 0)
+ {
+ var (x, y, z) = frontier.Dequeue();
+ var loc = new Point3D(x, y, z);
+ var cached = cache.TryGetMask(map, x, y, (sbyte)z);
+ states++;
+
+ if (!cached.IsHit)
+ {
+ if (fellThrough < maxLog)
+ {
+ _output.WriteLine($"FELL THROUGH @ ({x},{y},{z}) hitKind={cached.HitKind}");
+ }
+
+ fellThrough++;
+ }
+
+ for (var d = 0; d < 8; d++)
+ {
+ var dir = (Direction)d;
+ var slowOk = Movement.Movement.CheckMovement(walker, map, loc, dir, out var slowZ);
+
+ if (slowOk)
+ {
+ var nx = x;
+ var ny = y;
+ Movement.Movement.Offset(dir, ref nx, ref ny);
+
+ if (visited.Count < maxStates && visited.Add((nx, ny, slowZ)))
+ {
+ frontier.Enqueue((nx, ny, slowZ));
+ }
+ }
+
+ if ((d & 1) != 0 || !cached.IsHit)
+ {
+ continue;
+ }
+
+ if (cached.IsWalkable(dir) != slowOk)
+ {
+ if (disagreements < maxLog)
+ {
+ _output.WriteLine($"WALK DIFF @ ({x},{y},{z}) dir={dir} slow={slowOk} cache={cached.IsWalkable(dir)}");
+ }
+
+ disagreements++;
+ }
+ else if (slowOk && slowZ != cached.GetWalkZ(dir))
+ {
+ if (disagreements < maxLog)
+ {
+ _output.WriteLine($"Z DIFF @ ({x},{y},{z}) dir={dir} slow={slowZ} cache={cached.GetWalkZ(dir)}");
+ }
+
+ disagreements++;
+ }
+ }
+ }
+
+ walker.Delete();
+
+ var fallthroughPct = states == 0 ? 0 : 100.0 * fellThrough / states;
+ _output.WriteLine($"[{label}] states={states} fellThrough={fellThrough} ({fallthroughPct:F2}%) disagreements={disagreements}");
+
+ Assert.True(states > 50, $"[{label}] flood-fill stalled at {states} states — bad waypoint");
+
+ // Where the cache answers at all, it must be right.
+ Assert.Equal(0, disagreements);
+
+ // And it must answer nearly everywhere. A small residual is legitimate: a walkable surface
+ // directly beneath a bridge or stair ramp falls through because the bake's clearance check
+ // is deliberately conservative there. An anchor regression is not small — the pre-fix sewer
+ // fell through on ~98% — so a 1% ceiling separates the two comfortably.
+ Assert.True(
+ fallthroughPct < 1.0,
+ $"[{label}] cache fell through on {fallthroughPct:F2}% ({fellThrough}/{states}) of reachable states"
+ );
}
}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs
deleted file mode 100644
index 8f05291ae..000000000
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs
+++ /dev/null
@@ -1,183 +0,0 @@
-using System.Collections.Generic;
-using Server.Engines.Pathing.Cache;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Server.Tests.Pathfinding;
-
-///
-/// Parity coverage for "walkable static surface above a land tile" terrain — sewers,
-/// dungeon walkways, bridges, raised foundations, and stacked building floors.
-///
-/// The original parity tests only queried at the LAND-anchored standing Z and skipped
-/// multi-Z fallthroughs, so they never noticed that a query at the REAL walk Z — the static
-/// surface a creature actually stands on — returns
-/// , because the baker anchored
-/// SourceZ at the land average instead of the walkway. In the Britain sewer that's a ~98%
-/// cache miss on a known walk-path (confirmed via [PathDiag).
-///
-/// Method: flood-fill outward from a known-walkable start using
-/// — the slow path the cache mirrors. Each
-/// reached (x, y, z) is a genuine standing state at its TRUE Z (CheckMovement returns the
-/// destination Z it lands on), exactly the set of states A* would query. For every reached
-/// state the cache must serve a Hit and agree with the slow path. This naturally follows
-/// ramped stairs (each tread at its own Z) and climbs to upper floors, so one start covers
-/// the whole connected structure — no fragile fixed-Z assumption.
-///
-/// A bare test world has no spawned items/mobiles, so CheckMovement reduces to static
-/// walkability (no door/dynamic interference). Parity restricted to cardinal directions:
-/// the cache stores raw masks and applies the diagonal corner-cut at query time, so a raw
-/// diagonal bit legitimately differs from CheckMovement's diagonal result.
-///
-/// EXPECTED: RED before the standable-surface bake (reached states fall through at their
-/// true Z); GREEN after.
-///
-[Collection("Sequential Pathfinding Tests")]
-public class StepCacheStaticSurfaceParityTests
-{
- private readonly ITestOutputHelper _output;
-
- public StepCacheStaticSurfaceParityTests(ITestOutputHelper output)
- {
- _output = output;
- }
-
- [Theory]
- // label, start X, Y, Z (a real in-game walkable tile), max states to explore. Seeds are
- // chosen to span the terrain classes the standable-surface bake must get right; the
- // flood-fill spreads from each across a wide local area, so a handful of seeds exercises
- // thousands of distinct (cell, Z) states without an exhaustive whole-map walk.
- // sewer — static walkway @ z=5 over impassable land; covers dungeon walkways + bridges.
- // inn — stair foot @ z=10; climbs the stairs onto the 1st & 2nd floors (multi-Z).
- // plain — open Britain ground; guards against clearance false-positives on flat land.
- // town — Britain cobblestones near the inn; mixed buildings, stairs, raised floors.
- [InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)]
- [InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)]
- [InlineData("trammel_open_plain", 1500, 1600, 10, 2500)]
- [InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // plain ground: guards against clearance false-positives
- public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates)
- {
- var cache = StepCache.Instance;
- cache.Clear();
- cache.MissPromotionThreshold = 1; // eager build — expect the cache to answer every state
-
- var map = Map.Maps[1];
- Assert.NotNull(map);
-
- var stub = new ParityStubMobile();
- stub.MoveToWorld(new Point3D(sx, sy, sz), map);
-
- // Sanity: the start must itself be a walkable standing state via the slow path.
- var startWalkable = false;
- for (var d = 0; d < 8; d++)
- {
- if (Movement.Movement.CheckMovement(stub, map, new Point3D(sx, sy, sz), (Direction)d, out _))
- {
- startWalkable = true;
- break;
- }
- }
- Assert.True(startWalkable, $"[{label}] start ({sx},{sy},{sz}) is not walkable per the slow path — bad waypoint");
-
- var visited = new HashSet<(int x, int y, int z)>();
- var queue = new Queue<(int x, int y, int z)>();
- visited.Add((sx, sy, sz));
- queue.Enqueue((sx, sy, sz));
-
- var states = 0;
- var fellThrough = 0;
- var disagreements = 0;
- const int maxLog = 12;
-
- while (queue.Count > 0)
- {
- var (x, y, z) = queue.Dequeue();
- states++;
-
- var loc = new Point3D(x, y, z);
- var lookup = cache.TryGetMask(map, x, y, (sbyte)z);
-
- if (!lookup.IsHit)
- {
- if (fellThrough < maxLog)
- {
- _output.WriteLine($"FELL THROUGH @ ({x},{y},{z}) hitKind={lookup.HitKind}");
- }
- fellThrough++;
- }
-
- for (var d = 0; d < 8; d++)
- {
- var dir = (Direction)d;
- var slowOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var nz);
-
- // Expand the frontier through every legal move (incl. diagonals).
- if (slowOk)
- {
- var nx = x;
- var ny = y;
- Movement.Movement.Offset(dir, ref nx, ref ny);
- var next = (nx, ny, (int)nz);
- if (visited.Count < maxStates && visited.Add(next))
- {
- queue.Enqueue(next);
- }
- }
-
- // Parity on cardinals only (diagonals carry the query-time corner-cut rule).
- if ((d & 1) == 0 && lookup.IsHit)
- {
- var cacheOk = lookup.IsWalkable(dir);
- if (cacheOk != slowOk)
- {
- if (disagreements < maxLog)
- {
- _output.WriteLine($"WALK DIFF @ ({x},{y},{z}) dir={dir} slow={slowOk} cache={cacheOk}");
- }
- disagreements++;
- }
- else if (slowOk && nz != lookup.GetWalkZ(dir))
- {
- if (disagreements < maxLog)
- {
- _output.WriteLine($"Z DIFF @ ({x},{y},{z}) dir={dir} slow={nz} cache={lookup.GetWalkZ(dir)}");
- }
- disagreements++;
- }
- }
- }
- }
-
- stub.Delete();
-
- var fallthroughPct = states == 0 ? 0 : 100.0 * fellThrough / states;
- _output.WriteLine($"[{label}] states={states} fellThrough={fellThrough} ({fallthroughPct:F2}%) disagreements={disagreements}");
-
- Assert.True(states > 50, $"[{label}] only explored {states} states — flood-fill stalled, bad waypoint");
-
- // Correctness is strict: where the cache DOES answer, it must agree with the slow path.
- Assert.Equal(0, disagreements);
-
- // Coverage: nearly every reachable state should be cache-served. A small residual is
- // expected and acceptable — a walkable surface sitting directly under a bridge/stair
- // ramp falls through to the slow path (correct, just uncached) because the bake's
- // clearance check is intentionally conservative there. A real anchor regression shows
- // up as a large fraction (the pre-fix sewer was ~98%), which this still catches.
- Assert.True(
- fallthroughPct < 1.0,
- $"[{label}] cache fell through on {fallthroughPct:F2}% ({fellThrough}/{states}) of reachable states — coverage regression"
- );
- }
-
- ///
- /// Default static walker: inherits straight from Mobile so MovementImpl sees no
- /// BaseCreature flags (CanSwim/CanFly false, bc==null). Mirrors the existing parity stub.
- ///
- private class ParityStubMobile : Mobile
- {
- public ParityStubMobile()
- {
- Body = 0xC9;
- }
- }
-}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs
deleted file mode 100644
index 429d60ca7..000000000
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-using Server.Engines.Pathing.Cache;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Server.Tests.Pathfinding;
-
-[Collection("Sequential Pathfinding Tests")]
-public class StaticWalkabilityParityTests
-{
- private readonly ITestOutputHelper _output;
-
- public StaticWalkabilityParityTests(ITestOutputHelper output)
- {
- _output = output;
- }
-
- [SkippableTheory]
- [InlineData("britain_inn_dense", 1480, 1610, 32)]
- [InlineData("trammel_open_plain", 1500, 1600, 32)]
- public void BakerMatchesCheckMovement(string label, int xStart, int yStart, int size)
- {
- TileDataRequirement.SkipIfMissing();
- var map = Map.Maps[1];
- Assert.NotNull(map);
-
- var stub = new ParityStubMobile();
- stub.MoveToWorld(new Point3D(xStart, yStart, 0), map);
-
- var disagreements = 0;
- var samples = 0;
- var oldWalkable = 0;
- var newWalkable = 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)avgZ;
-
- var loc = new Point3D(x, y, sourceZ);
-
- var bakerResult = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
-
- for (var d = 0; d < 8; d++)
- {
- var dir = (Direction)d;
- samples++;
-
- var oldOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var oldZ);
-
- // Apply creature diagonal corner-cut rule at query time:
- // diagonal walkable iff raw-diagonal AND (left-partner OR right-partner).
- // (Raw masks are correct per spec; baker omits diagonal logic per design.)
- var newOk = bakerResult.IsWalkable(dir);
- if (newOk && (d & 1) == 1)
- {
- var leftPartner = (Direction)((d - 1) & 7);
- var rightPartner = (Direction)((d + 1) & 7);
- if (!bakerResult.IsWalkable(leftPartner) && !bakerResult.IsWalkable(rightPartner))
- {
- newOk = false;
- }
- }
-
- var newZ = bakerResult.GetWalkZ(dir);
-
- if (oldOk)
- {
- oldWalkable++;
- }
- if (newOk)
- {
- newWalkable++;
- }
-
- if (oldOk != newOk)
- {
- disagreements++;
- _output.WriteLine(
- $"DISAGREE walkable @ ({x},{y},{sourceZ}) dir={dir}: " +
- $"old={oldOk} new={newOk}"
- );
- }
- else if (oldOk && oldZ != newZ)
- {
- disagreements++;
- _output.WriteLine(
- $"DISAGREE destZ @ ({x},{y},{sourceZ}) dir={dir}: " +
- $"old={oldZ} new={newZ}"
- );
- }
- }
- }
- }
-
- stub.Delete();
-
- _output.WriteLine(
- $"[{label}] Samples: {samples}, Disagreements: {disagreements}, " +
- $"OldWalkable: {oldWalkable}, NewWalkable: {newWalkable}"
- );
-
- // Non-vacuity guard for the variety case: at least one region must show some
- // blocked directions. The open_plain region is allowed to be all-walkable.
- if (label == "britain_inn_dense")
- {
- Assert.NotEqual(0, oldWalkable);
- Assert.NotEqual(samples, oldWalkable);
- }
-
- Assert.Equal(0, disagreements);
- }
-
- ///
- /// Minimal Mobile stub for parity testing. Inherits directly from Mobile so that
- /// MovementImpl sees no BaseCreature-specific flags (CanSwim=false, CanFly=false,
- /// bc==null → BaseCreature branches skipped) giving us the default static walker baseline.
- ///
- private class ParityStubMobile : Mobile
- {
- public ParityStubMobile()
- {
- Body = 0xC9; // arbitrary horse body
- }
- }
-}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
index 0f9117960..a1b368cb0 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
@@ -349,6 +349,36 @@ public sealed class StepCache
internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) =>
_lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY);
+ ///
+ /// Diagnostic: the resident chunk covering (chunkX, chunkY), or null if it isn't resident.
+ /// Exposed so tests can inspect and inject chunk state without reflecting into the internals.
+ ///
+ internal StepChunk GetResidentChunk(int mapId, int chunkX, int chunkY) =>
+ _chunks.GetValueOrDefault(EncodeKey(mapId, chunkX, chunkY));
+
+ ///
+ /// Diagnostic: whether the eviction key list still mirrors the resident set exactly. A desync
+ /// breaks sampled eviction — a stale key throws on lookup, a missing one pins a chunk resident
+ /// forever — and it is invisible from the outside, so tests assert on it directly.
+ ///
+ internal bool ResidentIndexInSync()
+ {
+ if (_keysList.Count != _chunks.Count)
+ {
+ return false;
+ }
+
+ foreach (var key in _keysList)
+ {
+ if (!_chunks.ContainsKey(key))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/// Closes every open .swb reader, releasing the underlying file streams.
public void CloseLazyReaders()
{