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() {