diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index cc6005064..655e13788 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -1875,7 +1875,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa internal List Multis => _multis ?? m_DefaultMultiList; - internal int MultisVersion => _multisVersion; + public int MultisVersion => _multisVersion; internal ref readonly ValueLinkList Mobiles => ref _mobiles; diff --git a/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs b/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs new file mode 100644 index 000000000..598aac653 --- /dev/null +++ b/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Reflection; +using Server.Items; +using Server.Misc; +using Server.Movement; +using Server.Tests.Maps; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[CollectionDefinition("Sequential Pathfinding Tests", DisableParallelization = true)] +public class PathfindingTestFixture : ICollectionFixture, IDisposable +{ + public PathfindingTestFixture() + { + Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); + Core.LoopContext = new EventLoopContext(); + Core.Expansion = Expansion.EJ; + + ServerConfiguration.Load(true); + ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); + + var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR") + ?? @"C:\Ultima Online Classic"; + ServerConfiguration.DataDirectories.Add(clientFiles); + + AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); + + SkillsInfo.Configure(); + Server.Network.NetState.Configure(); + TestMapDefinitions.ConfigureTestMapDefinitions(); + + World.Configure(); + Timer.Init(0); + RaceDefinitions.Configure(); + MovementImpl.Configure(); + World.Load(); + World.ExitSerializationThreads(); + DecayScheduler.Configure(); + + // TileData's static cctor short-circuits when running under xUnit + // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable + // flags are populated; without this, every tile reads as flag=None and + // MovementImpl.CheckMovement treats everything as walkable. + ForceLoadTileData(); + + VerifyTrammelTileDataLoaded(); + } + + private static void ForceLoadTileData() + { + var loadMethod = typeof(TileData).GetMethod( + "Load", + BindingFlags.Static | BindingFlags.NonPublic + ); + if (loadMethod == null) + { + throw new InvalidOperationException( + "TileData.Load not found via reflection — engine may have refactored." + ); + } + loadMethod.Invoke(null, null); + } + + private static void VerifyTrammelTileDataLoaded() + { + var trammel = Map.Maps[1]; + if (trammel == null) + { + throw new InvalidOperationException( + "Trammel (mapId=1) was not registered. Check TestMapDefinitions." + ); + } + + var tile = trammel.Tiles.GetLandTile(1500, 1600); + if (tile.ID == 0) + { + throw new InvalidOperationException( + $"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " + + $"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " + + $"{Path.Combine(Core.BaseDirectory, "Data")}." + ); + } + } + + public void Dispose() + { + Timer.Init(0); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs new file mode 100644 index 000000000..347af7f19 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -0,0 +1,109 @@ +using Server.Engines.Pathing.Cache; +using Server.Mobiles; +using Server.PathAlgorithms.BitmapAStar; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +/// +/// Smoke tests for 's two branches: cache-direct fast +/// path (default walkers) and per-cell slow path (capability creatures and non-GM players). +/// Exercised end-to-end against the real Trammel TileMatrix to ensure neither regresses +/// to "no path found" on reachable goals. +/// +[Collection("Sequential Pathfinding Tests")] +public class BitmapAStarAlgorithmTests +{ + private readonly ITestOutputHelper _output; + + public BitmapAStarAlgorithmTests(ITestOutputHelper output) + { + _output = output; + } + + [Theory] + // Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable. + // Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches + // during expansion (GetAverageZ at the goal cell may differ from the engine's + // runtime-computed standing Z, which would break the destNode equality check). + [InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal + [InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N + public void DefaultWalker_FindsPath_ViaCacheFastPath(int sx, int sy, int gx, int gy) + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + var stub = new DefaultWalkerStub(); + map.GetAverageZ(sx, sy, out _, out var startZ, out _); + var start = new Point3D(sx, sy, (sbyte)startZ); + var goal = new Point3D(gx, gy, (sbyte)startZ); + + stub.MoveToWorld(start, map); + + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + + stub.Delete(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + _output.WriteLine($"default-walker ({sx},{sy})->({gx},{gy}): {result.Length} steps"); + } + + [Theory] + [InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal + [InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N + public void CapabilityCreature_FindsPath_ViaInlineSlowPath(int sx, int sy, int gx, int gy) + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + var stub = new SwimmingStub(World.NewMobile); + stub.DefaultMobileInit(); + stub.CanSwim = true; // forces non-default-walker → inline GetSuccessorsSlowPath + map.GetAverageZ(sx, sy, out _, out var startZ, out _); + var start = new Point3D(sx, sy, (sbyte)startZ); + var goal = new Point3D(gx, gy, (sbyte)startZ); + + stub.MoveToWorld(start, map); + + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + + stub.Delete(); + + // Capability creature: assert reachability — exact length depends on terrain and + // tie-breaking, but a swimmer should always reach a goal a default walker reaches + // on dry land (CanSwim is permissive, never restrictive). + Assert.NotNull(result); + Assert.NotEmpty(result); + _output.WriteLine($"swimmer ({sx},{sy})->({gx},{gy}): {result.Length} steps"); + } + + /// + /// Plain Mobile — IsDefaultWalker returns true, the bitmap algorithm uses the cache + /// fast path on every expansion. + /// + private sealed class DefaultWalkerStub : Mobile + { + public DefaultWalkerStub() + { + Body = 0xC9; + } + } + + /// + /// BaseCreature with CanSwim=true — IsDefaultWalker returns false, the bitmap + /// algorithm short-circuits GetSuccessors to GetSuccessorsSlowPath on every cell. + /// Use the Serial constructor (deserialization path) to bypass NPCSpeeds init, + /// which requires the npc-speeds.json table loaded — not available in tests. + /// + private sealed class SwimmingStub : BaseCreature + { + public SwimmingStub(Serial serial) : base(serial) + { + Body = 0xC9; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs new file mode 100644 index 000000000..76a765926 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -0,0 +1,229 @@ +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepCacheLifecycleTests +{ + [Fact] + public void Singleton_IsAvailable() + { + var cache = StepCache.Instance; + Assert.NotNull(cache); + } + + [Fact] + public void Clear_OnEmptyCache_LeavesStatsZero() + { + var cache = StepCache.Instance; + cache.Clear(); + + var stats = cache.GetStats(); + Assert.Equal(0, stats.ResidentChunks); + Assert.Equal(0L, stats.Hits); + Assert.Equal(0L, stats.BuildsTotal); + } + + [Fact] + public void TryGetMask_FirstQuery_BuildsChunkAndReturnsBakerOutput() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + + // Pinned cell (1500, 1600, z=10): mask=0xC1 + var ok = cache.TryGetMask( + map, 1500, 1600, sourceZ: 10, + out var mask, + out var dN, out var dNE, out var dE, out var dSE, + out var dS, out var dSW, out var dW, out var dNW, + out var hitKind + ); + + Assert.True(ok); + Assert.Equal(CacheHitKind.Miss_NotBuilt, hitKind); + Assert.Equal((byte)0xC1, mask); + Assert.Equal((sbyte)10, dN); + Assert.Equal((sbyte)10, dW); + Assert.Equal((sbyte)10, dNW); + + var stats = cache.GetStats(); + Assert.Equal(1, stats.ResidentChunks); + Assert.Equal(1L, stats.MissesNotBuilt); + Assert.Equal(1L, stats.BuildsTotal); + + // Second query of same cell → Hit + var ok2 = cache.TryGetMask( + map, 1500, 1600, sourceZ: 10, + out var mask2, + out _, out _, out _, out _, out _, out _, out _, out _, + out var hitKind2 + ); + Assert.True(ok2); + Assert.Equal(CacheHitKind.Hit, hitKind2); + Assert.Equal((byte)0xC1, mask2); + + var stats2 = cache.GetStats(); + Assert.Equal(1, stats2.ResidentChunks); + Assert.Equal(1L, stats2.Hits); + } + + [Fact] + public void TryGetMask_OffMap_ReturnsFalseFallthrough() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + + var ok = cache.TryGetMask( + map, -1, -1, sourceZ: 0, + out var mask, out _, out _, out _, out _, out _, out _, out _, out _, + out var hitKind + ); + + Assert.False(ok); + Assert.Equal(CacheHitKind.Fallthrough_OffMap, hitKind); + Assert.Equal((byte)0, mask); + } + + [Fact] + public void MultisVersion_Bump_TriggersDirtyRebuild() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + var sector = map.GetRealSector(1500 >> 4, 1600 >> 4); + + // First query: builds chunk, snapshots current MultisVersion. + cache.TryGetMask(map, 1500, 1600, 10, + out _, out _, out _, out _, out _, out _, out _, out _, out _, + out var firstHitKind); + Assert.Equal(CacheHitKind.Miss_NotBuilt, firstHitKind); + + // Bump _multisVersion via reflection. + var versionField = typeof(Map.Sector).GetField( + "_multisVersion", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance + ); + Assert.NotNull(versionField); + var current = (int)versionField.GetValue(sector); + versionField.SetValue(sector, current + 1); + + // Second query: detects version mismatch, rebuilds. + cache.TryGetMask(map, 1500, 1600, 10, + out _, out _, out _, out _, out _, out _, out _, out _, out _, + out var secondHitKind); + Assert.Equal(CacheHitKind.Miss_DirtyRebuild, secondHitKind); + + 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. + Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits); + Assert.Equal(0L, stats.FallthroughMultiZ); + Assert.Equal(0L, stats.FallthroughOffMap); + } + + [Fact] + public void MultiZCell_RoutesToFallthrough() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + + // Build a chunk first so it exists. + cache.TryGetMask(map, 1500, 1600, 10, + out _, out _, out _, out _, out _, out _, out _, out _, out _, out _); + + // 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]; + + // Mark cell at (1500, 1600) within the chunk as multi-Z. + var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4)); + chunk.MarkCellMultiZ(cellIndex); + + var ok = cache.TryGetMask(map, 1500, 1600, 10, + out _, out _, out _, out _, out _, out _, out _, out _, out _, + out var hitKind); + + Assert.False(ok); + Assert.Equal(CacheHitKind.Fallthrough_MultiZ, hitKind); + + var stats = cache.GetStats(); + Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ); + } + + [Fact] + public void LruCap_OverflowEvictsToCap() + { + var cache = StepCache.Instance; + cache.Clear(); + cache.MaxResidentChunks = 4; + + try + { + var map = Map.Maps[1]; + + // Build 5 distinct chunks by querying different sectors. + for (var i = 0; i < 5; i++) + { + var x = 1500 + (i * 16); + var y = 1600; + cache.TryGetMask(map, x, y, 10, + out _, out _, out _, out _, out _, out _, out _, out _, out _, out _); + System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs + } + + 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"); + } + } + finally + { + cache.MaxResidentChunks = 8192; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs new file mode 100644 index 000000000..a0b494677 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -0,0 +1,90 @@ +using Server.Engines.Pathing.Cache; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepCacheParityTests +{ + private readonly ITestOutputHelper _output; + + public StepCacheParityTests(ITestOutputHelper output) + { + _output = output; + } + + [Theory] + [InlineData("britain_inn_dense", 1480, 1610, 32)] + [InlineData("trammel_open_plain", 1500, 1600, 32)] + [InlineData("britain_causeway", 1475, 1641, 32)] + public void CacheMatchesBaker(string label, int xStart, int yStart, int size) + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + + var disagreements = 0; + var samples = 0; + var multiZ = 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 _); + + // 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); + + var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + + var ok = cache.TryGetMask( + map, x, y, sourceZ, + out var mask, + out var dN, out var dNE, out var dE, out var dSE, + out var dS, out var dSW, out var dW, out var dNW, + out var hitKind + ); + + samples++; + + if (hitKind == CacheHitKind.Fallthrough_MultiZ) + { + multiZ++; + continue; + } + + Assert.True(ok, $"Cache returned !ok at ({x},{y}) hitKind={hitKind}"); + + if (mask != baker.Mask) + { + disagreements++; + _output.WriteLine($"MASK DIFF @ ({x},{y}) cache=0x{mask:X2} baker=0x{baker.Mask:X2}"); + continue; + } + + if (dN != baker.DestZ_N || dNE != baker.DestZ_NE || dE != baker.DestZ_E || dSE != baker.DestZ_SE + || dS != baker.DestZ_S || dSW != baker.DestZ_SW || dW != baker.DestZ_W || dNW != baker.DestZ_NW) + { + disagreements++; + _output.WriteLine($"Z DIFF @ ({x},{y}) cache=({dN},{dNE},{dE},{dSE},{dS},{dSW},{dW},{dNW}) baker=({baker.DestZ_N},{baker.DestZ_NE},{baker.DestZ_E},{baker.DestZ_SE},{baker.DestZ_S},{baker.DestZ_SW},{baker.DestZ_W},{baker.DestZ_NW})"); + } + } + } + + _output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}"); + + // Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer. + if (label == "britain_inn_dense") + { + Assert.True(samples - multiZ > 0, "expected real cache answers in dense region"); + } + + Assert.Equal(0, disagreements); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs new file mode 100644 index 000000000..8bea2f7f3 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs @@ -0,0 +1,126 @@ +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; + } + + [Theory] + [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) + { + 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.GetDestZ(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.Tests/Tests/Engines/Pathing/StepProbeTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs new file mode 100644 index 000000000..8f9011039 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs @@ -0,0 +1,248 @@ +using Server.Engines.Pathing.Cache; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepProbeTests +{ + private readonly ITestOutputHelper _output; + + public StepProbeTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void ComputeMaskAt_NullMap_ReturnsDefault() + { + var result = StepProbe.ComputeMaskAt(null, 100, 100, 0); + + Assert.Equal(0, result.Mask); + for (var d = 0; d < 8; d++) + { + Assert.Equal(0, result.GetDestZ((Direction)d)); + } + } + + [Fact] + public void ComputeMaskAt_InternalMap_ReturnsDefault() + { + var result = StepProbe.ComputeMaskAt(Map.Internal, 100, 100, 0); + + Assert.Equal(0, result.Mask); + for (var d = 0; d < 8; d++) + { + Assert.Equal(0, result.GetDestZ((Direction)d)); + } + } + + [Fact] + public void ComputeMaskAt_TopLeftCorner_OffMapDirectionsBlocked() + { + // From source (0, 0), these neighbor cells are off-map and MUST be blocked, + // regardless of map content: + // N = (0, -1) bit 0 + // NE = (1, -1) bit 1 + // SW = (-1, 1) bit 5 + // W = (-1, 0) bit 6 + // NW = (-1, -1) bit 7 + // Bits 2 (E), 3 (SE), 4 (S) depend on map content and aren't asserted. + var map = Map.Maps[1]; + Assert.NotNull(map); + + var result = StepProbe.ComputeMaskAt(map, 0, 0, 0); + + Assert.False(result.IsWalkable(Direction.North), "N should be off-map"); + Assert.False(result.IsWalkable(Direction.Right), "NE should be off-map"); + Assert.False(result.IsWalkable(Direction.Left), "SW should be off-map"); + Assert.False(result.IsWalkable(Direction.West), "W should be off-map"); + Assert.False(result.IsWalkable(Direction.Up), "NW should be off-map"); + } + + [Fact] + public void ComputeMaskAt_BottomRightCorner_OffMapDirectionsBlocked() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + + var x = map.Width - 1; + var y = map.Height - 1; + var result = StepProbe.ComputeMaskAt(map, x, y, 0); + + // From the SE corner, S/SE/SW/E/NE all go off-map (only N/NW/W in-map). + Assert.False(result.IsWalkable(Direction.Right), "NE should be off-map"); + Assert.False(result.IsWalkable(Direction.East), "E should be off-map"); + Assert.False(result.IsWalkable(Direction.Down), "SE should be off-map"); + Assert.False(result.IsWalkable(Direction.South), "S should be off-map"); + Assert.False(result.IsWalkable(Direction.Left), "SW should be off-map"); + } + + [Fact] + public void ComputeMaskAt_TrammelOpenPlain_HasFlatCellWithAllDirectionsWalkable() + { + // Invariant: somewhere in the open-plain region, there must be a fully-flat cell + // whose mask is 0xFF and all 8 destZ values equal sourceZ (no slope). + // This protects against a regression where the baker stops detecting flat cells. + var map = Map.Maps[1]; + Assert.NotNull(map); + + var found = false; + + for (var x = 1500; x < 1532 && !found; x++) + { + for (var y = 1600; y < 1632 && !found; y++) + { + map.GetAverageZ(x, y, out _, out var avgZ, out _); + var sourceZ = (sbyte)avgZ; + var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + + if (result.Mask != 0xFF) + { + continue; + } + + var allSameZ = true; + for (var d = 0; d < 8; d++) + { + if (result.GetDestZ((Direction)d) != sourceZ) + { + allSameZ = false; + break; + } + } + + if (allSameZ) + { + found = true; + _output.WriteLine($"Flat-open cell found at ({x}, {y}, {sourceZ})"); + } + } + } + + Assert.True(found, "Expected at least one fully-flat open cell in (1500..1532, 1600..1632)"); + } + + [Fact] + public void ComputeMaskAt_BritainInnDense_HasCellWithBlockedDirections() + { + // Invariant: inside the dense Britain inn region, at least one cell must have + // at least one direction blocked by a static. Protects against a regression + // where the baker reports everything as walkable (the original false-pass bug). + var map = Map.Maps[1]; + Assert.NotNull(map); + + var blockedCellCount = 0; + + for (var x = 1480; x < 1512; x++) + { + for (var y = 1610; y < 1642; y++) + { + map.GetAverageZ(x, y, out _, out var avgZ, out _); + var sourceZ = (sbyte)avgZ; + var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + + if (result.Mask != 0xFF) + { + blockedCellCount++; + } + } + } + + _output.WriteLine($"Cells with at least one blocked direction: {blockedCellCount} / 1024"); + Assert.True(blockedCellCount > 0, "Expected at least one cell with a blocked direction in the dense region"); + } + + [Theory] + [InlineData(1500, 1600)] + [InlineData(1480, 1610)] + [InlineData(0, 0)] + public void ComputeMaskAt_IsDeterministic(int x, int y) + { + // Same inputs must always produce identical output. Catches accidental + // statefulness in the baker (e.g., a static cache that gets corrupted). + var map = Map.Maps[1]; + Assert.NotNull(map); + + map.GetAverageZ(x, y, out _, out var avgZ, out _); + var sourceZ = (sbyte)avgZ; + + var first = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + var second = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + var third = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + + Assert.Equal(first.Mask, second.Mask); + Assert.Equal(first.Mask, third.Mask); + for (var d = 0; d < 8; d++) + { + Assert.Equal(first.GetDestZ((Direction)d), second.GetDestZ((Direction)d)); + Assert.Equal(first.GetDestZ((Direction)d), third.GetDestZ((Direction)d)); + } + } + + [Fact] + public void ComputeMaskAt_PinnedCell_TrammelOpenPlainOrigin() + { + // PINNING test: locks specific output for Trammel (1500, 1600). + // Cell at z=10 with mask 0xC1 (N + W + NW only walkable). The other five + // directions are blocked by water (E/SE/S/SW are wet tiles, NE is shore). + // If this changes, either the baker logic changed or the underlying tile + // data changed; re-run the parity test to confirm before updating expected values. + var map = Map.Maps[1]; + Assert.NotNull(map); + + map.GetAverageZ(1500, 1600, out _, out var avgZ, out _); + var sourceZ = (sbyte)avgZ; + + var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ); + + Assert.Equal((sbyte)10, sourceZ); + Assert.Equal((byte)0xC1, result.Mask); + + Assert.True(result.IsWalkable(Direction.North)); + Assert.False(result.IsWalkable(Direction.Right)); + Assert.False(result.IsWalkable(Direction.East)); + Assert.False(result.IsWalkable(Direction.Down)); + Assert.False(result.IsWalkable(Direction.South)); + Assert.False(result.IsWalkable(Direction.Left)); + Assert.True(result.IsWalkable(Direction.West)); + Assert.True(result.IsWalkable(Direction.Up)); + + Assert.Equal((sbyte)10, result.GetDestZ(Direction.North)); + Assert.Equal((sbyte)10, result.GetDestZ(Direction.West)); + Assert.Equal((sbyte)10, result.GetDestZ(Direction.Up)); + } + + [Fact] + public void ComputeMaskAt_PinnedCell_BritainInnDenseOrigin() + { + // PINNING test: locks specific output for Trammel (1480, 1610). + // Cell at z=20 with mask 0x3F (N/NE/E/SE/S/SW walkable, W/NW blocked by + // a wall to the west). All walkable directions stay flat at z=20. + var map = Map.Maps[1]; + Assert.NotNull(map); + + map.GetAverageZ(1480, 1610, out _, out var avgZ, out _); + var sourceZ = (sbyte)avgZ; + + var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ); + + Assert.Equal((sbyte)20, sourceZ); + Assert.Equal((byte)0x3F, result.Mask); + + Assert.True(result.IsWalkable(Direction.North)); + Assert.True(result.IsWalkable(Direction.Right)); + Assert.True(result.IsWalkable(Direction.East)); + Assert.True(result.IsWalkable(Direction.Down)); + Assert.True(result.IsWalkable(Direction.South)); + Assert.True(result.IsWalkable(Direction.Left)); + Assert.False(result.IsWalkable(Direction.West)); + Assert.False(result.IsWalkable(Direction.Up)); + + for (var d = 0; d < 6; d++) + { + Assert.Equal((sbyte)20, result.GetDestZ((Direction)d)); + } + } +} diff --git a/Projects/UOContent/Engines/Pathing/FastAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs similarity index 61% rename from Projects/UOContent/Engines/Pathing/FastAStarAlgorithm.cs rename to Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index c4e234260..7298a1c47 100644 --- a/Projects/UOContent/Engines/Pathing/FastAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: FastAStarAlgorithm.cs * + * File: BitmapAStarAlgorithm.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -14,15 +14,24 @@ ************************************************************************/ using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Server.Engines.Pathing.Cache; using Server.Mobiles; using CalcMoves = Server.Movement.Movement; using MoveImpl = Server.Movement.MovementImpl; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -namespace Server.PathAlgorithms.FastAStar; +namespace Server.PathAlgorithms.BitmapAStar; -public class FastAStarAlgorithm : PathAlgorithm +/// +/// A* pathfinder with a single bitmap-cache lookup per cell expansion. Default walkers +/// take one call returning the 8-direction +/// mask + per-direction Z. Non-default walkers (non-GM players, creatures with swim/fly/ +/// door/clip capabilities) and per-cell cache fallthroughs route through +/// , which runs the per-direction +/// loop for that one cell. +/// +public class BitmapAStarAlgorithm : PathAlgorithm { private struct PathNode { @@ -40,7 +49,7 @@ public class FastAStarAlgorithm : PathAlgorithm private const int PlaneOffset = 128; private const int PlaneCount = 13; private const int PlaneHeight = 20; - public static readonly PathAlgorithm Instance = new FastAStarAlgorithm(); + public static readonly PathAlgorithm Instance = new BitmapAStarAlgorithm(); private static readonly Direction[] _path = new Direction[AreaSize * AreaSize]; private static readonly PathNode[] _nodes = new PathNode[NodeCount]; @@ -51,6 +60,10 @@ public class FastAStarAlgorithm : PathAlgorithm private static int _xOffset; private static int _yOffset; + // When set, GetSuccessors delegates to the per-cell slow path on every expansion + // (preserves player AND-rule and BaseCreature capability overlays). Reset at end of Find. + private static bool _currentMobileNeedsSlowPath; + private Point3D _goal; [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -76,6 +89,8 @@ public class FastAStarAlgorithm : PathAlgorithm return null; } + _currentMobileNeedsSlowPath = !IsDefaultWalker(m); + Array.Clear(_nodeStates); _goal = goal; @@ -120,6 +135,7 @@ public class FastAStarAlgorithm : PathAlgorithm _nodeStates[bestNode] = 2; + // Set MovementImpl globals so per-cell slow-path fallthroughs see the right state. if (bc != null) { MoveImpl.AlwaysIgnoreDoors = bc.CanOpenDoors; @@ -203,11 +219,13 @@ public class FastAStarAlgorithm : PathAlgorithm } _openQueue.Clear(); + _currentMobileNeedsSlowPath = false; return dirs; } } _openQueue.Clear(); + _currentMobileNeedsSlowPath = false; return null; } @@ -221,6 +239,13 @@ public class FastAStarAlgorithm : PathAlgorithm return x + y * AreaSize + z * AreaSize * AreaSize; } + /// + /// One call returns the 8-direction + /// walkable mask + destination Zs. Diagonal corner-cut applies the lenient creature + /// OR-rule using partner bits in the same mask byte — no neighbor-chunk lookup needed. + /// On cache fallthrough or for non-default walkers, defers to + /// for THIS cell only. + /// private static int GetSuccessors(int p, Mobile m, Map map) { var px = p % AreaSize; @@ -230,50 +255,94 @@ public class FastAStarAlgorithm : PathAlgorithm var p3D = new Point3D(px + _xOffset, py + _yOffset, pz); var vals = _successors; + + if (_currentMobileNeedsSlowPath) + { + return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + } + + var count = 0; + + StepCache.Instance.TryGetMask( + map, p3D.X, p3D.Y, (sbyte)p3D.Z, + out var mask, + out var dN, out var dNE, out var dE, out var dSE, + out var dS, out var dSW, out var dW, out var dNW, + out var hitKind + ); + + if (hitKind is CacheHitKind.Fallthrough_MultiZ + or CacheHitKind.Fallthrough_OffMap + or CacheHitKind.Fallthrough_SourceZMismatch) + { + return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + } + + for (var i = 0; i < 8; ++i) + { + var x = px; + var y = py; + CalcMoves.Offset((Direction)i, ref x, ref y); + + if (x is < 0 or >= AreaSize || y is < 0 or >= AreaSize) + { + continue; + } + + if ((mask & (1 << i)) == 0) + { + continue; + } + + // Diagonal corner-cut (creature OR-rule): partner bits live in the same mask byte. + if ((i & 1) == 1) + { + var leftBit = 1 << ((i - 1) & 0x7); + var rightBit = 1 << ((i + 1) & 0x7); + if ((mask & leftBit) == 0 && (mask & rightBit) == 0) + { + continue; + } + } + + var z = i switch + { + 0 => dN, + 1 => dNE, + 2 => dE, + 3 => dSE, + 4 => dS, + 5 => dSW, + 6 => dW, + 7 => dNW, + _ => (sbyte)0 + }; + + var idx = GetIndex(x + _xOffset, y + _yOffset, z); + + if (idx >= 0 && idx < NodeCount) + { + _nodes[idx].z = z; + vals[count++] = idx; + } + } + + return count; + } + + /// + /// Per-direction loop for a single source cell. + /// Runs on cache fallthrough or when is set. + /// + private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals) + { var count = 0; for (var i = 0; i < 8; ++i) { - int x; - int y; - switch (i) - { - default: // 0 - x = 0; - y = -1; - break; - case 1: - x = 1; - y = -1; - break; - case 2: - x = 1; - y = 0; - break; - case 3: - x = 1; - y = 1; - break; - case 4: - x = 0; - y = 1; - break; - case 5: - x = -1; - y = 1; - break; - case 6: - x = -1; - y = 0; - break; - case 7: - x = -1; - y = -1; - break; - } - - x += px; - y += py; + var x = px; + var y = py; + CalcMoves.Offset((Direction)i, ref x, ref y); if (x is < 0 or >= AreaSize || y is < 0 or >= AreaSize) { @@ -294,4 +363,24 @@ public class FastAStarAlgorithm : PathAlgorithm return count; } + + /// + /// Default walker = the cache's baked rules apply directly (lenient OR-rule for + /// diagonal corner-cut, no capability overlays). Non-GM players (strict AND-rule) + /// and creatures with swim/fly/door/clip capabilities require the slow path. + /// + private static bool IsDefaultWalker(Mobile m) + { + if (m.Player && m.AccessLevel < AccessLevel.GameMaster) + { + return false; + } + + if (m is not BaseCreature bc) + { + return true; + } + + return !bc.CanSwim && !bc.CanFly && !bc.CanOpenDoors && !bc.CanMoveOverObstacles; + } } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs new file mode 100644 index 000000000..76c43379b --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs @@ -0,0 +1,31 @@ +using System; + +namespace Server.Engines.Pathing.Cache; + +/// +/// Periodic backstop that enforces the StaticWalkabilityCache resident-chunk cap. +/// Steady-state cost is a single early-return; only fires real work when the cache +/// has overflowed MaxResidentChunks. Runs on the game thread; no locking required. +/// +public class CacheEvictionTimer : Timer +{ + private static CacheEvictionTimer _instance; + + public static void Configure() + { + if (_instance != null) + { + return; + } + + _instance = new CacheEvictionTimer(); + _instance.Start(); + } + + private CacheEvictionTimer() : base(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) { } + + protected override void OnTick() + { + StepCache.Instance.EnforceLruCap(); + } +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs new file mode 100644 index 000000000..b38045741 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs @@ -0,0 +1,15 @@ +namespace Server.Engines.Pathing.Cache; + +/// +/// Outcome categories for StaticWalkabilityCache.TryGetMask. Used for telemetry +/// and to drive the slow-path fallthrough decision in callers. +/// +public enum CacheHitKind : byte +{ + Hit = 0, // clean default-walker answer from the resident chunk + Miss_NotBuilt = 1, // chunk wasn't resident; built and returned + Miss_DirtyRebuild = 2, // version mismatch; rebuilt and returned + 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 +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs new file mode 100644 index 000000000..040b13810 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs @@ -0,0 +1,28 @@ +namespace Server.Engines.Pathing.Cache; + +/// +/// Snapshot of StaticWalkabilityCache counters. Returned by GetStats() and consumed +/// by the [PathCacheStats admin command. All counters are monotonic except ResidentChunks. +/// +public readonly struct CacheStats( + int residentChunks, + long hits, + long missesNotBuilt, + long missesDirtyRebuild, + long fallthroughMultiZ, + long fallthroughOffMap, + long fallthroughSourceZMismatch, + long evictionsByLruCap, + long buildsTotal +) +{ + public readonly int ResidentChunks = residentChunks; + public readonly long Hits = hits; + public readonly long MissesNotBuilt = missesNotBuilt; + public readonly long MissesDirtyRebuild = missesDirtyRebuild; + public readonly long FallthroughMultiZ = fallthroughMultiZ; + public readonly long FallthroughOffMap = fallthroughOffMap; + public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; + 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 new file mode 100644 index 000000000..ede0f255e --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Engines.Pathing.Cache; + +/// +/// Singleton store of per-chunk static walkability data. Chunks correspond to +/// Map.SectorSize = 16; key encoding packs (mapId, chunkX, chunkY) into a long. +/// Lazily built on first query; invalidated by version-check vs Sector.MultisVersion; +/// memory bounded by MaxResidentChunks via probabilistic LRU eviction. +/// +/// Default-walker scope only. Cells with multi-Z surfaces and queries for non-default +/// walkers route to the MovementImpl slow path via the Fallthrough_* hit kinds. +/// +public sealed class StepCache +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(StepCache)); + + public static StepCache Instance { get; } = new(); + + private readonly Dictionary _chunks = new(); + // Parallel list of keys for O(1) random sampling during eviction. Kept in lockstep + // with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction. + private readonly List _keysList = new(); + + // Telemetry counters + private long _hits; + private long _missesNotBuilt; + private long _missesDirtyRebuild; + private long _fallthroughMultiZ; + private long _fallthroughOffMap; + private long _fallthroughSourceZMismatch; + private long _evictionsByLruCap; + private long _buildsTotal; + + private StepCache() { } + + /// Hard cap on resident chunk count. Default 8192. Override for tests / ops. + public int MaxResidentChunks { get; set; } = 8192; + + /// + /// Pack (mapId, chunkX, chunkY) into a single long key. + /// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16]. + /// + internal static long EncodeKey(int mapId, int chunkX, int chunkY) => + ((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF); + + public CacheStats GetStats() => new CacheStats( + residentChunks: _chunks.Count, + hits: _hits, + missesNotBuilt: _missesNotBuilt, + missesDirtyRebuild: _missesDirtyRebuild, + fallthroughMultiZ: _fallthroughMultiZ, + fallthroughOffMap: _fallthroughOffMap, + fallthroughSourceZMismatch: _fallthroughSourceZMismatch, + evictionsByLruCap: _evictionsByLruCap, + buildsTotal: _buildsTotal + ); + + /// + /// Drop all cached chunks AND zero every telemetry counter. Used by tests and + /// benchmarks that need a known cold-start state. Counter reset is intentional — + /// counters are since-last-clear, not since-startup. + /// + public void Clear() + { + _chunks.Clear(); + _keysList.Clear(); + _hits = 0; + _missesNotBuilt = 0; + _missesDirtyRebuild = 0; + _fallthroughMultiZ = 0; + _fallthroughOffMap = 0; + _fallthroughSourceZMismatch = 0; + _evictionsByLruCap = 0; + _buildsTotal = 0; + } + + /// + /// Probabilistic LRU sample size — picks SampleSize random resident chunks per + /// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny + /// fraction of the cost (no full sort). Redis uses the same approach (`maxmemory-samples`). + /// 5 yields ~quality-of-true-LRU for cache eviction; higher values trade speed for accuracy. + /// + private const int LruSampleSize = 5; + + /// + /// If resident chunk count exceeds MaxResidentChunks, evict via probabilistic LRU + /// until the count is at or below the cap. Per-eviction cost is O(LruSampleSize), + /// independent of resident count — sustained cap pressure has no perpetual perf hit. + /// Called from CacheEvictionTimer; also callable directly from tests. + /// + public void EnforceLruCap() + { + var overflow = _chunks.Count - MaxResidentChunks; + if (overflow <= 0) + { + return; + } + + while (overflow-- > 0 && _keysList.Count > 0) + { + var oldestIdx = -1; + long oldestTouched = long.MaxValue; + long oldestKey = 0; + + // Sample LruSampleSize random keys; track the oldest by LastTouchedTicks. + // With replacement is fine — collisions are rare and don't break correctness. + var samples = Math.Min(LruSampleSize, _keysList.Count); + for (var s = 0; s < samples; s++) + { + var idx = Utility.Random(_keysList.Count); + var k = _keysList[idx]; + var touched = _chunks[k].LastTouchedTicks; + if (touched < oldestTouched) + { + oldestTouched = touched; + oldestKey = k; + oldestIdx = idx; + } + } + + _chunks.Remove(oldestKey); + // Swap-and-pop _keysList[oldestIdx] with the tail; O(1) regardless of position. + var last = _keysList.Count - 1; + if (oldestIdx != last) + { + _keysList[oldestIdx] = _keysList[last]; + } + _keysList.RemoveAt(last); + _evictionsByLruCap++; + } + } + + internal static void DecodeKey(long key, out int mapId, out int chunkX, out int chunkY) + { + mapId = (int)((key >> 32) & 0xFFFF); + chunkX = (int)((key >> 16) & 0xFFFF); + chunkY = (int)(key & 0xFFFF); + } + + private const int ChunkSize = 16; + + /// + /// Hot-path query. Returns the cached mask + 8 destination Z values for (map, x, y, sourceZ). + /// Returns false on off-map or multi-Z fallthrough; the caller should use the slow path. + /// + public bool TryGetMask( + Map map, int x, int y, sbyte sourceZ, + out byte mask, + out sbyte destZN, out sbyte destZNE, out sbyte destZE, out sbyte destZSE, + out sbyte destZS, out sbyte destZSW, out sbyte destZW, out sbyte destZNW, + out CacheHitKind hitKind + ) + { + mask = 0; + destZN = destZNE = destZE = destZSE = destZS = destZSW = destZW = destZNW = 0; + + if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height) + { + hitKind = CacheHitKind.Fallthrough_OffMap; + _fallthroughOffMap++; + return false; + } + + var chunkX = x >> 4; + var chunkY = y >> 4; + var key = EncodeKey(map.MapID, chunkX, chunkY); + + 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; + } + else + { + var sector = map.GetRealSector(chunkX, chunkY); + if (chunk.BuiltMultisVersion != sector.MultisVersion) + { + chunk = BuildChunk(map, chunkX, chunkY); + _chunks[key] = chunk; + hitKindResult = CacheHitKind.Miss_DirtyRebuild; + // _missesDirtyRebuild++ moved into the success switch below to + // preserve the mutual-exclusivity invariant (a multi-Z fallthrough + // on a freshly dirty-rebuilt chunk must NOT count both counters). + } + } + + chunk.LastTouchedTicks = Core.TickCount; + + var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4)); + + if (chunk.IsCellMultiZ(cellIndex)) + { + hitKind = CacheHitKind.Fallthrough_MultiZ; + _fallthroughMultiZ++; + return false; + } + + // Source-Z guard: the cache stores one answer per cell baked at SourceZ. + // StepHeight tolerance accepts incremental Z jitter; loosening it breaks parity + // because tile reachability shifts at step-height boundaries. + if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight) + { + hitKind = CacheHitKind.Fallthrough_SourceZMismatch; + _fallthroughSourceZMismatch++; + return false; + } + + mask = chunk.Mask[cellIndex]; + destZN = chunk.DestZN[cellIndex]; + destZNE = chunk.DestZNE[cellIndex]; + destZE = chunk.DestZE[cellIndex]; + destZSE = chunk.DestZSE[cellIndex]; + destZS = chunk.DestZS[cellIndex]; + destZSW = chunk.DestZSW[cellIndex]; + destZW = chunk.DestZW[cellIndex]; + destZNW = chunk.DestZNW[cellIndex]; + + switch (hitKindResult) + { + case CacheHitKind.Miss_NotBuilt: + { + _missesNotBuilt++; + break; + } + case CacheHitKind.Miss_DirtyRebuild: + { + _missesDirtyRebuild++; + break; + } + case CacheHitKind.Hit: + { + _hits++; + break; + } + } + hitKind = hitKindResult; + + return true; + } + + /// + /// Chunk-miss resolution: build the chunk via the runtime baker. + /// + private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) => + BuildChunk(map, chunkX, chunkY); + + private StepChunk BuildChunk(Map map, int chunkX, int chunkY) + { + var chunk = new StepChunk(); + var sector = map.GetRealSector(chunkX, chunkY); + chunk.BuiltMultisVersion = sector.MultisVersion; + + var baseX = chunkX << 4; + var baseY = chunkY << 4; + + for (var dy = 0; dy < ChunkSize; dy++) + { + for (var dx = 0; dx < ChunkSize; dx++) + { + var x = baseX + dx; + var y = baseY + dy; + var cell = (dy << 4) | dx; + + 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); + + var result = StepProbe.ComputeMaskAt(map, x, y, standingZ); + + chunk.Mask[cell] = result.Mask; + chunk.SourceZ[cell] = standingZ; + chunk.DestZN[cell] = result.DestZ_N; + chunk.DestZNE[cell] = result.DestZ_NE; + chunk.DestZE[cell] = result.DestZ_E; + chunk.DestZSE[cell] = result.DestZ_SE; + chunk.DestZS[cell] = result.DestZ_S; + chunk.DestZSW[cell] = result.DestZ_SW; + chunk.DestZW[cell] = result.DestZ_W; + chunk.DestZNW[cell] = result.DestZ_NW; + + // Multi-Z = ≥2 surfaces reachable from standingZ. Mirrors the baker's + // CheckStaticStep filter so we don't over-mark. + if (CountReachableSurfaces(map, x, y, standingZ) > 1) + { + chunk.MarkCellMultiZ(cell); + } + } + } + + _buildsTotal++; + return chunk; + } + + 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/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs new file mode 100644 index 000000000..fb7b24d06 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -0,0 +1,50 @@ +namespace Server.Engines.Pathing.Cache; + +/// +/// Per-chunk storage backing StepCache. Holds raw walkability masks and +/// destination Z values for each of 256 cells in a 16x16 chunk, plus build-time +/// metadata (multis version, multi-Z bitmap) and LRU bookkeeping. +/// +internal sealed class StepChunk +{ + public const int CellsPerChunk = 256; // 16 x 16 + + /// Bit i of Mask[c] = "can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here. + public readonly byte[] Mask = new byte[CellsPerChunk]; + + public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk]; + + public readonly sbyte[] DestZN = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZNE = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZE = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZSE = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZS = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZSW = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZW = new sbyte[CellsPerChunk]; + public readonly sbyte[] DestZNW = new sbyte[CellsPerChunk]; + + /// + /// 32 bytes = 256 bits. Bit set = cell has >1 walkable surface; route to slow path. + /// TODO(PR2): lazy-init this. The vast majority of chunks are entirely single-Z, so + /// allocating 32 bytes per chunk wastes ~256KB at full cap. Make nullable; allocate + /// on first MarkCellMultiZ; IsCellMultiZ short-circuits to false when null. + /// + public readonly byte[] MultiZCells = new byte[32]; + + /// Snapshot of Sector.MultisVersion at the time this chunk was built. + public int BuiltMultisVersion; + + /// Updated on every cache hit/miss. Used by LRU fallback eviction. + public long LastTouchedTicks; + + /// True if any cell in this chunk has more than one walkable surface (set during build). + public bool HasAnyMultiZ; + + public bool IsCellMultiZ(int cellIndex) => (MultiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0; + + public void MarkCellMultiZ(int cellIndex) + { + MultiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); + HasAnyMultiZ = true; + } +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs new file mode 100644 index 000000000..f588b75f8 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs @@ -0,0 +1,39 @@ +namespace Server.Engines.Pathing.Cache; + +public readonly struct StepMask( + byte mask, + sbyte destZn, + sbyte destZne, + sbyte destZe, + sbyte destZse, + sbyte destZs, + sbyte destZsw, + sbyte destZw, + sbyte destZnw +) +{ + public readonly byte Mask = mask; + public readonly sbyte DestZ_N = destZn; + public readonly sbyte DestZ_NE = destZne; + public readonly sbyte DestZ_E = destZe; + public readonly sbyte DestZ_SE = destZse; + public readonly sbyte DestZ_S = destZs; + public readonly sbyte DestZ_SW = destZsw; + public readonly sbyte DestZ_W = destZw; + public readonly sbyte DestZ_NW = destZnw; + + public bool IsWalkable(Direction d) => (Mask & (1 << (int)d)) != 0; + + public sbyte GetDestZ(Direction d) => d switch + { + Direction.North => DestZ_N, + Direction.Right => DestZ_NE, + Direction.East => DestZ_E, + Direction.Down => DestZ_SE, + Direction.South => DestZ_S, + Direction.Left => DestZ_SW, + Direction.West => DestZ_W, + Direction.Up => DestZ_NW, + _ => 0 + }; +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs new file mode 100644 index 000000000..bd8aef7e7 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -0,0 +1,280 @@ +using System; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Engines.Pathing.Cache; + +/// +/// Computes static-only walkability for a single cell — the per-cell, per-direction +/// "can step" mask and destination Z, based purely on land + statics + multis. Mirrors +/// .Check minus the item and mobile collision phases. +/// +/// +/// Default-walker scope: assumes CanSwim=false, CanFly=false, CanOpenDoors=false, +/// CantWalk=false. Single source-Z per cell. Diagonal corner-cut is NOT applied here; +/// callers must AND the partner-cell results at query time per the creature rule +/// (one cardinal partner walkable suffices). +/// +public static class StepProbe +{ + private const int PersonHeight = 16; + private const int StepHeight = 2; + + public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) + { + if (map == null || map == Map.Internal) + { + return default; + } + + GetStaticStartZ(map, x, y, sourceZ, out var startZ, out var startTop, out _); + + byte mask = 0; + Span destZs = stackalloc sbyte[8]; + + for (var d = 0; d < 8; d++) + { + var dx = x; + var dy = y; + CalcMoves.Offset((Direction)d, ref dx, ref dy); + + if (CheckStaticStep(map, dx, dy, startZ, startTop, out var newZ)) + { + mask |= (byte)(1 << d); + destZs[d] = (sbyte)newZ; + } + } + + return new StepMask( + mask, + destZs[0], destZs[1], destZs[2], destZs[3], + destZs[4], destZs[5], destZs[6], destZs[7] + ); + } + + /// + /// Returns the slow path's standing-Z for a default walker at (x, y) with hint locZ. + /// This is the Z the creature ends up STANDING AT — typically the topmost walkable + /// surface that's reachable from locZ (paver Z+1 for paver-over-ground; landCenter + /// for bare land). Mirrors MovementImpl.Check's surface-selection logic for the + /// destination cell, distilled to "what Z value does the slow path return as newZ + /// when stepping ONTO this cell". Used by StepCache to bake SourceZ + /// correctly so A*'s tracked-per-cell Z matches the cache's bake-time assumption. + /// + public static int ComputeStandingZ(Map map, int x, int y, int locZ) + { + GetStaticStartZ(map, x, y, locZ, out _, out _, out var zCenter); + return zCenter; + } + + /// + /// Mirrors GetStartZ from MovementImpl, but static-only (no item list). + /// Assumes default walker: CanSwim=false, CantWalk=false. + /// + private static void GetStaticStartZ(Map map, int x, int y, int locZ, out int zLow, out int zTop, out int zCenter) + { + var landTile = map.Tiles.GetLandTile(x, y); + var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var impassable = (flags & TileFlag.Impassable) != 0; + + // CantWalk=false, CanSwim=false → landBlocks = impassable + var landBlocks = impassable; + + map.GetAverageZ(x, y, out var landZ, out var landCenter, out var landTop); + + var considerLand = !landTile.Ignored; + + zCenter = zLow = zTop = 0; + var isSet = false; + + if (considerLand && !landBlocks && locZ >= landCenter) + { + zLow = landZ; + zCenter = landCenter; + zTop = landTop; + isSet = true; + } + + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var calcTop = tile.Z + id.CalcHeight; + + // CanSwim=false → only check Surface; CantWalk=false + if (isSet && calcTop < zCenter || locZ < calcTop || !id.Surface) + { + continue; + } + + zLow = tile.Z; + zCenter = calcTop; + + var top = tile.Z + id.Height; + + if (!isSet || top > zTop) + { + zTop = top; + } + + isSet = true; + } + + if (!isSet) + { + zLow = zTop = locZ; + } + else if (locZ > zTop) + { + zTop = locZ; + } + } + + /// + /// Mirrors MovementImpl.Check for static tiles only. + /// Assumes default walker: CanSwim=false, CanFly=false, CantWalk=false, + /// AlwaysIgnoreDoors=false. Items and mobile collision phases are omitted. + /// + private static bool CheckStaticStep(Map map, int x, int y, int startZ, int startTop, out int newZ) + { + newZ = 0; + + if (x < 0 || y < 0 || x >= map.Width || y >= map.Height) + { + return false; + } + + var landTile = map.Tiles.GetLandTile(x, y); + var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var impassable = (flags & TileFlag.Impassable) != 0; + + // CantWalk=false, CanSwim=false → landBlocks = impassable + var landBlocks = impassable; + + var considerLand = !landTile.Ignored; + + map.GetAverageZ(x, y, out var landZ, out var landCenter, out _); + + var moveIsOk = false; + + var stepTop = startTop + StepHeight; + var checkTop = startZ + PersonHeight; + + int testTop; + + foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + + // CanSwim=false, CantWalk=false: + // Skip if not a passable surface (no swim path either) + if (!itemData.Surface || itemData.Impassable) + { + continue; + } + + var itemZ = tile.Z; + var itemTop = itemZ; + var ourZ = itemZ + itemData.CalcHeight; + testTop = checkTop; + + // Pick the candidate closest to startZ; ties broken by higher ourZ + if (moveIsOk) + { + var cmp = Math.Abs(ourZ - startZ) - Math.Abs(newZ - startZ); + + if (cmp > 0 || cmp == 0 && ourZ > newZ) + { + continue; + } + } + + if (ourZ + PersonHeight > testTop) + { + testTop = ourZ + PersonHeight; + } + + if (!itemData.Bridge) + { + itemTop += itemData.Height; + } + + if (stepTop < itemTop) + { + continue; + } + + var landCheck = itemZ + Math.Min(itemData.Height, StepHeight); + + if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ) + { + continue; + } + + // IsOk equivalent: check static tiles don't block (ourZ, testTop) space + if (StaticsBlockAt(map, x, y, ourZ, testTop)) + { + continue; + } + + newZ = ourZ; + moveIsOk = true; + } + + // Land surface fallback (mirrors Check's land block at the bottom) + if (!considerLand || landBlocks || stepTop < landZ) + { + return moveIsOk; + } + + testTop = checkTop; + + if (landCenter + PersonHeight > testTop) + { + testTop = landCenter + PersonHeight; + } + + var shouldCheck = true; + + if (moveIsOk) + { + var cmp = Math.Abs(landCenter - startZ) - Math.Abs(newZ - startZ); + + if (cmp > 0 || cmp == 0 && landCenter > newZ) + { + shouldCheck = false; + } + } + + if (shouldCheck && !StaticsBlockAt(map, x, y, landCenter, testTop)) + { + newZ = landCenter; + moveIsOk = true; + } + + return moveIsOk; + } + + /// + /// Mirrors the static-tile portion of IsOk: returns true if any static tile at (x,y) + /// has ImpassableSurface and overlaps the vertical range (ourZ, testTop). + /// + private static bool StaticsBlockAt(Map map, int x, int y, int ourZ, int testTop) + { + foreach (var check in map.Tiles.GetStaticAndMultiTiles(x, y)) + { + var itemData = TileData.ItemTable[check.ID & TileData.MaxItemValue]; + + if (itemData.ImpassableSurface) + { + var checkZ = check.Z; + var checkTop = checkZ + itemData.CalcHeight; + + if (checkTop > ourZ && testTop > checkZ) + { + return true; + } + } + } + + return false; + } +} diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index 2ae2e714b..5c475f51c 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -1,8 +1,9 @@ using System; using System.Diagnostics; +using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; -using Server.PathAlgorithms.FastAStar; +using Server.PathAlgorithms.BitmapAStar; using Server.Spells; using Server.Targeting; @@ -31,7 +32,7 @@ namespace Server try { - var alg = OverrideAlgorithm ?? FastAStarAlgorithm.Instance; + var alg = OverrideAlgorithm ?? BitmapAStarAlgorithm.Instance; if (alg?.CheckCondition(m, map, start, goal) == true) { @@ -59,6 +60,7 @@ namespace Server public static void Configure() { CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); + CacheEvictionTimer.Configure(); } [Usage("Path")] @@ -111,7 +113,7 @@ namespace Server SpellHelper.GetSurfaceTop(ref p); - Path(from, p, FastAStarAlgorithm.Instance, "Fast", 0); + Path(from, p, BitmapAStarAlgorithm.Instance, "Bitmap", 0); OverrideAlgorithm = null; } }