diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 04cb41831..f92fab0c9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -58,6 +58,7 @@ public class BitmapAStarAlgorithmTests public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy) { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; Assert.NotNull(map); @@ -249,6 +250,7 @@ public class BitmapAStarAlgorithmTests public void NonGmPlayer_UsesCache_WithStrictDiagonalRule() { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; Assert.NotNull(map); @@ -277,6 +279,7 @@ public class BitmapAStarAlgorithmTests public void DoorCreature_UsesCache_NotSlowPath() { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; var stub = new DoorOpenerStub(World.NewMobile); @@ -303,6 +306,7 @@ public class BitmapAStarAlgorithmTests public void ObstacleCreature_UsesCache_NotSlowPath() { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; var stub = new ObstacleClimberStub(World.NewMobile); @@ -325,6 +329,74 @@ public class BitmapAStarAlgorithmTests "CanMoveOverObstacles creature should use the cache (movables are dynamic items)"); } + // --------------------------------------------------------------------------------- + // Promotion-gate integration tests. These exercise BitmapAStarAlgorithm.Find() + // end-to-end against the live StepCache to prove the per-Find generation gate + // actually defers BuildChunk on a single pathfind. They duplicate behavior that + // unit tests cover at the cache layer; the value is end-to-end verification that + // the bench-relevant scenario (single Find on cleared cache) skips builds entirely. + // TODO: REMOVE these two tests once PR-6's gate is proven stable in production BDN. + // --------------------------------------------------------------------------------- + + [Fact] + public void Find_SinglePathfindOnClearedCache_DoesNotBuildAnyChunk() + { + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 2; + + var map = Map.Maps[1]; + var stub = new DefaultWalkerStub(); + map.GetAverageZ(1500, 1600, out _, out var startZ, out _); + var start = new Point3D(1500, 1600, (sbyte)startZ); + var goal = new Point3D(1498, 1598, (sbyte)startZ); + stub.MoveToWorld(start, map); + + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + + var stats = StepCache.Instance.GetStats(); + stub.Delete(); + + Assert.NotNull(result); + Assert.Equal(0L, stats.BuildsTotal); + Assert.True(stats.FallthroughNotBuilt > 0L, + $"expected fallthrough on every chunk touched once; got 0 (residents={stats.ResidentChunks})"); + _output.WriteLine( + $"single-Find gate: builds={stats.BuildsTotal} fallthrough_not_built={stats.FallthroughNotBuilt}" + ); + } + + [Fact] + public void Find_TwoPathfindsOverlappingChunks_PromoteToBuildOnSecondFind() + { + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 2; + + var map = Map.Maps[1]; + var stub = new DefaultWalkerStub(); + map.GetAverageZ(1500, 1600, out _, out var startZ, out _); + var start = new Point3D(1500, 1600, (sbyte)startZ); + var goal = new Point3D(1498, 1598, (sbyte)startZ); + stub.MoveToWorld(start, map); + + // Find #1: first time anyone touches these chunks. Gate defers; no builds. + BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var afterFirst = StepCache.Instance.GetStats(); + Assert.Equal(0L, afterFirst.BuildsTotal); + + // Find #2: same path; chunks now hit their second distinct Find inside the window. + // Gate promotes — at least one BuildChunk fires. + BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var afterSecond = StepCache.Instance.GetStats(); + + stub.Delete(); + + Assert.True(afterSecond.BuildsTotal > 0L, + $"second Find through overlapping chunks must promote (got {afterSecond.BuildsTotal} builds)"); + _output.WriteLine( + $"two-Find gate: first builds={afterFirst.BuildsTotal} second builds={afterSecond.BuildsTotal}" + ); + } + /// /// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache /// fast path on every expansion. diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index e2f288fa5..d8e9aed01 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using Server.Engines.Pathing.Cache; using Xunit; @@ -17,15 +18,30 @@ public class StepCacheFileTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 1; // eager build to populate chunks for save var map = Map.Maps[1]; Assert.NotNull(map); - // Populate three distinct chunks by querying different sectors. + // Populate three distinct chunks by querying different sectors. Query at each cell's + // real standable surface Z (where the cache anchors) so first-touch yields a clean + // hit rather than an off-surface fallthrough. var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) }; - foreach (var (x, y) in sourceQueries) + var standZ = new sbyte[sourceQueries.Length]; { - cache.TryGetMask(map, x, y, sourceZ: 10); + Span surfZ = stackalloc sbyte[16]; + for (var i = 0; i < sourceQueries.Length; i++) + { + var (qx, qy) = sourceQueries[i]; + var n = StepProbe.ComputeStandableSurfaceZs(map, qx, qy, surfZ); + Assert.True(n > 0, $"({qx},{qy}) has no standable surface — bad test cell"); + standZ[i] = surfZ[0]; + } + } + for (var i = 0; i < sourceQueries.Length; i++) + { + var (x, y) = sourceQueries[i]; + cache.TryGetMask(map, x, y, standZ[i]); } Assert.Equal(3, cache.GetStats().ResidentChunks); @@ -36,7 +52,7 @@ public class StepCacheFileTests for (var i = 0; i < sourceQueries.Length; i++) { var (x, y) = sourceQueries[i]; - expected[i] = cache.TryGetMask(map, x, y, sourceZ: 10); + expected[i] = cache.TryGetMask(map, x, y, standZ[i]); } var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb"); @@ -66,7 +82,7 @@ public class StepCacheFileTests for (var i = 0; i < sourceQueries.Length; i++) { var (x, y) = sourceQueries[i]; - var lookup = cache.TryGetMask(map, x, y, sourceZ: 10); + var lookup = cache.TryGetMask(map, x, y, standZ[i]); Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind); Assert.Equal(expected[i].WalkMask, lookup.WalkMask); Assert.Equal(expected[i].WetMask, lookup.WetMask); @@ -171,6 +187,7 @@ public class StepCacheFileTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 1; // eager build to populate chunks for save var map = Map.Maps[1]; Assert.NotNull(map); @@ -210,4 +227,175 @@ public class StepCacheFileTests } } } + + /// + /// First-touch on a chunk that the lazy reader can satisfy must NOT route through the + /// miss tracker — file-loaded chunks represent an explicit prior decision to keep + /// them warm. This guards the deployment shape where an admin ships .swb files and + /// expects the very first NPC pathfind in any region to use cache (not slow path). + /// + /// + /// A chunk with an injected swim layer must serialize and deserialize via the lazy + /// reader without losing the layer. Validates v3 file format end-to-end: swim layer + /// fields survive Save → Clear → LazyOpen → first-touch query. + /// + [Fact] + public void SwimLayer_RoundTrips_ThroughLazyReader() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 1; + + var map = Map.Maps[1]; + Assert.NotNull(map); + + // Build a chunk and inject a synthetic swim layer onto cell (1500, 1600). + 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] = -7; + chunk.SwimMask[cellIndex] = 0b0000_1111; + chunk.SwimZN_Layer[cellIndex] = -7; + chunk.SwimZNE_Layer[cellIndex] = -7; + chunk.SwimZE_Layer[cellIndex] = -7; + chunk.SwimZSE_Layer[cellIndex] = -7; + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{System.Guid.NewGuid():N}.swb"); + try + { + Assert.Equal(1, cache.SaveToFile(path, map.MapID)); + + cache.Clear(); + cache.MissPromotionThreshold = 1; + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + + // Pull the chunk back via a query at swim Z; the layer must hit and serve our + // injected mask. Walk-Z query of the same cell should still hit the walk + // layer with whatever the bake produced. + var swim = cache.TryGetMask(map, 1500, 1600, sourceZ: -7); + Assert.True(swim.IsHit); + Assert.Equal((byte)0, swim.WalkMask); + Assert.Equal((byte)0b0000_1111, swim.WetMask); + Assert.Equal((sbyte)-7, swim.SwimZ_N); + Assert.Equal((sbyte)-7, swim.SwimZ_E); + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + /// + /// PreloadOnLazyOpen=true must materialize every chunk in the .swb file into the + /// resident set immediately, eliminating first-touch file-read latency. Counterpart + /// to which proves the + /// default lazy behavior. + /// + [Fact] + public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 1; + + var map = Map.Maps[1]; + Assert.NotNull(map); + + var coords = new (int, int)[] + { + (1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600) + }; + foreach (var (x, y) in coords) + { + cache.TryGetMask(map, x, y, sourceZ: 10); + } + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{System.Guid.NewGuid():N}.swb"); + try + { + Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID)); + + cache.Clear(); + cache.PreloadOnLazyOpen = true; + try + { + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + + // Every chunk should be resident — no further queries needed. + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker + + // Subsequent query is a clean Hit, not a Miss_NotBuilt. + var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); + Assert.Equal(CacheHitKind.Hit, lookup.HitKind); + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); + } + finally + { + cache.PreloadOnLazyOpen = false; + } + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public void LazyReaderHit_BypassesMissTrackerOnFirstTouch() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 1; // eager build for save phase + + var map = Map.Maps[1]; + + // Build + save one chunk. + cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{System.Guid.NewGuid():N}.swb"); + try + { + Assert.Equal(1, cache.SaveToFile(path, map.MapID)); + + // Reset to a fresh state with the file open as a lazy reader and the deferred + // promotion threshold restored to 2. + cache.Clear(); + cache.MissPromotionThreshold = 2; + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + + // First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the + // chunk and serves it before the miss tracker is consulted. + var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + Assert.True(lookup.IsHit); + Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind); + Assert.Equal(1, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().FallthroughNotBuilt); + Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 4eeb5d80b..de4b2fa51 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -26,38 +26,167 @@ public class StepCacheLifecycleTests } [Fact] - public void TryGetMask_FirstQuery_BuildsChunkAndReturnsBakerOutput() + public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough() { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 2; var map = Map.Maps[1]; Assert.NotNull(map); - // Pinned cell (1500, 1600, z=10): mask=0xC1 + // 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); - Assert.True(lookup.IsHit); - Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind); - Assert.Equal((byte)0xC1, lookup.WalkMask); - Assert.Equal((sbyte)10, lookup.WalkZ_N); - Assert.Equal((sbyte)10, lookup.WalkZ_W); - Assert.Equal((sbyte)10, lookup.WalkZ_NW); + Assert.False(lookup.IsHit); + Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); + + var stats = cache.GetStats(); + Assert.Equal(0, stats.ResidentChunks); + Assert.Equal(0L, stats.BuildsTotal); + Assert.Equal(0L, stats.MissesNotBuilt); + Assert.Equal(1L, stats.FallthroughNotBuilt); + } + + [Fact] + public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 2; + + var map = Map.Maps[1]; + + // 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); + + 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 stats = cache.GetStats(); Assert.Equal(1, stats.ResidentChunks); Assert.Equal(1L, stats.MissesNotBuilt); Assert.Equal(1L, stats.BuildsTotal); + Assert.Equal(1L, stats.FallthroughNotBuilt); - // Second query of same cell → Hit - var lookup2 = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); - Assert.True(lookup2.IsHit); - Assert.Equal(CacheHitKind.Hit, lookup2.HitKind); - Assert.Equal((byte)0xC1, lookup2.WalkMask); + // 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); + } - var stats2 = cache.GetStats(); - Assert.Equal(1, stats2.ResidentChunks); - Assert.Equal(1L, stats2.Hits); + [Fact] + public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 2; + cache.MissPromotionWindowMs = 1; // 1ms window for testability + + var map = Map.Maps[1]; + + var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + Assert.False(first.IsHit); + + 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); + Assert.False(second.IsHit); + Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); + Assert.Equal(0, cache.GetStats().ResidentChunks); + Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt); + } + + [Fact] + public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 2; + + var map = Map.Maps[1]; + + // 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); + Assert.False(lookup.IsHit); + Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); + } + + Assert.Equal(0, cache.GetStats().ResidentChunks); + 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. + cache.BeginFindGeneration(); + var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + Assert.True(promoted.IsHit); + Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind); + Assert.Equal(1, cache.GetStats().ResidentChunks); + Assert.Equal(1L, cache.GetStats().BuildsTotal); + } + + [Fact] + public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 2; + cache.MissPromotionWindowMs = 1; // 1ms window for testability + + var map = Map.Maps[1]; + + cache.BeginFindGeneration(); + Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit); + + System.Threading.Thread.Sleep(20); // exceed window + + // 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); + Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); + Assert.Equal(0, cache.GetStats().ResidentChunks); + } + + [Fact] + public void TryGetMask_DistinctChunks_TrackedIndependently() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MissPromotionThreshold = 2; + + var map = Map.Maps[1]; + + // 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); } [Fact] @@ -80,11 +209,13 @@ public class StepCacheLifecycleTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 2; var map = Map.Maps[1]; var sector = map.GetRealSector(1500 >> 4, 1600 >> 4); - // First query: builds chunk, snapshots current MultisVersion. + // First touch defers (Fallthrough_NotBuilt); second touch promotes and builds. + Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); // Bump _multisVersion via reflection. @@ -96,18 +227,18 @@ public class StepCacheLifecycleTests var current = (int)versionField.GetValue(sector); versionField.SetValue(sector, current + 1); - // Second query: detects version mismatch, rebuilds. + // Third query: detects version mismatch, rebuilds. Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind); var stats = cache.GetStats(); Assert.Equal(1L, stats.MissesDirtyRebuild); Assert.Equal(2L, stats.BuildsTotal); - // Mutual-exclusivity invariant: every successful TryGetMask hits exactly one - // outcome counter. Two queries above both returned true (the test cell is not - // multi-Z and not off-map), so the three outcome counters must sum to 2 and the - // fallthrough counters must be zero. + // Mutual-exclusivity invariant: hits + miss-builds + dirty-rebuilds = served-result count. + // Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild), + // and the first was a Fallthrough_NotBuilt (no build, slow-path signal). Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits); + Assert.Equal(1L, stats.FallthroughNotBuilt); Assert.Equal(0L, stats.FallthroughMultiZ); Assert.Equal(0L, stats.FallthroughOffMap); } @@ -117,6 +248,7 @@ public class StepCacheLifecycleTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests var map = Map.Maps[1]; @@ -165,6 +297,7 @@ public class StepCacheLifecycleTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 1; var map = Map.Maps[1]; cache.TryGetMask(map, 1500, 1600, 10); @@ -205,11 +338,122 @@ public class StepCacheLifecycleTests Assert.Equal((sbyte)42, lookup.WalkZ_NE); } + [Fact] + public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch() + { + // 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 map = Map.Maps[1]; + + cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk + var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch; + + // 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); @@ -246,6 +490,7 @@ public class StepCacheLifecycleTests var cache = StepCache.Instance; cache.Clear(); cache.MaxResidentChunks = 4; + cache.MissPromotionThreshold = 1; try { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index 8d7de8b6b..b00d30f8f 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Xunit; using Xunit.Abstractions; @@ -22,6 +23,7 @@ public class StepCacheParityTests { var cache = StepCache.Instance; cache.Clear(); + cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately var map = Map.Maps[1]; Assert.NotNull(map); @@ -31,16 +33,22 @@ public class StepCacheParityTests 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]; + 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 _); - - // The cache bakes from the slow path's standing Z (the Z a creature actually - // stands at on this cell). Query with the same Z so the source-Z guard - // doesn't false-positive on every paver cell. - var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ); + if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0) + { + continue; + } + var sourceZ = surfZ[0]; var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs new file mode 100644 index 000000000..8f05291ae --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheStaticSurfaceParityTests.cs @@ -0,0 +1,183 @@ +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/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index af4a91dc0..46d2bdf9e 100644 --- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -108,6 +108,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm return null; } + // Mark a new Find generation so the StepCache promotion gate counts THIS pathfind + // as one touch per chunk regardless of how many times the expansion frontier + // probes a given chunk. Without this, A* hits each visited chunk dozens of times + // and trips the threshold immediately. + StepCache.Instance.BeginFindGeneration(); + Server.Engines.Pathing.PathfindRecorder.RecordIfEnabled(m, map, start, goal); _currentMobileNeedsSlowPath = RequiresSlowPath(m); @@ -494,6 +500,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm /// /// Per-direction loop for a single source cell. /// Runs on cache fallthrough or when is set. + /// CheckMovement validates land/statics/items via MovementImpl; dynamic mobile blocking + /// is layered on top because MovementImpl doesn't iterate same-cell mobiles. /// private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals) { @@ -510,15 +518,23 @@ public class BitmapAStarAlgorithm : PathAlgorithm continue; } - if (CalcMoves.CheckMovement(m, map, p3D, (Direction)i, out var z)) + if (!CalcMoves.CheckMovement(m, map, p3D, (Direction)i, out var z)) { - var idx = GetIndex(x + _xOffset, y + _yOffset, z); + continue; + } - if (idx >= 0 && idx < NodeCount) - { - _nodes[idx].z = z; - vals[count++] = idx; - } + var absX = x + _xOffset; + var absY = y + _yOffset; + if (IsBlockedByDynamic(m, map, absX, absY, z)) + { + continue; + } + + var idx = GetIndex(absX, absY, z); + if (idx >= 0 && idx < NodeCount) + { + _nodes[idx].z = z; + vals[count++] = idx; } } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs index 24847235c..6418ca1c1 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs @@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache; /// /// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive /// the slow-path fallthrough decision in callers. Ordering is load-bearing: -/// values 0-2 are hits, values 3-5 are fallthroughs (see StepMask.IsHit). +/// values 0-2 are hits, values 3-6 are fallthroughs (see StepMask.IsHit). /// public enum CacheHitKind : byte { @@ -13,4 +13,5 @@ public enum CacheHitKind : byte Fallthrough_MultiZ = 3, // cell has multiple walkable surfaces; caller must use slow path Fallthrough_OffMap = 4, // out of bounds Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge + Fallthrough_NotBuilt = 6, // first-touch miss without lazy file hit; build deferred until second touch } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs index 040b13810..10c78014d 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs @@ -12,6 +12,7 @@ public readonly struct CacheStats( long fallthroughMultiZ, long fallthroughOffMap, long fallthroughSourceZMismatch, + long fallthroughNotBuilt, long evictionsByLruCap, long buildsTotal ) @@ -23,6 +24,7 @@ public readonly struct CacheStats( public readonly long FallthroughMultiZ = fallthroughMultiZ; public readonly long FallthroughOffMap = fallthroughOffMap; public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; + public readonly long FallthroughNotBuilt = fallthroughNotBuilt; public readonly long EvictionsByLruCap = evictionsByLruCap; public readonly long BuildsTotal = buildsTotal; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 7f5481151..1c2af3e7e 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -24,6 +24,29 @@ public sealed class StepCache // with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction. private readonly List _keysList = new(); + // Second-touch promotion tracker. A chunk's first miss within the window returns + // Fallthrough_NotBuilt; the caller takes the slow path. The Nth DISTINCT-FIND miss + // within the same window (where N = MissPromotionThreshold) promotes to BuildChunk + + // serve. We count distinct Find generations, not raw TryGetMask calls — A* expansion + // hits each visited chunk many times in one Find, so per-call counting hits threshold + // immediately and defeats the gate. Per-Find counting filters single-Find pass-throughs + // (pet following a moving player) while still promoting chunks revisited by multiple + // Finds (NPC patrolling fixed territory). + private readonly Dictionary _chunkMissTracker = new(); + private const int MaxMissTrackerEntries = 4096; + + // Generation counter incremented by BeginFindGeneration(). Sentinel 0 = "no Find started + // yet"; treated as a distinct generation per call so callers that bypass BeginFindGeneration + // (single-call tests, BakeMap with threshold=1) get sensible behavior. + private uint _findGeneration; + + private struct ChunkMissState + { + public byte MissCount; + public uint LastMissTickStamp; + public uint LastFindGeneration; + } + // Telemetry counters private long _hits; private long _missesNotBuilt; @@ -31,6 +54,7 @@ public sealed class StepCache private long _fallthroughMultiZ; private long _fallthroughOffMap; private long _fallthroughSourceZMismatch; + private long _fallthroughNotBuilt; private long _evictionsByLruCap; private long _buildsTotal; @@ -39,6 +63,43 @@ public sealed class StepCache /// Hard cap on resident chunk count. Default 8192. Override for tests / ops. public int MaxResidentChunks { get; set; } = 8192; + /// + /// When true, immediately materializes every chunk in + /// the .swb file into the resident set, paying the file-load cost upfront at boot + /// instead of on first query. Trades ~25–50ms boot time per fully-baked map for zero + /// first-touch latency in production. Default off — preserves the lazy memory profile. + /// + public bool PreloadOnLazyOpen { get; set; } + + /// + /// Number of misses on the same chunk within + /// required to trigger a build. 1 = eager (legacy behavior). 2 = second-touch (default, + /// filters single-touch pass-throughs). + /// + public int MissPromotionThreshold { get; set; } = 2; + + /// + /// Window over which misses against the same chunk accumulate toward promotion. + /// Misses spaced wider than this restart the count. Default 30s. + /// + public uint MissPromotionWindowMs { get; set; } = 30_000; + + /// + /// Marks the start of a new pathfind. The promotion gate counts distinct Find + /// generations per chunk, not raw TryGetMask calls — call this once at the top of + /// each pathfind invocation so multiple cell expansions within one Find don't trip + /// the threshold. Wraps at uint.MaxValue back to 1 (0 is reserved as the + /// "no Find started yet" sentinel). + /// + public void BeginFindGeneration() + { + unchecked { _findGeneration++; } + if (_findGeneration == 0) { _findGeneration = 1; } + } + + /// Test-only: read the current Find generation. + internal uint CurrentFindGeneration => _findGeneration; + /// /// Pack (mapId, chunkX, chunkY) into a single long key. /// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16]. @@ -54,6 +115,7 @@ public sealed class StepCache fallthroughMultiZ: _fallthroughMultiZ, fallthroughOffMap: _fallthroughOffMap, fallthroughSourceZMismatch: _fallthroughSourceZMismatch, + fallthroughNotBuilt: _fallthroughNotBuilt, evictionsByLruCap: _evictionsByLruCap, buildsTotal: _buildsTotal ); @@ -79,12 +141,15 @@ public sealed class StepCache { _chunks.Clear(); _keysList.Clear(); + _chunkMissTracker.Clear(); + _findGeneration = 0; _hits = 0; _missesNotBuilt = 0; _missesDirtyRebuild = 0; _fallthroughMultiZ = 0; _fallthroughOffMap = 0; _fallthroughSourceZMismatch = 0; + _fallthroughNotBuilt = 0; _evictionsByLruCap = 0; _buildsTotal = 0; } @@ -123,18 +188,30 @@ public sealed class StepCache return 0; } - var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize; - var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize; - - for (var cy = 0; cy < chunkRows; cy++) + // BakeMap is an explicit decision to populate every chunk; the promotion gate + // would otherwise return Fallthrough_NotBuilt for every chunk (each touched once) + // and the bake would write an empty file. Force eager build for the duration. + var prevThreshold = MissPromotionThreshold; + MissPromotionThreshold = 1; + try { - for (var cx = 0; cx < chunkCols; cx++) + var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize; + var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize; + + for (var cy = 0; cy < chunkRows; cy++) { - // Any sourceZ works — the chunk is built on first access regardless of - // whether the query returns Hit or Fallthrough_SourceZMismatch. - TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0); + for (var cx = 0; cx < chunkCols; cx++) + { + // Any sourceZ works — the chunk is built on first access regardless of + // whether the query returns Hit or Fallthrough_SourceZMismatch. + TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0); + } } } + finally + { + MissPromotionThreshold = prevThreshold; + } return SaveToFile(path, mapId); } @@ -213,9 +290,54 @@ public sealed class StepCache "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", path, reader.IndexedChunkCount, mapId ); + + if (PreloadOnLazyOpen) + { + PreloadFromLazyReader(mapId, reader); + } + return true; } + /// + /// Materializes every chunk in into the resident set. + /// Called from when + /// is set. Skips chunks whose live doesn't + /// match the file's snapshot — those will rebake on first query. + /// + private void PreloadFromLazyReader(int mapId, StepCacheFile.LazyReader reader) + { + var map = Map.Maps[mapId]; + if (map == null || map == Map.Internal) + { + return; + } + + var loaded = 0; + foreach (var (chunkX, chunkY) in reader.EnumerateChunkCoords()) + { + var key = EncodeKey(mapId, chunkX, chunkY); + if (_chunks.ContainsKey(key)) + { + continue; + } + + var chunk = TryLoadFromLazyReader(map, chunkX, chunkY); + if (chunk == null) + { + continue; + } + + _chunks[key] = chunk; + _keysList.Add(key); + loaded++; + } + + logger.Information( + "StepCache: preloaded {Loaded} chunks from .swb for map {MapId}", loaded, mapId + ); + } + /// /// Number of .swb readers currently open. Mostly for tests / telemetry. /// @@ -324,10 +446,27 @@ public sealed class StepCache var hitKindResult = CacheHitKind.Hit; if (!_chunks.TryGetValue(key, out var chunk)) { - chunk = ResolveMissingChunk(map, chunkX, chunkY); - _chunks[key] = chunk; - _keysList.Add(key); - hitKindResult = CacheHitKind.Miss_NotBuilt; + // Try lazy file first — file-loaded chunks bypass the miss tracker because + // the .swb represents an explicit prior decision to keep this chunk warm. + chunk = TryLoadFromLazyReader(map, chunkX, chunkY); + if (chunk != null) + { + _chunks[key] = chunk; + _keysList.Add(key); + hitKindResult = CacheHitKind.Miss_NotBuilt; + } + else if (ShouldPromoteAfterMiss(key)) + { + chunk = BuildChunk(map, chunkX, chunkY); + _chunks[key] = chunk; + _keysList.Add(key); + hitKindResult = CacheHitKind.Miss_NotBuilt; + } + else + { + _fallthroughNotBuilt++; + return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_NotBuilt); + } } else { @@ -369,6 +508,37 @@ public sealed class StepCache // because tile reachability shifts at step-height boundaries. if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight) { + // Swim-layer fallback for shore cells: if the chunk has the layer and this + // cell's water-surface Z is within StepHeight of the query, serve from the + // swim layer (computed at swim-perspective Z). Walker queries on shore cells + // fall through this branch via their Z mismatch with SwimSourceZ. + if (chunk.HasSwimLayer) + { + var swimSrc = chunk.SwimSourceZ[cellIndex]; + if (swimSrc != StepChunk.NoSwimLayerCell && Math.Abs(sourceZ - swimSrc) <= StepHeight) + { + switch (hitKindResult) + { + case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; } + case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; } + case CacheHitKind.Hit: { _hits++; break; } + } + return new StepMask( + 0, chunk.SwimMask[cellIndex], + 0, 0, 0, 0, 0, 0, 0, 0, + chunk.SwimZN_Layer[cellIndex], + chunk.SwimZNE_Layer[cellIndex], + chunk.SwimZE_Layer[cellIndex], + chunk.SwimZSE_Layer[cellIndex], + chunk.SwimZS_Layer[cellIndex], + chunk.SwimZSW_Layer[cellIndex], + chunk.SwimZW_Layer[cellIndex], + chunk.SwimZNW_Layer[cellIndex], + hitKindResult + ); + } + } + _fallthroughSourceZMismatch++; return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_SourceZMismatch); } @@ -404,28 +574,128 @@ public sealed class StepCache } /// - /// Chunk-miss resolution: try the lazy file reader for this map first; if there's no - /// file or no record at this (chunkX, chunkY), fall back to the runtime baker. The - /// file path validates each loaded chunk's MultisVersion against the live sector — a - /// stale snapshot triggers a rebuild rather than serving a wrong answer. + /// Returns a fresh StepChunk loaded from the lazy file reader, or null if there's no + /// open reader for the map / no record at (chunkX, chunkY) / the loaded snapshot is + /// stale relative to the live sector's . A null + /// return means the caller should consult the miss tracker; a stale return means + /// "rebuild, the .swb is out of date and a future SaveToFile will overwrite it." /// - private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) + private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY) { - if (_lazyReaders.TryGetValue(map.MapID, out var reader)) + if (!_lazyReaders.TryGetValue(map.MapID, out var reader)) { - var loaded = reader.TryReadChunk(chunkX, chunkY); - if (loaded != null) + return null; + } + var loaded = reader.TryReadChunk(chunkX, chunkY); + if (loaded == null) + { + return null; + } + var sector = map.GetRealSector(chunkX, chunkY); + return loaded.BuiltMultisVersion == sector.MultisVersion ? loaded : null; + } + + /// + /// Records a miss for and decides whether to build now + /// or defer to slow path. Counts distinct Find generations, not raw calls — multiple + /// TryGetMask calls within one Find (BeginFindGeneration scope) count as one touch. + /// Returns true when DISTINCT-FIND misses within the window cross + /// ; caller should run BuildChunk and serve. + /// Returns false otherwise; caller should return Fallthrough_NotBuilt so the algorithm + /// uses the slow path. Generation 0 ("no Find active") treats every call as distinct, + /// preserving legacy semantics for callers that don't call BeginFindGeneration. + /// + private bool ShouldPromoteAfterMiss(long chunkKey) + { + // Environment.TickCount, not Core.TickCount: tests/bench fixtures may not advance + // the game-loop tick. The promotion window is wall-clock anyway. + var now = (uint)Environment.TickCount; + var gen = _findGeneration; + + if (_chunkMissTracker.TryGetValue(chunkKey, out var state)) + { + // Same Find generation as the last touch — A* expansion is probing this chunk + // multiple times in one pathfind. Don't increment; the gate counts distinct + // Finds. Skip when gen==0 (no Find started) so legacy single-call tests still + // see incrementing behavior. + if (gen != 0 && state.LastFindGeneration == gen) { - var sector = map.GetRealSector(chunkX, chunkY); - if (loaded.BuiltMultisVersion == sector.MultisVersion) + return false; + } + + var elapsed = now - state.LastMissTickStamp; + if (elapsed > MissPromotionWindowMs) + { + _chunkMissTracker[chunkKey] = new ChunkMissState { - return loaded; - } - // Snapshot is stale (multis added/removed since the bake). Fall through - // to the runtime baker; a future SaveToFile will overwrite the entry. + MissCount = 1, + LastMissTickStamp = now, + LastFindGeneration = gen + }; + return false; + } + + var newCount = (byte)Math.Min(state.MissCount + 1, byte.MaxValue); + if (newCount >= MissPromotionThreshold) + { + _chunkMissTracker.Remove(chunkKey); + return true; + } + + _chunkMissTracker[chunkKey] = new ChunkMissState + { + MissCount = newCount, + LastMissTickStamp = now, + LastFindGeneration = gen + }; + return false; + } + + if (MissPromotionThreshold <= 1) + { + return true; + } + + if (_chunkMissTracker.Count >= MaxMissTrackerEntries) + { + PruneMissTracker(now); + } + + _chunkMissTracker[chunkKey] = new ChunkMissState + { + MissCount = 1, + LastMissTickStamp = now, + LastFindGeneration = gen + }; + return false; + } + + /// + /// Drop tracker entries older than the promotion window. Called when the tracker hits + /// its capacity ceiling. If the prune doesn't reclaim anything (every entry is in + /// window), the cap is enforced by clearing — the worst case is a few extra + /// Fallthrough_NotBuilt returns until traffic re-establishes hot chunks. + /// + private void PruneMissTracker(uint now) + { + var window = MissPromotionWindowMs; + var beforeCount = _chunkMissTracker.Count; + var toRemove = new List(); + foreach (var kvp in _chunkMissTracker) + { + if (now - kvp.Value.LastMissTickStamp > window) + { + toRemove.Add(kvp.Key); } } - return BuildChunk(map, chunkX, chunkY); + foreach (var k in toRemove) + { + _chunkMissTracker.Remove(k); + } + if (_chunkMissTracker.Count == beforeCount) + { + _chunkMissTracker.Clear(); + } } private StepChunk BuildChunk(Map map, int chunkX, int chunkY) @@ -440,7 +710,12 @@ public sealed class StepCache // Tier 4 strata accumulator. Lazily allocated when the first multi-Z cell // appears; otherwise the chunk has zero strata overhead. ushort[] strataOffsetByCell = null; - System.Collections.Generic.List strataData = null; + List strataData = null; + + // Reused per cell: the standable surface Zs (walkway / bridge / floor levels). 16 is + // generous — clearance forces standable surfaces >= PersonHeight apart, so a 256-tall + // Z range admits at most ~16 anyway. + Span surfaceZs = stackalloc sbyte[16]; for (var dy = 0; dy < ChunkSize; dy++) { @@ -452,10 +727,18 @@ public sealed class StepCache map.GetAverageZ(x, y, out _, out var avgZ, out _); - // Bake from the slow path's "standing Z" (the surface Z a creature actually - // stands at, not the ground avg). A* tracks newZ as standing Z, so SourceZ - // must match for the source-Z guard not to over-fire. - var standingZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ); + // Anchor the cell at the surface a creature actually STANDS on, not the land + // average. For plain overworld that's the land; for static-over-land terrain + // (sewer/dungeon walkways, bridges, stair treads, raised foundations, upper + // building floors) it's the walkable static surface — which the old + // ComputeStandingZ(avgZ) anchor missed, producing source-Z fallthroughs (or, + // within the StepHeight tolerance band on stairs, a wrong vertical-neighbor + // answer baked at the adjacent tread). ComputeStandableSurfaceZs returns the + // standable surfaces ascending; the lowest is the primary anchor and A* tracks + // newZ to match it. Cells with no standable walk surface (deep water, solid + // rock) fall back to the land avg so the swim layer / wetMask still bake. + var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaceZs); + var standingZ = surfaceCount > 0 ? surfaceZs[0] : (sbyte)Math.Clamp(avgZ, sbyte.MinValue, sbyte.MaxValue); var result = StepProbe.ComputeMaskAt(map, x, y, standingZ); @@ -479,39 +762,69 @@ public sealed class StepCache chunk.SwimZW[cell] = result.SwimZ_W; chunk.SwimZNW[cell] = result.SwimZ_NW; - // Multi-Z handling: if the cell has 2+ reachable surfaces, compute its - // Tier 4 strata so future queries can be answered without falling through - // to the slow path. ComputeStrataAt returns null for single-Z cells. - if (CountReachableSurfaces(map, x, y, standingZ) > 1) + // Shore-cell handling: if the cell has BOTH a walk surface (standing Z) + // AND a water surface (Wet land tile or wet static) at a Z separated by + // > StepHeight, populate the swim layer at swim-perspective Z. Only when + // ComputeMaskAt produces a non-zero swim mask — bridges/docks/piers with + // insufficient vertical clearance for a swim creature's body envelope + // produce wetMask=0 (StaticsBlockAt rejects them), and we skip those cells + // rather than baking a stratum that always answers "no movement." The + // sentinel NoSwimLayerCell stays in SwimSourceZ for skipped cells; the + // chunk only sets HasSwimLayer when at least one cell got a usable entry. + var swimZRaw = StepProbe.ComputeSwimStandingZ(map, x, y); + if (swimZRaw != int.MinValue && Math.Abs(swimZRaw - standingZ) > StepHeight) { - var strata = StepProbe.ComputeStrataAt(map, x, y); - if (strata != null) + var swimSrc = (sbyte)Math.Clamp(swimZRaw, sbyte.MinValue + 1, sbyte.MaxValue); + var swimResult = StepProbe.ComputeMaskAt(map, x, y, swimSrc); + if (swimResult.WetMask != 0) { - if (strataOffsetByCell == null) + if (chunk.SwimSourceZ == null) { - strataOffsetByCell = new ushort[StepChunk.CellsPerChunk]; - for (var i = 0; i < strataOffsetByCell.Length; i++) - { - strataOffsetByCell[i] = StepChunk.NoStrata; - } - strataData = new System.Collections.Generic.List(256); + chunk.AllocateSwimLayer(); } + chunk.SwimSourceZ[cell] = swimSrc; + chunk.SwimMask[cell] = swimResult.WetMask; + chunk.SwimZN_Layer[cell] = swimResult.SwimZ_N; + chunk.SwimZNE_Layer[cell] = swimResult.SwimZ_NE; + chunk.SwimZE_Layer[cell] = swimResult.SwimZ_E; + chunk.SwimZSE_Layer[cell] = swimResult.SwimZ_SE; + chunk.SwimZS_Layer[cell] = swimResult.SwimZ_S; + chunk.SwimZSW_Layer[cell] = swimResult.SwimZ_SW; + chunk.SwimZW_Layer[cell] = swimResult.SwimZ_W; + chunk.SwimZNW_Layer[cell] = swimResult.SwimZ_NW; + } + } - // Cap at 65,535 byte offsets — well above realistic per-chunk - // strata volume. If we ever blow past this we'd silently truncate; - // assert as a defensive guard. - if (strataData.Count > ushort.MaxValue - StepChunk.StratumByteLength * 8) + // Stacked walkable surfaces at one cell (ground + 1st + 2nd building floors, + // a bridge over a walkable path, etc.): bake a stratum per standable surface + // so a query at any floor's Z hits. The primary (lowest) surface is also in + // the main mask above, but multi-Z cells are served exclusively from strata, + // so every standable surface — including the primary — must appear here. + // Single-surface cells (the common case, incl. stair treads and sewer + // walkways) skip this entirely and stay on the fast single-mask path. + if (surfaceCount >= 2) + { + if (strataOffsetByCell == null) + { + strataOffsetByCell = new ushort[StepChunk.CellsPerChunk]; + for (var i = 0; i < strataOffsetByCell.Length; i++) { - // Should never happen for sane tile data; bail to fallthrough. + strataOffsetByCell[i] = StepChunk.NoStrata; } - else + strataData = new List(256); + } + + // Cap at 65,535 byte offsets — well above realistic per-chunk strata + // volume. If we ever blow past this we silently leave the cell single-Z + // (it keeps the land-anchored main mask and falls through off-surface). + if (strataData.Count <= ushort.MaxValue - StepChunk.StratumByteLength * 8) + { + strataOffsetByCell[cell] = (ushort)strataData.Count; + strataData.Add((byte)surfaceCount); + for (var i = 0; i < surfaceCount; i++) { - strataOffsetByCell[cell] = (ushort)strataData.Count; - strataData.Add((byte)strata.Length); - for (var s = 0; s < strata.Length; s++) - { - AppendStratumBytes(strataData, strata[s]); - } + var sz = surfaceZs[i]; + AppendStratumBytes(strataData, new StepProbe.ComputedStratum(sz, StepProbe.ComputeMaskAt(map, x, y, sz))); } } } @@ -593,9 +906,7 @@ public sealed class StepCache return false; } - private static void AppendStratumBytes( - System.Collections.Generic.List dst, in StepProbe.ComputedStratum s - ) + private static void AppendStratumBytes(List dst, in StepProbe.ComputedStratum s) { dst.Add((byte)s.ZCenter); dst.Add(s.Mask.WalkMask); @@ -620,54 +931,4 @@ public sealed class StepCache private const int PersonHeight = 16; private const int StepHeight = 2; - - /// - /// Counts walkable surfaces actually reachable from a creature standing at sourceZ. - /// Mirrors .CheckStaticStep so cells flagged multi-Z - /// here are exactly those where the baker would have multiple candidate destinations. - /// Reachable when: surface and !impassable; stepTop ≥ itemTop; vertical overlap with - /// the creature's PersonHeight envelope. - /// - internal static int CountReachableSurfaces(Map map, int x, int y, sbyte sourceZ) - { - var startTop = sourceZ + PersonHeight; - var stepTop = startTop + StepHeight; - var count = 0; - - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) - { - var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; - if (!data.Surface || data.Impassable) - { - continue; - } - - var itemZ = tile.Z; - var itemTop = data.Bridge ? itemZ : itemZ + data.Height; - - if (stepTop < itemTop) - { - continue; - } - - if (sourceZ + PersonHeight > itemZ && itemZ + data.Height > sourceZ) - { - count++; - } - } - - // Land surface check — same shape, but use GetAverageZ for the land's effective top. - var landTile = map.Tiles.GetLandTile(x, y); - var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; - if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0) - { - map.GetAverageZ(x, y, out var landZ, out _, out var landTop); - if (stepTop >= landZ && sourceZ + PersonHeight > landZ && landTop > sourceZ) - { - count++; - } - } - - return count; - } } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index 6272d3834..cddfd4367 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -14,11 +14,11 @@ namespace Server.Engines.Pathing.Cache; /// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless /// of file size. /// -/// File layout v2 (little-endian, BufferWriter / BufferReader convention): +/// File layout v3 (little-endian, BufferWriter / BufferReader convention): /// /// Header (48 bytes): /// u32 Magic = 0x42575300 ('SWB\0') -/// u32 Version = current FormatVersion (2) +/// u32 Version = current FormatVersion (3) /// u32 MapId /// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the /// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul. @@ -35,11 +35,16 @@ namespace Server.Engines.Pathing.Cache; /// u16 ChunkY /// u32 BuiltMultisVersion /// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata trailer follows +/// u8 HasSwimLayer 0 = no shore cells (no swim trailer); 1 = swim trailer follows /// byte WalkMask[256] /// byte WetMask[256] /// sbyte SourceZ[256] /// sbyte WalkZN[256]..WalkZNW[256] (8 arrays in N,NE,E,SE,S,SW,W,NW order) -/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order) +/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order; baked at WALK source-Z) +/// // Swim layer trailer — only when HasSwimLayer == 1 (chunks containing shore cells): +/// sbyte SwimSourceZ[256] (NoSwimLayerCell sentinel = sbyte.MinValue) +/// byte SwimMask[256] (per-cell swim mask baked at SwimSourceZ) +/// sbyte SwimZN_Layer[256]..SwimZNW_Layer[256] (8 arrays, dest-Z at swim perspective) /// // Strata trailer — only when HasStrata == 1: /// u16 StrataOffsetByCell[256] (NoStrata sentinel = 0xFFFF) /// u32 StrataDataLength @@ -63,15 +68,20 @@ namespace Server.Engines.Pathing.Cache; internal static class StepCacheFile { public const uint Magic = 0x42575300; // 'SWB\0' - public const uint FormatVersion = 2; + public const uint FormatVersion = 4; /// /// Lowest format version this binary can load. Files below this version are treated as /// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with - /// the current FormatVersion. Bumped to 2 when Tier 4 multi-Z strata landed; v1 had no - /// strata data and is incompatible with the strata-aware lookup path. + /// the current FormatVersion. Bumped to 3 when the swim layer landed (v2 had no swim + /// layer). Bumped to 4 when the baker switched to clearance-aware standable-surface + /// strata: v3 bakes anchored every cell at the land average and so missed walkable + /// static-over-land surfaces (sewer/dungeon walkways, bridges, upper building floors), + /// producing ~98% source-Z fallthroughs on those routes. The on-disk layout is + /// unchanged; only the strata population differs, so the bump exists purely to force a + /// one-time re-bake of stale v3 files on first boot under the new binary. /// - public const uint MinSupportedVersion = 2; + public const uint MinSupportedVersion = 4; private const int HeaderSize = sizeof(uint) // Magic @@ -87,15 +97,19 @@ internal static class StepCacheFile // single bulk read per chunk without consulting the next offset. private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint); - /// Fixed-size portion of a chunk record (everything except the optional strata trailer). + /// Fixed-size portion of a chunk record (everything except the optional strata + swim trailers). private const int BytesPerChunkBase = - sizeof(ushort) + sizeof(ushort) + sizeof(uint) + sizeof(byte) + sizeof(ushort) + sizeof(ushort) + sizeof(uint) + + sizeof(byte) + sizeof(byte) // HasStrata + HasSwimLayer + StepChunk.CellsPerChunk // WalkMask + StepChunk.CellsPerChunk // WetMask + StepChunk.CellsPerChunk // SourceZ + 8 * StepChunk.CellsPerChunk // WalkZ[8] + 8 * StepChunk.CellsPerChunk; // SwimZ[8] + /// Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays. + private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk; + /// Strata trailer overhead when present: 256×u16 offset table + u32 data length. private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint); @@ -201,9 +215,10 @@ internal static class StepCacheFile { Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); - // Initial estimate: base record + a modest strata budget per chunk. BufferWriter - // grows on overflow, so under-estimating just causes a few realloc/copy cycles - // during the bake — not a correctness issue. + // Initial estimate: base record + a modest strata budget per chunk. Coastline + // chunks add another ~2.5 KB (swim layer) but they're a small fraction of any + // map; the writer grows on overflow so under-estimating just causes a few + // realloc/copy cycles during the bake — not a correctness issue. var capacity = HeaderSize + (BytesPerChunkBase + 256) * (int)chunkCount + IndexEntryBytes * (int)chunkCount; @@ -355,7 +370,9 @@ internal static class StepCacheFile var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization(); var strataData = chunk.GetStrataDataForSerialization(); var hasStrata = strataOffsetByCell != null; + var hasSwimLayer = chunk.HasSwimLayer; w.Write((byte)(hasStrata ? 1 : 0)); + w.Write((byte)(hasSwimLayer ? 1 : 0)); w.Write(chunk.WalkMask); w.Write(chunk.WetMask); @@ -379,6 +396,20 @@ internal static class StepCacheFile WriteSBytes(w, chunk.SwimZW); WriteSBytes(w, chunk.SwimZNW); + if (hasSwimLayer) + { + WriteSBytes(w, chunk.SwimSourceZ); + w.Write(chunk.SwimMask); + WriteSBytes(w, chunk.SwimZN_Layer); + WriteSBytes(w, chunk.SwimZNE_Layer); + WriteSBytes(w, chunk.SwimZE_Layer); + WriteSBytes(w, chunk.SwimZSE_Layer); + WriteSBytes(w, chunk.SwimZS_Layer); + WriteSBytes(w, chunk.SwimZSW_Layer); + WriteSBytes(w, chunk.SwimZW_Layer); + WriteSBytes(w, chunk.SwimZNW_Layer); + } + if (hasStrata) { // 256 × u16 offsets, then u32 length-prefixed strata byte array. @@ -403,6 +434,7 @@ internal static class StepCacheFile r.ReadUShort(); var multisVersion = (int)r.ReadUInt(); var hasStrata = r.ReadByte() != 0; + var hasSwimLayer = r.ReadByte() != 0; var chunk = new StepChunk { BuiltMultisVersion = multisVersion }; @@ -428,6 +460,21 @@ internal static class StepCacheFile ReadSBytes(r, chunk.SwimZW); ReadSBytes(r, chunk.SwimZNW); + if (hasSwimLayer) + { + chunk.AllocateSwimLayer(); + ReadSBytes(r, chunk.SwimSourceZ); + r.Read(chunk.SwimMask); + ReadSBytes(r, chunk.SwimZN_Layer); + ReadSBytes(r, chunk.SwimZNE_Layer); + ReadSBytes(r, chunk.SwimZE_Layer); + ReadSBytes(r, chunk.SwimZSE_Layer); + ReadSBytes(r, chunk.SwimZS_Layer); + ReadSBytes(r, chunk.SwimZSW_Layer); + ReadSBytes(r, chunk.SwimZW_Layer); + ReadSBytes(r, chunk.SwimZNW_Layer); + } + if (hasStrata) { var offsets = new ushort[StepChunk.CellsPerChunk]; @@ -472,6 +519,19 @@ internal static class StepCacheFile public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY)); + /// + /// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by + /// when preload is enabled to materialize all chunks + /// upfront instead of on first query. + /// + public IEnumerable<(int chunkX, int chunkY)> EnumerateChunkCoords() + { + foreach (var key in _offsets.Keys) + { + yield return ((int)(key >> 32), (int)(key & 0xFFFFFFFF)); + } + } + internal LazyReader( FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp, uint chunkCount, Dictionary offsets diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs index 351e6ef29..c39039369 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -37,6 +37,91 @@ internal sealed class StepChunk public readonly sbyte[] SwimZW = new sbyte[CellsPerChunk]; public readonly sbyte[] SwimZNW = new sbyte[CellsPerChunk]; + /// + /// Swim layer — populated only for chunks containing at least one shore cell (a cell + /// with both a walkable land surface and a water surface separated by > StepHeight). + /// On shore cells, queries from the swim source Z miss the primary source-Z guard; + /// the swim layer carries the correct wetMask + per-direction destination Zs computed + /// from the water surface's perspective. For non-shore cells in a chunk that has the + /// layer, [cell] = sentinel. + /// All swim-layer arrays are null on chunks with no shore cells (~90% of map chunks + /// on Trammel) — zero memory cost on the common case. + /// + public const sbyte NoSwimLayerCell = sbyte.MinValue; + + private sbyte[] _swimSourceZ; + private byte[] _swimMask; + private sbyte[] _swimZN_extra; + private sbyte[] _swimZNE_extra; + private sbyte[] _swimZE_extra; + private sbyte[] _swimZSE_extra; + private sbyte[] _swimZS_extra; + private sbyte[] _swimZSW_extra; + private sbyte[] _swimZW_extra; + private sbyte[] _swimZNW_extra; + + /// True when this chunk has at least one shore cell with a populated swim layer. + public bool HasSwimLayer => _swimSourceZ != null; + + /// Per-cell water-surface standing Z (or ). Null when chunk has no swim layer. + public sbyte[] SwimSourceZ => _swimSourceZ; + /// Per-cell swim mask computed at . Null when chunk has no swim layer. + public byte[] SwimMask => _swimMask; + public sbyte[] SwimZN_Layer => _swimZN_extra; + public sbyte[] SwimZNE_Layer => _swimZNE_extra; + public sbyte[] SwimZE_Layer => _swimZE_extra; + public sbyte[] SwimZSE_Layer => _swimZSE_extra; + public sbyte[] SwimZS_Layer => _swimZS_extra; + public sbyte[] SwimZSW_Layer => _swimZSW_extra; + public sbyte[] SwimZW_Layer => _swimZW_extra; + public sbyte[] SwimZNW_Layer => _swimZNW_extra; + + /// + /// Lazily allocates the swim-layer arrays and seeds with + /// the sentinel. Called at bake time the first time a + /// shore cell is detected in this chunk. + /// + internal void AllocateSwimLayer() + { + if (_swimSourceZ != null) + { + return; + } + _swimSourceZ = new sbyte[CellsPerChunk]; + _swimMask = new byte[CellsPerChunk]; + _swimZN_extra = new sbyte[CellsPerChunk]; + _swimZNE_extra = new sbyte[CellsPerChunk]; + _swimZE_extra = new sbyte[CellsPerChunk]; + _swimZSE_extra = new sbyte[CellsPerChunk]; + _swimZS_extra = new sbyte[CellsPerChunk]; + _swimZSW_extra = new sbyte[CellsPerChunk]; + _swimZW_extra = new sbyte[CellsPerChunk]; + _swimZNW_extra = new sbyte[CellsPerChunk]; + for (var i = 0; i < CellsPerChunk; i++) + { + _swimSourceZ[i] = NoSwimLayerCell; + } + } + + /// Test/serialization hook: install pre-built swim-layer arrays. Pass nulls to clear. + internal void SetSwimLayer( + sbyte[] swimSourceZ, byte[] swimMask, + sbyte[] zN, sbyte[] zNE, sbyte[] zE, sbyte[] zSE, + sbyte[] zS, sbyte[] zSW, sbyte[] zW, sbyte[] zNW + ) + { + _swimSourceZ = swimSourceZ; + _swimMask = swimMask; + _swimZN_extra = zN; + _swimZNE_extra = zNE; + _swimZE_extra = zE; + _swimZSE_extra = zSE; + _swimZS_extra = zS; + _swimZSW_extra = zSW; + _swimZW_extra = zW; + _swimZNW_extra = zNW; + } + /// Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays. public const ushort NoStrata = ushort.MaxValue; diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index c8f74468e..2d90e771a 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -102,6 +102,87 @@ public static class StepProbe return strata; } + /// + /// Writes the distinct surface Zs at (x, y) that a default walker (PersonHeight envelope) + /// can actually STAND on — each candidate surface (walkable land center + every walkable + /// static top) that has PersonHeight of vertical clearance free of impassable statics — + /// into , ascending, and returns the count. + /// + /// This is the clearance-aware counterpart to 's candidate + /// gather: it drops surfaces a creature cannot occupy (land under a sewer walkway, ground + /// under a low bridge), so the result is exactly the set of standing Zs the slow path can + /// resolve to. Two standable surfaces are inherently >= PersonHeight apart (an upper + /// surface within PersonHeight of a lower one removes the lower one's clearance), so a + /// single ascending pass with an exact-duplicate skip is sufficient. + /// + /// Used by the baker to capture walkable static-over-land surfaces (sewer/dungeon + /// walkways, bridges, raised foundations, upper building floors) that the land-anchored + /// main mask would otherwise miss. + /// + public static int ComputeStandableSurfaceZs(Map map, int x, int y, Span zs) + { + if (map == null || map == Map.Internal) + { + return 0; + } + if (x < 0 || y < 0 || x >= map.Width || y >= map.Height) + { + return 0; + } + + Span cand = stackalloc int[16]; + var count = 0; + + var landTile = map.Tiles.GetLandTile(x, y); + var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0) + { + map.GetAverageZ(x, y, out _, out var landCenter, out _); + cand[count++] = landCenter; + } + + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + if (count >= cand.Length) + { + break; + } + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (!data.Surface || data.Impassable) + { + continue; + } + cand[count++] = tile.Z + data.CalcHeight; + } + + if (count == 0) + { + return 0; + } + + cand[..count].Sort(); + + var n = 0; + for (var i = 0; i < count && n < zs.Length; i++) + { + var cz = (sbyte)Math.Clamp(cand[i], sbyte.MinValue + 1, sbyte.MaxValue); + if (n > 0 && zs[n - 1] == cz) + { + continue; + } + // Standable iff the creature's PersonHeight body envelope above this surface is + // free of impassable statics. The surface itself never blocks (its top == cz, + // which is the envelope floor, not inside it). + if (StaticsBlockAt(map, x, y, cz, cz + PersonHeight)) + { + continue; + } + zs[n++] = cz; + } + + return n; + } + public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) { if (map == null || map == Map.Internal) @@ -166,6 +247,45 @@ public static class StepProbe return zCenter; } + /// + /// Returns the water-surface standing Z at (x, y) — the Z a swim-only mob would stand + /// at on this cell — or if no water surface exists. Used + /// by to detect shore cells (cells with both walk and swim + /// surfaces separated by > StepHeight) and bake their swim layer at swim-perspective Z. + /// + public static int ComputeSwimStandingZ(Map map, int x, int y) + { + if (map == null || map == Map.Internal) + { + return int.MinValue; + } + if (x < 0 || y < 0 || x >= map.Width || y >= map.Height) + { + return int.MinValue; + } + + // Land tile flagged Wet — its center Z is the swim surface. + var landTile = map.Tiles.GetLandTile(x, y); + var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + if (!landTile.Ignored && (landFlags & TileFlag.Wet) != 0) + { + map.GetAverageZ(x, y, out _, out var landCenter, out _); + return landCenter; + } + + // Otherwise scan statics for a wet surface. + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (data.Wet) + { + return tile.Z + data.CalcHeight; + } + } + + return int.MinValue; + } + /// /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk. /// diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index c75e90d0b..c41b9b484 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -7,9 +7,14 @@ namespace Server.Engines.Pathing; /// Admin commands for inspecting and operating the pathfinding step cache. /// [PathCacheStats — current resident-chunk count + hit/miss/eviction telemetry. /// [PathCacheClear — drop all cached chunks, close lazy readers, zero counters. +/// [PathBake — walk a whole map building the full static cache, then save it. /// [PathCacheSave — persist resident chunks per map to Data/Pathfinding/<mapId>.swb. /// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup. /// [PathRecord — toggle JSONL telemetry capture for replay / benchmark corpora. +/// +/// The step cache works WITHOUT any .swb file — chunks build on demand as creatures path. +/// A baked .swb is an optional optimization that removes first-pathfind-after-boot latency +/// for shard owners who want it; is how you produce one. /// public static class PathCacheCommands { @@ -30,6 +35,7 @@ public static class PathCacheCommands CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); + CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake); CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord); @@ -78,6 +84,55 @@ public static class PathCacheCommands e.Mobile.SendMessage($"StepCache cleared: {residentBefore} chunks dropped, counters reset."); } + [Usage("PathBake [mapId]")] + [Description("Walks every chunk of the given map (or all loaded maps) building the full static step cache, then saves it to Data/Pathfinding/.swb so a future boot has zero first-pathfind latency. WARNING: blocks the game loop for several seconds and transiently uses hundreds of MB per map — run during maintenance, not peak hours.")] + private static void OnPathBake(CommandEventArgs e) + { + var from = e.Mobile; + int? only = e.Arguments.Length > 0 && int.TryParse(e.Arguments[0], out var parsed) ? parsed : null; + + from.SendMessage("PathBake: building the static step cache. The server will pause briefly per map..."); + + var totalChunks = 0; + var totalMaps = 0; + var sw = System.Diagnostics.Stopwatch.StartNew(); + + for (var i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + if (map == null || map == Map.Internal || (only.HasValue && map.MapID != only.Value)) + { + continue; + } + + // BakeMap walks the whole map (building every chunk) and writes the .swb. The + // chunks are left resident afterward; drop them so peak memory is bounded to one + // map at a time and the post-command footprint returns to the LRU cap. + var written = StepCache.Instance.BakeMap(map.MapID, PathFor(map.MapID)); + StepCache.Instance.ClearResidentChunks(); + + if (written > 0) + { + totalChunks += written; + totalMaps++; + from.SendMessage($" map {map.MapID}: {written} chunks → {PathFor(map.MapID)}"); + } + } + + sw.Stop(); + + if (totalMaps == 0) + { + from.SendMessage(only.HasValue ? $"PathBake: map {only.Value} not loaded." : "PathBake: no maps to bake."); + return; + } + + // Reopen the freshly written files as lazy backing stores so they're usable now + // without a restart (resident memory stays bounded by the LRU cap). + AutoLoadAtStartup(); + from.SendMessage($"PathBake: {totalChunks} chunks across {totalMaps} map(s) in {sw.Elapsed.TotalSeconds:F1}s; lazy readers reopened."); + } + [Usage("PathCacheSave")] [Description("Persists resident StepCache chunks for every loaded map to Data/Pathfinding/.swb.")] private static void OnPathCacheSave(CommandEventArgs e) diff --git a/Projects/UOContent/Engines/Pathing/PathDiag.cs b/Projects/UOContent/Engines/Pathing/PathDiag.cs new file mode 100644 index 000000000..51b1372b8 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/PathDiag.cs @@ -0,0 +1,205 @@ +using System; +using System.Diagnostics; +using System.IO; +using Server.Engines.Pathing.Cache; +using Server.PathAlgorithms.BitmapAStar; +using Server.Targeting; + +namespace Server.Engines.Pathing; + +/// +/// Developer diagnostic for the bitmap A* step cache. Stand where a creature would start, +/// run [PathDiag, and target the goal. The detailed report is appended to +/// Logs/pathdiag.log; a short summary is sent to the invoking client. For the route +/// it records: +/// 1. the raw tile makeup of the start and goal cells (land + statics) and the +/// clearance-aware standable surfaces the baker anchors to — the ground truth for +/// "why does the cache (not) serve this cell"; +/// 2. one warm -served Find with the per-pathfind cache +/// hit/fallthrough breakdown and fallthrough fraction — a high fallthrough fraction +/// means the cache isn't helping the route (it pays the lookup then uses the slow path); +/// 3. warm timing over many iterations. +/// +/// Primarily useful when bringing up custom maps / facets: it shows whether static-over-land +/// geometry (dungeon walkways, bridges, stairs, raised foundations, stacked floors) is being +/// baked at the right Z. +/// +/// Output goes to a log file rather than the console because the live server uses Serilog and +/// raw Console writes interleave badly with it. The promotion gate is forced to eager +/// (threshold 1) for the duration so the cache builds on first touch and the numbers reflect +/// its best case; the previous threshold is restored afterward. +/// +public static class PathDiag +{ + private const int TimingIterations = 200; + + private static string LogPath => Path.Combine(Core.BaseDirectory, "Logs", "pathdiag.log"); + + public static void Configure() + { + CommandSystem.Register("PathDiag", AccessLevel.Administrator, OnPathDiag); + } + + [Usage("PathDiag")] + [Description("Diagnoses the step cache for a route (results appended to Logs/pathdiag.log): target a tile to record start/goal tile makeup, the per-Find cache hit/fallthrough breakdown, and warm timing.")] + private static void OnPathDiag(CommandEventArgs e) + { + var start = e.Mobile.Location; + e.Mobile.SendMessage("PathDiag: target the goal tile."); + e.Mobile.BeginTarget(-1, true, TargetFlags.None, (from, targeted) => OnTarget(from, start, targeted)); + } + + private static void OnTarget(Mobile from, Point3D start, object targeted) + { + if (targeted is not IPoint3D p) + { + return; + } + + var map = from.Map; + var goal = new Point3D(p.X, p.Y, p.Z); + + if (!Utility.InRange(start, goal, 38)) + { + from.SendMessage("PathDiag: goal is outside the A* search window (38 tiles); aborting."); + return; + } + + var cache = StepCache.Instance; + var previousThreshold = cache.MissPromotionThreshold; + cache.MissPromotionThreshold = 1; // eager build — measure the cache's best case + + StreamWriter log = null; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); + log = new StreamWriter(new FileStream(LogPath, FileMode.Append, FileAccess.Write, FileShare.Read)); + + log.WriteLine($"===== [{Core.Now:yyyy-MM-dd HH:mm:ss}] PathDiag ({start.X},{start.Y},{start.Z}) -> ({goal.X},{goal.Y},{goal.Z}) on {map} ====="); + + DumpCell(log, map, start.X, start.Y, start.Z, "start"); + DumpCell(log, map, goal.X, goal.Y, goal.Z, "goal"); + var find = RunInstrumentedFind(log, from, map, start, goal); + var (minUs, avgUs) = TimeWarm(log, from, map, start, goal); + log.WriteLine(); + + from.SendMessage($"PathDiag ({start.X},{start.Y},{start.Z})->({goal.X},{goal.Y},{goal.Z}): {find.result}"); + from.SendMessage($" cache fallthrough {find.fallthroughPct:F1}% of {find.total}; warm min={minUs:F1}us avg={avgUs:F1}us"); + from.SendMessage($" full report appended to Logs/pathdiag.log"); + } + catch (IOException ex) + { + from.SendMessage($"PathDiag: failed to write {LogPath}: {ex.Message}"); + } + finally + { + log?.Dispose(); + cache.MissPromotionThreshold = previousThreshold; + } + } + + /// + /// Writes the raw tile makeup of one cell plus the surfaces the baker anchors to. A large + /// gap between the query Z and the standable surfaces is the signature of a route the + /// cache can't serve (the creature stands on a static surface far from the land average). + /// + private static void DumpCell(TextWriter log, Map map, int x, int y, int queryZ, string label) + { + map.GetAverageZ(x, y, out var landZ, out var avgZ, out var landTop); + var landTile = map.Tiles.GetLandTile(x, y); + var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var landImpassable = (landFlags & TileFlag.Impassable) != 0; + var landWet = (landFlags & TileFlag.Wet) != 0; + + Span surfaces = stackalloc sbyte[16]; + var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaces); + + log.WriteLine($"{label} cell ({x},{y}) queryZ={queryZ}:"); + log.WriteLine($" land: avgZ={avgZ} landZ={landZ} landTop={landTop} impassable={landImpassable} wet={landWet} ignored={landTile.Ignored}"); + + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < surfaceCount; i++) + { + sb.Append(i == 0 ? "" : ",").Append(surfaces[i]); + } + log.WriteLine($" standable surfaces={surfaceCount} [{sb}] -> {(surfaceCount >= 2 ? "multi-Z (strata)" : "single-Z anchor")}"); + + log.WriteLine(" static/multi surfaces:"); + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + log.WriteLine($" id=0x{tile.ID:X4} z={tile.Z} top={tile.Z + data.CalcHeight} h={data.Height} surface={data.Surface} impass={data.Impassable} bridge={data.Bridge} wet={data.Wet}"); + } + } + + /// + /// Runs one warm Find and records the StepCache counter delta for it — the per-pathfind + /// cache hit/fallthrough mix and the fallthrough fraction. Returns a summary for the + /// caller to relay to the player. + /// + private static (string result, double fallthroughPct, long total) RunInstrumentedFind( + TextWriter log, Mobile from, Map map, Point3D start, Point3D goal + ) + { + var cache = StepCache.Instance; + + // Warm every chunk the route touches before measuring. + for (var i = 0; i < 3; i++) + { + BitmapAStarAlgorithm.Instance.Find(from, map, start, goal); + } + + var before = cache.GetStats(); + var path = BitmapAStarAlgorithm.Instance.Find(from, map, start, goal); + var after = cache.GetStats(); + + var served = after.Hits - before.Hits + + (after.MissesNotBuilt - before.MissesNotBuilt) + + (after.MissesDirtyRebuild - before.MissesDirtyRebuild); + var fallthrough = after.FallthroughMultiZ - before.FallthroughMultiZ + + (after.FallthroughSourceZMismatch - before.FallthroughSourceZMismatch) + + (after.FallthroughOffMap - before.FallthroughOffMap) + + (after.FallthroughNotBuilt - before.FallthroughNotBuilt); + var total = served + fallthrough; + var pct = total == 0 ? 0 : 100.0 * fallthrough / total; + var result = path == null ? "NO PATH" : $"{path.Length} steps"; + + log.WriteLine($"warm Find: {result}"); + log.WriteLine($" cache-served={served} fallthrough={fallthrough} ({pct:F1}% of {total} probes)"); + log.WriteLine($" fallthrough breakdown: multiZ={after.FallthroughMultiZ - before.FallthroughMultiZ} " + + $"srcZ={after.FallthroughSourceZMismatch - before.FallthroughSourceZMismatch} " + + $"offMap={after.FallthroughOffMap - before.FallthroughOffMap} " + + $"notBuilt={after.FallthroughNotBuilt - before.FallthroughNotBuilt}"); + if (path == null) + { + log.WriteLine(" NO PATH: goal unreachable within the 38-tile window (or not standable). This is an A* scope limit, independent of the cache."); + } + + return (result, pct, total); + } + + private static (double minUs, double avgUs) TimeWarm(TextWriter log, Mobile from, Map map, Point3D start, Point3D goal) + { + var sw = new Stopwatch(); + var minTicks = long.MaxValue; + long totalTicks = 0; + + for (var i = 0; i < TimingIterations; i++) + { + sw.Restart(); + BitmapAStarAlgorithm.Instance.Find(from, map, start, goal); + sw.Stop(); + totalTicks += sw.ElapsedTicks; + if (sw.ElapsedTicks < minTicks) + { + minTicks = sw.ElapsedTicks; + } + } + + var usPerTick = 1_000_000.0 / Stopwatch.Frequency; + var minUs = minTicks * usPerTick; + var avgUs = totalTicks * usPerTick / TimingIterations; + log.WriteLine($"timing over {TimingIterations} warm Finds: min={minUs:F1}us avg={avgUs:F1}us"); + return (minUs, avgUs); + } +}