From 64643dbc7e96d0839b68afba37c15bc9c9e934a6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:48:57 -0700 Subject: [PATCH] test(pathing): consolidate the parity and lifecycle tests Three files tested "parity" and none of them said which parity. They were in fact three different layers, and the seams between them were the useful part, so they are now one StepCacheParityTests that names them: ProbeMatchesSlowPath StepProbe vs MovementImpl - is the bake right? CacheMatchesProbe StepCache vs StepProbe - is it stored and returned intact? CacheServesReachableWalkStates StepCache vs MovementImpl - end to end, over the states A* visits Merging them removed a duplicated stub Mobile, duplicated region seeds, and a filename/class mismatch (StepProbeParityTests.cs declared StaticWalkabilityParityTests). SwimBake_ProducesWetCells moved with it: it lived in the cache parity file but never touched the cache - it is a probe test. The layering is load-bearing, not cosmetic. Offsetting the cache's cell index by one fails 7 of the 10 parity cases, and the 3 that stay green are exactly the ones that don't touch the cache - so a failure localizes the fault instead of just reporting one. Reflection: tests reached into StepCache._chunks via GetField in 9 places to inspect or inject chunk state, each rebuilding the key and the cell-index arithmetic by hand. StepCache now exposes GetResidentChunk and ResidentIndexInSync alongside the existing internal test hooks (LazyReaderHasChunk, CurrentFindGeneration), and the shared arithmetic moved to PathingTestSupport. All 9 reflection blocks are gone. StepCacheLifecycleTests is regrouped by what it actually covers - promotion gate, fallthrough routes, strata, swim layer, eviction - with the Tier4* names dropped (same internal jargon already removed from the comments) and the repeated offsets/strata byte-array construction replaced by builders. Dropped Singleton_IsAvailable: it asserted that an inline-initialized static property was not null, which it cannot be. Test count 123 -> 122, entirely from that one removal; every parity case and every behavioral assertion is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Engines/Pathing/PathingTestSupport.cs | 69 +++ .../Engines/Pathing/StepCacheFileTests.cs | 13 +- .../Pathing/StepCacheLifecycleTests.cs | 584 +++++++----------- .../Engines/Pathing/StepCacheParityTests.cs | 378 +++++++++--- .../StepCacheStaticSurfaceParityTests.cs | 183 ------ .../Engines/Pathing/StepProbeParityTests.cs | 127 ---- .../Engines/Pathing/Cache/StepCache.cs | 30 + 7 files changed, 634 insertions(+), 750 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs 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() {