diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 347af7f19..04cb41831 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,6 +1,7 @@ using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.PathAlgorithms.BitmapAStar; +using Server.Systems.FeatureFlags; using Xunit; using Xunit.Abstractions; @@ -54,7 +55,7 @@ public class BitmapAStarAlgorithmTests [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) + public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy) { StepCache.Instance.Clear(); var map = Map.Maps[1]; @@ -62,27 +63,270 @@ public class BitmapAStarAlgorithmTests var stub = new SwimmingStub(World.NewMobile); stub.DefaultMobileInit(); - stub.CanSwim = true; // forces non-default-walker → inline GetSuccessorsSlowPath + stub.CanSwim = true; // overlay route — cache + (walkMask | wetMask&canSwim) 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 statsBefore = StepCache.Instance.GetStats(); var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); 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); + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "CanSwim creature should use the cache via capability overlay, not the slow path"); _output.WriteLine($"swimmer ({sx},{sy})->({gx},{gy}): {result.Length} steps"); } + [Fact] + public void DynamicObstaclePass_RejectsCellOccupiedByLivingMobile() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + // (1500, 1600) walks N/W/NW only (mask=0xC1). Path NW two cells; plant a blocker + // on the direct NW step so the algorithm must route via N→W or W→N around it. + var sx = 1500; + var sy = 1600; + var gx = 1498; + var gy = 1598; + var blockX = 1499; + var blockY = 1599; + + 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); + + var walker = new DefaultWalkerStub(); + walker.MoveToWorld(start, map); + + // Living blocker on the only direct-line cell. CanMoveOver returns false for an + // alive non-staff mobile, so the dynamic-obstacle pass must reject this cell. + map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); + var blocker = new DefaultWalkerStub(); + blocker.MoveToWorld(new Point3D(blockX, blockY, (sbyte)blockZ), map); + + var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); + + // Path may exist via an alternate route, but must NOT pass through the blocker. + Assert.NotNull(result); + var x = sx; + var y = sy; + foreach (var dir in result) + { + Server.Movement.Movement.Offset(dir, ref x, ref y); + Assert.False(x == blockX && y == blockY, + $"path traversed blocker cell ({blockX},{blockY})"); + } + + walker.Delete(); + blocker.Delete(); + } + + [Fact] + public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + // Same NW-around-blocker scenario as the mobile-blocker test, but with an item. + var sx = 1500; + var sy = 1600; + var gx = 1498; + var gy = 1598; + var blockX = 1499; + var blockY = 1599; + + // Find any ItemID whose TileData has ImpassableSurface so the dynamic pass + // rejects the cell. Pinning to a specific ID would couple the test to UO art data. + ushort blockerItemId = 0; + for (ushort id = 1; id < TileData.MaxItemValue; id++) + { + if (TileData.ItemTable[id].ImpassableSurface) + { + blockerItemId = id; + break; + } + } + Assert.NotEqual(0, blockerItemId); + + 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); + + var walker = new DefaultWalkerStub(); + walker.MoveToWorld(start, map); + + map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); + var blocker = new Item(World.NewItem) + { + ItemID = blockerItemId, + Map = map, + Location = new Point3D(blockX, blockY, (sbyte)blockZ) + }; + + var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); + + Assert.NotNull(result); + var x = sx; + var y = sy; + foreach (var dir in result) + { + Server.Movement.Movement.Offset(dir, ref x, ref y); + Assert.False(x == blockX && y == blockY, + $"path traversed item-blocker cell ({blockX},{blockY})"); + } + + walker.Delete(); + blocker.Delete(); + } + + [Fact] + public void FeatureFlagDisabled_RoutesToSlowPath_NoCacheUse() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + 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 statsBefore = StepCache.Instance.GetStats(); + var prevFlag = ContentFeatureFlags.BitmapPathfindingCache; + try + { + ContentFeatureFlags.BitmapPathfindingCache = false; + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + Assert.NotNull(result); + Assert.NotEmpty(result); + } + finally + { + ContentFeatureFlags.BitmapPathfindingCache = prevFlag; + } + var statsAfter = StepCache.Instance.GetStats(); + + stub.Delete(); + Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal); + } + + [Fact] + public void FlyCreature_RoutesToSlowPath_NoCacheUse() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + + var stub = new FlyingStub(World.NewMobile); + stub.DefaultMobileInit(); + 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 statsBefore = StepCache.Instance.GetStats(); + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); + + stub.Delete(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal); + } + + [Fact] + public void NonGmPlayer_UsesCache_WithStrictDiagonalRule() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + Assert.NotNull(map); + + var stub = new PlayerStub(); + 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 statsBefore = StepCache.Instance.GetStats(); + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); + + stub.Delete(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + // BuildsTotal increments on every chunk build, which happens only when the cache + // is queried. Slow path never touches the cache. + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "Non-GM player should use the cache, not the slow path"); + } + + [Fact] + public void DoorCreature_UsesCache_NotSlowPath() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + + var stub = new DoorOpenerStub(World.NewMobile); + stub.DefaultMobileInit(); + 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 statsBefore = StepCache.Instance.GetStats(); + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); + + stub.Delete(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "CanOpenDoors creature should use the cache (doors are dynamic items)"); + } + + [Fact] + public void ObstacleCreature_UsesCache_NotSlowPath() + { + StepCache.Instance.Clear(); + var map = Map.Maps[1]; + + var stub = new ObstacleClimberStub(World.NewMobile); + stub.DefaultMobileInit(); + 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 statsBefore = StepCache.Instance.GetStats(); + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); + + stub.Delete(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "CanMoveOverObstacles creature should use the cache (movables are dynamic items)"); + } + /// - /// Plain Mobile — IsDefaultWalker returns true, the bitmap algorithm uses the cache + /// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache /// fast path on every expansion. /// private sealed class DefaultWalkerStub : Mobile @@ -94,10 +338,23 @@ public class BitmapAStarAlgorithmTests } /// - /// 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. + /// Mobile with Player=true and default AccessLevel (Player). Triggers the strict + /// AND-rule for diagonal corner-cut while still using the cache. + /// + private sealed class PlayerStub : Mobile + { + public PlayerStub() + { + Body = 0xC9; + Player = true; + } + } + + /// + /// BaseCreature with CanSwim=true — uses the cache via capability overlay (walk OR + /// (wet AND canSwim)). 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 { @@ -106,4 +363,38 @@ public class BitmapAStarAlgorithmTests Body = 0xC9; } } + + /// + /// BaseCreature with CanFly=true — RequiresSlowPath returns true (Z-jumping is beyond + /// the cache's static-only scope), so GetSuccessors short-circuits to the slow path. + /// + private sealed class FlyingStub : BaseCreature + { + public FlyingStub(Serial serial) : base(serial) + { + Body = 0xC9; + } + + public override bool CanFly => true; + } + + private sealed class DoorOpenerStub : BaseCreature + { + public DoorOpenerStub(Serial serial) : base(serial) + { + Body = 0xC9; + } + + public override bool CanOpenDoors => true; + } + + private sealed class ObstacleClimberStub : BaseCreature + { + public ObstacleClimberStub(Serial serial) : base(serial) + { + Body = 0xC9; + } + + public override bool CanMoveOverObstacles => true; + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 76a765926..f57ef4728 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -35,20 +35,14 @@ public class StepCacheLifecycleTests 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 - ); + var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); - 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); + 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); var stats = cache.GetStats(); Assert.Equal(1, stats.ResidentChunks); @@ -56,15 +50,10 @@ public class StepCacheLifecycleTests 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 lookup2 = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + Assert.True(lookup2.IsHit); + Assert.Equal(CacheHitKind.Hit, lookup2.HitKind); + Assert.Equal((byte)0xC1, lookup2.WalkMask); var stats2 = cache.GetStats(); Assert.Equal(1, stats2.ResidentChunks); @@ -79,15 +68,11 @@ public class StepCacheLifecycleTests 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 - ); + var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0); - Assert.False(ok); - Assert.Equal(CacheHitKind.Fallthrough_OffMap, hitKind); - Assert.Equal((byte)0, mask); + Assert.False(lookup.IsHit); + Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind); + Assert.Equal((byte)0, lookup.WalkMask); } [Fact] @@ -100,10 +85,7 @@ public class StepCacheLifecycleTests 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); + Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); // Bump _multisVersion via reflection. var versionField = typeof(Map.Sector).GetField( @@ -115,10 +97,7 @@ public class StepCacheLifecycleTests 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); + Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind); var stats = cache.GetStats(); Assert.Equal(1L, stats.MissesDirtyRebuild); @@ -142,8 +121,7 @@ public class StepCacheLifecycleTests 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 _); + cache.TryGetMask(map, 1500, 1600, 10); // Snapshot current FallthroughMultiZ in case (1500, 1600) is naturally multi-Z // in real tile data; we only assert the synthetic injection produces a delta of 1. @@ -165,12 +143,10 @@ public class StepCacheLifecycleTests 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); + var lookup = cache.TryGetMask(map, 1500, 1600, 10); - Assert.False(ok); - Assert.Equal(CacheHitKind.Fallthrough_MultiZ, hitKind); + Assert.False(lookup.IsHit); + Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind); var stats = cache.GetStats(); Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ); @@ -192,8 +168,7 @@ public class StepCacheLifecycleTests { var x = 1500 + (i * 16); var y = 1600; - cache.TryGetMask(map, x, y, 10, - out _, out _, out _, out _, out _, out _, out _, out _, out _, out _); + cache.TryGetMask(map, x, y, 10); System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index a0b494677..8d7de8b6b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -29,6 +29,7 @@ public class StepCacheParityTests var disagreements = 0; var samples = 0; var multiZ = 0; + var wetCells = 0; for (var x = xStart; x < xStart + size; x++) { @@ -43,41 +44,49 @@ public class StepCacheParityTests 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 - ); + var lookup = cache.TryGetMask(map, x, y, sourceZ); samples++; - if (hitKind == CacheHitKind.Fallthrough_MultiZ) + if (lookup.HitKind == CacheHitKind.Fallthrough_MultiZ) { multiZ++; continue; } - Assert.True(ok, $"Cache returned !ok at ({x},{y}) hitKind={hitKind}"); + Assert.True(lookup.IsHit, $"Cache returned !ok at ({x},{y}) hitKind={lookup.HitKind}"); - if (mask != baker.Mask) + if (lookup.WalkMask != baker.WalkMask) { disagreements++; - _output.WriteLine($"MASK DIFF @ ({x},{y}) cache=0x{mask:X2} baker=0x{baker.Mask:X2}"); + _output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{lookup.WalkMask:X2} baker=0x{baker.WalkMask: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) + if (lookup.WetMask != baker.WetMask) { 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($"WET MASK DIFF @ ({x},{y}) cache=0x{lookup.WetMask:X2} baker=0x{baker.WetMask:X2}"); + continue; + } + + if (lookup.WetMask != 0) + { + wetCells++; + } + + if (lookup.WalkZ_N != baker.WalkZ_N || lookup.WalkZ_NE != baker.WalkZ_NE + || lookup.WalkZ_E != baker.WalkZ_E || lookup.WalkZ_SE != baker.WalkZ_SE + || lookup.WalkZ_S != baker.WalkZ_S || lookup.WalkZ_SW != baker.WalkZ_SW + || lookup.WalkZ_W != baker.WalkZ_W || lookup.WalkZ_NW != baker.WalkZ_NW) + { + disagreements++; + _output.WriteLine($"Z DIFF @ ({x},{y}) cache=({lookup.WalkZ_N},{lookup.WalkZ_NE},{lookup.WalkZ_E},{lookup.WalkZ_SE},{lookup.WalkZ_S},{lookup.WalkZ_SW},{lookup.WalkZ_W},{lookup.WalkZ_NW}) baker=({baker.WalkZ_N},{baker.WalkZ_NE},{baker.WalkZ_E},{baker.WalkZ_SE},{baker.WalkZ_S},{baker.WalkZ_SW},{baker.WalkZ_W},{baker.WalkZ_NW})"); } } } - _output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}"); + _output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ} wetCells={wetCells}"); // Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer. if (label == "britain_inn_dense") @@ -87,4 +96,40 @@ public class StepCacheParityTests Assert.Equal(0, disagreements); } + + /// + /// Non-vacuity guard for the swim bake: scans a wide swath of the south-Britain bay + /// (Atlantic coast) and asserts at least one cell has a non-zero WetMask. Catches the + /// failure mode where StepProbe silently bakes zero swim output everywhere. + /// + [Fact] + public void SwimBake_ProducesWetCells_OnKnownWaterRegion() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + + // South Britain → Britain bay, includes Atlantic shoreline. 64×64 = 4096 cells; + // even a partial coastline straddle should yield dozens of wet cells. + const int xStart = 1430; + const int yStart = 1740; + const int size = 64; + + var wetCells = 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)StepProbe.ComputeStandingZ(map, x, y, avgZ); + var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ); + if (baker.WetMask != 0) + { + wetCells++; + } + } + } + + _output.WriteLine($"south-britain swim probe: wetCells={wetCells} of 4096"); + Assert.True(wetCells > 0, "swim bake produced zero wet cells across a 64×64 coastal region"); + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs index 8bea2f7f3..25b80773d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs @@ -62,7 +62,7 @@ public class StaticWalkabilityParityTests } } - var newZ = bakerResult.GetDestZ(dir); + var newZ = bakerResult.GetWalkZ(dir); if (oldOk) { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs index 8f9011039..cfca235da 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs @@ -19,10 +19,10 @@ public class StepProbeTests { var result = StepProbe.ComputeMaskAt(null, 100, 100, 0); - Assert.Equal(0, result.Mask); + Assert.Equal(0, result.WalkMask); for (var d = 0; d < 8; d++) { - Assert.Equal(0, result.GetDestZ((Direction)d)); + Assert.Equal(0, result.GetWalkZ((Direction)d)); } } @@ -31,10 +31,10 @@ public class StepProbeTests { var result = StepProbe.ComputeMaskAt(Map.Internal, 100, 100, 0); - Assert.Equal(0, result.Mask); + Assert.Equal(0, result.WalkMask); for (var d = 0; d < 8; d++) { - Assert.Equal(0, result.GetDestZ((Direction)d)); + Assert.Equal(0, result.GetWalkZ((Direction)d)); } } @@ -98,7 +98,7 @@ public class StepProbeTests var sourceZ = (sbyte)avgZ; var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - if (result.Mask != 0xFF) + if (result.WalkMask != 0xFF) { continue; } @@ -106,7 +106,7 @@ public class StepProbeTests var allSameZ = true; for (var d = 0; d < 8; d++) { - if (result.GetDestZ((Direction)d) != sourceZ) + if (result.GetWalkZ((Direction)d) != sourceZ) { allSameZ = false; break; @@ -143,7 +143,7 @@ public class StepProbeTests var sourceZ = (sbyte)avgZ; var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - if (result.Mask != 0xFF) + if (result.WalkMask != 0xFF) { blockedCellCount++; } @@ -172,12 +172,12 @@ public class StepProbeTests 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); + Assert.Equal(first.WalkMask, second.WalkMask); + Assert.Equal(first.WalkMask, third.WalkMask); 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)); + Assert.Equal(first.GetWalkZ((Direction)d), second.GetWalkZ((Direction)d)); + Assert.Equal(first.GetWalkZ((Direction)d), third.GetWalkZ((Direction)d)); } } @@ -198,7 +198,7 @@ public class StepProbeTests var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ); Assert.Equal((sbyte)10, sourceZ); - Assert.Equal((byte)0xC1, result.Mask); + Assert.Equal((byte)0xC1, result.WalkMask); Assert.True(result.IsWalkable(Direction.North)); Assert.False(result.IsWalkable(Direction.Right)); @@ -209,9 +209,9 @@ public class StepProbeTests 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)); + Assert.Equal((sbyte)10, result.GetWalkZ(Direction.North)); + Assert.Equal((sbyte)10, result.GetWalkZ(Direction.West)); + Assert.Equal((sbyte)10, result.GetWalkZ(Direction.Up)); } [Fact] @@ -229,7 +229,7 @@ public class StepProbeTests var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ); Assert.Equal((sbyte)20, sourceZ); - Assert.Equal((byte)0x3F, result.Mask); + Assert.Equal((byte)0x3F, result.WalkMask); Assert.True(result.IsWalkable(Direction.North)); Assert.True(result.IsWalkable(Direction.Right)); @@ -242,7 +242,7 @@ public class StepProbeTests for (var d = 0; d < 6; d++) { - Assert.Equal((sbyte)20, result.GetDestZ((Direction)d)); + Assert.Equal((sbyte)20, result.GetWalkZ((Direction)d)); } } } diff --git a/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs index e6d940fbd..5b960d76d 100644 --- a/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs +++ b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs @@ -14,4 +14,5 @@ public static class ContentFeatureFlags public static bool BulkOrders { get; set; } = true; public static bool PassiveDetectHidden { get; set; } = true; public static bool YoungPlayerSystem { get; set; } = true; + public static bool BitmapPathfindingCache { get; set; } = true; } diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs index 1c4bd4321..0402ad9a1 100644 --- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -976,6 +976,7 @@ public static class FeatureFlagManager "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, + "bitmap_pathfinding_cache" => ContentFeatureFlags.BitmapPathfindingCache = enabled, }; } diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index 7298a1c47..17e1aa882 100644 --- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using Server.Engines.Pathing.Cache; using Server.Mobiles; +using Server.Systems.FeatureFlags; using CalcMoves = Server.Movement.Movement; using MoveImpl = Server.Movement.MovementImpl; @@ -61,9 +62,27 @@ public class BitmapAStarAlgorithm : PathAlgorithm 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. + // (creature has CanFly — Z-jumping is beyond the cache's static-only scope). private static bool _currentMobileNeedsSlowPath; + // When set, diagonal corner-cut uses the strict AND-rule (BOTH cardinal partners + // must be walkable) instead of the lenient creature OR-rule. Cache still applies — + // partner bits live in the same source-cell mask byte. Non-GM players only. + private static bool _currentMobilePlayerStrict; + + // Capability overlay applied to cache results. Layered each cell: + // effective = (walkMask & !cantWalk) | (wetMask & canSwim) + // Reset at end of Find. + private static bool _currentMobileCanSwim; + private static bool _currentMobileCantWalk; + + // Dynamic-obstacle pass capability flags (per-mobile, captured in Find). + // Mirrors MovementImpl.Check's per-mobile derivations so per-cell items/mobiles + // checks can be evaluated without re-deriving. + private static bool _currentMobileIgnoreDoors; + private static bool _currentMobileIgnoreSpellFields; + private static bool _currentMobileIgnoreMovableImpassables; + private Point3D _goal; [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -89,7 +108,25 @@ public class BitmapAStarAlgorithm : PathAlgorithm return null; } - _currentMobileNeedsSlowPath = !IsDefaultWalker(m); + _currentMobileNeedsSlowPath = RequiresSlowPath(m); + _currentMobilePlayerStrict = m.Player && m.AccessLevel < AccessLevel.GameMaster; + if (m is BaseCreature creature) + { + _currentMobileCanSwim = creature.CanSwim; + _currentMobileCantWalk = creature.CantWalk; + _currentMobileIgnoreDoors = creature.CanOpenDoors; + _currentMobileIgnoreMovableImpassables = creature.CanMoveOverObstacles; + } + else + { + _currentMobileCanSwim = false; + _currentMobileCantWalk = false; + _currentMobileIgnoreDoors = false; + _currentMobileIgnoreMovableImpassables = false; + } + // Mirrors MovementImpl: dead/spectral mobiles also ignore doors. + _currentMobileIgnoreDoors |= !m.Alive || m.Body.BodyID == 0x3DB || m.IsDeadBondedPet; + _currentMobileIgnoreSpellFields = m is PlayerMobile && map != Map.Felucca; Array.Clear(_nodeStates); @@ -220,12 +257,24 @@ public class BitmapAStarAlgorithm : PathAlgorithm _openQueue.Clear(); _currentMobileNeedsSlowPath = false; + _currentMobilePlayerStrict = false; + _currentMobileCanSwim = false; + _currentMobileCantWalk = false; + _currentMobileIgnoreDoors = false; + _currentMobileIgnoreSpellFields = false; + _currentMobileIgnoreMovableImpassables = false; return dirs; } } _openQueue.Clear(); _currentMobileNeedsSlowPath = false; + _currentMobilePlayerStrict = false; + _currentMobileCanSwim = false; + _currentMobileCantWalk = false; + _currentMobileIgnoreDoors = false; + _currentMobileIgnoreSpellFields = false; + _currentMobileIgnoreMovableImpassables = false; return null; } @@ -256,28 +305,26 @@ public class BitmapAStarAlgorithm : PathAlgorithm var vals = _successors; - if (_currentMobileNeedsSlowPath) + if (_currentMobileNeedsSlowPath || !ContentFeatureFlags.BitmapPathfindingCache) { 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 - ); + var lookup = StepCache.Instance.TryGetMask(map, p3D.X, p3D.Y, (sbyte)p3D.Z); - if (hitKind is CacheHitKind.Fallthrough_MultiZ - or CacheHitKind.Fallthrough_OffMap - or CacheHitKind.Fallthrough_SourceZMismatch) + if (!lookup.IsHit) { return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); } + // Capability overlay: walking allowed unless cantWalk; swimming allowed if canSwim. + // Partner bits used for diagonal corner-cut also use the effective mask. + var walkBits = _currentMobileCantWalk ? (byte)0 : lookup.WalkMask; + var swimBits = _currentMobileCanSwim ? lookup.WetMask : (byte)0; + var mask = (byte)(walkBits | swimBits); + for (var i = 0; i < 8; ++i) { var x = px; @@ -294,31 +341,62 @@ public class BitmapAStarAlgorithm : PathAlgorithm continue; } - // Diagonal corner-cut (creature OR-rule): partner bits live in the same mask byte. + // Diagonal corner-cut. Creatures (default): OR-rule — at least one cardinal + // partner walkable. Non-GM players: AND-rule — BOTH partners must be walkable. + // Partner bits live in the same source-cell mask byte either way. if ((i & 1) == 1) { var leftBit = 1 << ((i - 1) & 0x7); var rightBit = 1 << ((i + 1) & 0x7); - if ((mask & leftBit) == 0 && (mask & rightBit) == 0) + if (_currentMobilePlayerStrict + ? (mask & leftBit) == 0 || (mask & rightBit) == 0 + : (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 - }; + // Walking takes precedence over swimming when both apply (matches MovementImpl's + // surface-selection: closest-to-startZ wins, and walk surface is always closer + // when the creature is currently standing on land). + var useWalkZ = (walkBits & (1 << i)) != 0; + var z = useWalkZ + ? i switch + { + 0 => lookup.WalkZ_N, + 1 => lookup.WalkZ_NE, + 2 => lookup.WalkZ_E, + 3 => lookup.WalkZ_SE, + 4 => lookup.WalkZ_S, + 5 => lookup.WalkZ_SW, + 6 => lookup.WalkZ_W, + 7 => lookup.WalkZ_NW, + _ => (sbyte)0 + } + : i switch + { + 0 => lookup.SwimZ_N, + 1 => lookup.SwimZ_NE, + 2 => lookup.SwimZ_E, + 3 => lookup.SwimZ_SE, + 4 => lookup.SwimZ_S, + 5 => lookup.SwimZ_SW, + 6 => lookup.SwimZ_W, + 7 => lookup.SwimZ_NW, + _ => (sbyte)0 + }; - var idx = GetIndex(x + _xOffset, y + _yOffset, z); + var absX = x + _xOffset; + var absY = y + _yOffset; + + // Dynamic-obstacle pass: items + mobiles at the target cell. Cache only + // covers static walkability; dynamic state has to be checked at query time. + if (IsBlockedByDynamic(m, map, absX, absY, z)) + { + continue; + } + + var idx = GetIndex(absX, absY, z); if (idx >= 0 && idx < NodeCount) { @@ -330,6 +408,79 @@ public class BitmapAStarAlgorithm : PathAlgorithm return count; } + private const int PersonHeightConst = 16; + private const int MobileHeight = 15; + + /// + /// Mirrors MovementImpl's dynamic-item / mobile collision phase for a target cell. + /// Items: ImpassableSurface that overlap (z, z+PersonHeight), respecting capability + /// overrides (CanOpenDoors → ignore door items; CanMoveOverObstacles → ignore movables; + /// non-Felucca players → ignore spell fields). Mobiles: any other mobile whose Z range + /// overlaps and which we can't move over. + /// + private static bool IsBlockedByDynamic(Mobile m, Map map, int x, int y, int z) + { + var ourTop = z + PersonHeightConst; + + foreach (var item in map.GetItemsAt(x, y)) + { + var itemData = item.ItemData; + if (!itemData.ImpassableSurface) + { + continue; + } + + if (_currentMobileIgnoreMovableImpassables && item.Movable) + { + continue; + } + + var itemId = item.ItemID & TileData.MaxItemValue; + if (_currentMobileIgnoreDoors + && (itemData.Door + || itemId is 0x692 or 0x846 or 0x873 + || itemId >= 0x6F5 && itemId <= 0x6F6)) + { + continue; + } + + if (_currentMobileIgnoreSpellFields && itemId is 0x82 or 0x3946 or 0x3956) + { + continue; + } + + var checkZ = item.Z; + var checkTop = checkZ + itemData.CalcHeight; + if (checkTop > z && ourTop > checkZ) + { + return true; + } + } + + foreach (var mob in map.GetMobilesAt(x, y)) + { + if (mob == m) + { + continue; + } + + if (mob.Z + MobileHeight > z && z + MobileHeight > mob.Z && !CanMoveOver(m, mob)) + { + return true; + } + } + + return false; + } + + /// + /// Mirrors MovementImpl.CanMoveOver — true when m can step onto t's cell (dead bodies, + /// hidden staff, etc.). + /// + private static bool CanMoveOver(Mobile m, Mobile t) => + !t.Alive || !m.Alive || t.IsDeadBondedPet || m.IsDeadBondedPet + || t.Hidden && t.AccessLevel > AccessLevel.Player; + /// /// Per-direction loop for a single source cell. /// Runs on cache fallthrough or when is set. @@ -365,22 +516,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm } /// - /// 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. + /// True for creatures whose movement rules the static cache can't model. Currently + /// only CanFly — flying creatures Z-jump arbitrarily and the cache's source-Z guard + /// would over-fire. CanSwim / CantWalk are handled via the capability overlay (walkMask + /// + wetMask). CanOpenDoors / CanMoveOverObstacles only affect dynamic items and don't + /// disqualify the cache. /// - 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; - } + private static bool RequiresSlowPath(Mobile m) => + m is BaseCreature bc && bc.CanFly; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs index b38045741..24847235c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs @@ -1,8 +1,9 @@ namespace Server.Engines.Pathing.Cache; /// -/// Outcome categories for StaticWalkabilityCache.TryGetMask. Used for telemetry -/// and to drive the slow-path fallthrough decision in callers. +/// 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). /// public enum CacheHitKind : byte { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index ede0f255e..060e57336 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -143,25 +143,16 @@ public sealed class StepCache 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. + /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind. + /// Inspect to decide whether to use the result or fall + /// back to 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 - ) + public StepMask TryGetMask(Map map, int x, int y, sbyte sourceZ) { - 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; + return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_OffMap); } var chunkX = x >> 4; @@ -184,9 +175,8 @@ public sealed class StepCache 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). + // _missesDirtyRebuild++ deferred to the outcome switch below so a + // multi-Z fallthrough on a freshly dirty-rebuilt chunk doesn't double-count. } } @@ -196,9 +186,8 @@ public sealed class StepCache if (chunk.IsCellMultiZ(cellIndex)) { - hitKind = CacheHitKind.Fallthrough_MultiZ; _fallthroughMultiZ++; - return false; + return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_MultiZ); } // Source-Z guard: the cache stores one answer per cell baked at SourceZ. @@ -206,42 +195,38 @@ public sealed class StepCache // because tile reachability shifts at step-height boundaries. if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight) { - hitKind = CacheHitKind.Fallthrough_SourceZMismatch; _fallthroughSourceZMismatch++; - return false; + return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_SourceZMismatch); } - 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; - } + case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; } + case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; } + case CacheHitKind.Hit: { _hits++; break; } } - hitKind = hitKindResult; - return true; + return new StepMask( + chunk.WalkMask[cellIndex], + chunk.WetMask[cellIndex], + chunk.WalkZN[cellIndex], + chunk.WalkZNE[cellIndex], + chunk.WalkZE[cellIndex], + chunk.WalkZSE[cellIndex], + chunk.WalkZS[cellIndex], + chunk.WalkZSW[cellIndex], + chunk.WalkZW[cellIndex], + chunk.WalkZNW[cellIndex], + chunk.SwimZN[cellIndex], + chunk.SwimZNE[cellIndex], + chunk.SwimZE[cellIndex], + chunk.SwimZSE[cellIndex], + chunk.SwimZS[cellIndex], + chunk.SwimZSW[cellIndex], + chunk.SwimZW[cellIndex], + chunk.SwimZNW[cellIndex], + hitKindResult + ); } /// @@ -276,16 +261,25 @@ public sealed class StepCache 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; + chunk.WalkMask[cell] = result.WalkMask; + chunk.WetMask[cell] = result.WetMask; + chunk.SourceZ[cell] = standingZ; + chunk.WalkZN[cell] = result.WalkZ_N; + chunk.WalkZNE[cell] = result.WalkZ_NE; + chunk.WalkZE[cell] = result.WalkZ_E; + chunk.WalkZSE[cell] = result.WalkZ_SE; + chunk.WalkZS[cell] = result.WalkZ_S; + chunk.WalkZSW[cell] = result.WalkZ_SW; + chunk.WalkZW[cell] = result.WalkZ_W; + chunk.WalkZNW[cell] = result.WalkZ_NW; + chunk.SwimZN[cell] = result.SwimZ_N; + chunk.SwimZNE[cell] = result.SwimZ_NE; + chunk.SwimZE[cell] = result.SwimZ_E; + chunk.SwimZSE[cell] = result.SwimZ_SE; + chunk.SwimZS[cell] = result.SwimZ_S; + chunk.SwimZSW[cell] = result.SwimZ_SW; + chunk.SwimZW[cell] = result.SwimZ_W; + chunk.SwimZNW[cell] = result.SwimZ_NW; // Multi-Z = ≥2 surfaces reachable from standingZ. Mirrors the baker's // CheckStaticStep filter so we don't over-mark. diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs index fb7b24d06..c49ab5606 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -1,35 +1,45 @@ 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. +/// Per-chunk storage backing StepCache. Holds raw walk + swim 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]; + /// Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here. + public readonly byte[] WalkMask = new byte[CellsPerChunk]; + + /// Bit i of WetMask[c] = "swim-only mob can step from cell c to neighbor (Direction)i". Layered with WalkMask via canSwim/cantWalk capability flags. + public readonly byte[] WetMask = 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]; + public readonly sbyte[] WalkZN = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZNE = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZE = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZSE = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZS = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZSW = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZW = new sbyte[CellsPerChunk]; + public readonly sbyte[] WalkZNW = new sbyte[CellsPerChunk]; + + public readonly sbyte[] SwimZN = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZNE = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZE = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZSE = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZS = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZSW = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZW = new sbyte[CellsPerChunk]; + public readonly sbyte[] SwimZNW = 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. + /// 32 bytes = 256 bits when allocated. Lazy: most chunks are entirely single-Z, + /// so we only pay the 32 bytes on chunks that actually need it. /// - public readonly byte[] MultiZCells = new byte[32]; + private byte[] _multiZCells; /// Snapshot of Sector.MultisVersion at the time this chunk was built. public int BuiltMultisVersion; @@ -37,14 +47,12 @@ internal sealed class StepChunk /// 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 bool IsCellMultiZ(int cellIndex) => + _multiZCells != null && (_multiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0; public void MarkCellMultiZ(int cellIndex) { - MultiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); - HasAnyMultiZ = true; + _multiZCells ??= new byte[32]; + _multiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); } } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs index f588b75f8..832f3d853 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs @@ -1,39 +1,85 @@ namespace Server.Engines.Pathing.Cache; +/// +/// Per-cell, per-direction static walkability data baked by +/// and stored by . WalkMask + WalkZ_* applies under default-walker +/// rules (cantWalk=false, canSwim=false). WetMask + SwimZ_* applies under swim-only rules +/// (cantWalk=true, canSwim=true). Algorithms layer the right rules per mobile. +/// public readonly struct StepMask( - byte mask, - sbyte destZn, - sbyte destZne, - sbyte destZe, - sbyte destZse, - sbyte destZs, - sbyte destZsw, - sbyte destZw, - sbyte destZnw + byte walkMask, + byte wetMask, + sbyte walkZN, + sbyte walkZNE, + sbyte walkZE, + sbyte walkZSE, + sbyte walkZS, + sbyte walkZSW, + sbyte walkZW, + sbyte walkZNW, + sbyte swimZN, + sbyte swimZNE, + sbyte swimZE, + sbyte swimZSE, + sbyte swimZS, + sbyte swimZSW, + sbyte swimZW, + sbyte swimZNW, + CacheHitKind hitKind = CacheHitKind.Hit ) { - 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 readonly byte WalkMask = walkMask; + public readonly byte WetMask = wetMask; + public readonly sbyte WalkZ_N = walkZN; + public readonly sbyte WalkZ_NE = walkZNE; + public readonly sbyte WalkZ_E = walkZE; + public readonly sbyte WalkZ_SE = walkZSE; + public readonly sbyte WalkZ_S = walkZS; + public readonly sbyte WalkZ_SW = walkZSW; + public readonly sbyte WalkZ_W = walkZW; + public readonly sbyte WalkZ_NW = walkZNW; + public readonly sbyte SwimZ_N = swimZN; + public readonly sbyte SwimZ_NE = swimZNE; + public readonly sbyte SwimZ_E = swimZE; + public readonly sbyte SwimZ_SE = swimZSE; + public readonly sbyte SwimZ_S = swimZS; + public readonly sbyte SwimZ_SW = swimZSW; + public readonly sbyte SwimZ_W = swimZW; + public readonly sbyte SwimZ_NW = swimZNW; + public readonly CacheHitKind HitKind = hitKind; - public bool IsWalkable(Direction d) => (Mask & (1 << (int)d)) != 0; + /// + /// True when the cache produced a usable answer (Hit / Miss_NotBuilt / Miss_DirtyRebuild). + /// False on Fallthrough_*, in which case the caller must use the slow path for this cell. + /// + public bool IsHit => HitKind <= CacheHitKind.Miss_DirtyRebuild; - public sbyte GetDestZ(Direction d) => d switch + public bool IsWalkable(Direction d) => (WalkMask & (1 << (int)d)) != 0; + public bool IsSwimmable(Direction d) => (WetMask & (1 << (int)d)) != 0; + + public sbyte GetWalkZ(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, + Direction.North => WalkZ_N, + Direction.Right => WalkZ_NE, + Direction.East => WalkZ_E, + Direction.Down => WalkZ_SE, + Direction.South => WalkZ_S, + Direction.Left => WalkZ_SW, + Direction.West => WalkZ_W, + Direction.Up => WalkZ_NW, + _ => 0 + }; + + public sbyte GetSwimZ(Direction d) => d switch + { + Direction.North => SwimZ_N, + Direction.Right => SwimZ_NE, + Direction.East => SwimZ_E, + Direction.Down => SwimZ_SE, + Direction.South => SwimZ_S, + Direction.Left => SwimZ_SW, + Direction.West => SwimZ_W, + Direction.Up => SwimZ_NW, _ => 0 }; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index bd8aef7e7..46182e6f7 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -9,10 +9,10 @@ namespace Server.Engines.Pathing.Cache; /// .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). +/// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only +/// (canSwim=true, cantWalk=true). Item / mobile collision phases are omitted (they're +/// the dynamic-obstacle pass's job). Diagonal corner-cut is NOT applied here; callers +/// must AND the partner-cell results at query time. /// public static class StepProbe { @@ -26,10 +26,19 @@ public static class StepProbe return default; } - GetStaticStartZ(map, x, y, sourceZ, out var startZ, out var startTop, out _); + GetStaticStartZ(map, x, y, sourceZ, canSwim: false, cantWalk: false, + out var walkStartZ, out var walkStartTop, out _); + GetStaticStartZ(map, x, y, sourceZ, canSwim: true, cantWalk: true, + out var swimStartZ, out var swimStartTop, out _); - byte mask = 0; - Span destZs = stackalloc sbyte[8]; + byte walkMask = 0; + byte wetMask = 0; + Span walkZs = stackalloc sbyte[8]; + Span swimZs = stackalloc sbyte[8]; + // stackalloc is NOT zero-initialized — unwritten slots hold whatever was on the + // stack. Clear before use; the loop only writes slots where the step succeeds. + walkZs.Clear(); + swimZs.Clear(); for (var d = 0; d < 8; d++) { @@ -37,47 +46,59 @@ public static class StepProbe var dy = y; CalcMoves.Offset((Direction)d, ref dx, ref dy); - if (CheckStaticStep(map, dx, dy, startZ, startTop, out var newZ)) + if (CheckStaticStep(map, dx, dy, walkStartZ, walkStartTop, + canSwim: false, cantWalk: false, out var walkZ)) { - mask |= (byte)(1 << d); - destZs[d] = (sbyte)newZ; + walkMask |= (byte)(1 << d); + walkZs[d] = (sbyte)walkZ; + } + + if (CheckStaticStep(map, dx, dy, swimStartZ, swimStartTop, + canSwim: true, cantWalk: true, out var swimZ)) + { + wetMask |= (byte)(1 << d); + swimZs[d] = (sbyte)swimZ; } } return new StepMask( - mask, - destZs[0], destZs[1], destZs[2], destZs[3], - destZs[4], destZs[5], destZs[6], destZs[7] + walkMask, wetMask, + walkZs[0], walkZs[1], walkZs[2], walkZs[3], + walkZs[4], walkZs[5], walkZs[6], walkZs[7], + swimZs[0], swimZs[1], swimZs[2], swimZs[3], + swimZs[4], swimZs[5], swimZs[6], swimZs[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. + /// Returns the slow path's standing-Z for a default walker at (x, y). Mirrors + /// MovementImpl.Check's surface-selection — paver Z+1 for paver-over-ground, + /// landCenter for bare land. Used by to bake SourceZ 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); + GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, + out _, out _, out var zCenter); return zCenter; } /// - /// Mirrors GetStartZ from MovementImpl, but static-only (no item list). - /// Assumes default walker: CanSwim=false, CantWalk=false. + /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk. /// - private static void GetStaticStartZ(Map map, int x, int y, int locZ, out int zLow, out int zTop, out int zCenter) + private static void GetStaticStartZ( + Map map, int x, int y, int locZ, bool canSwim, bool cantWalk, + 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; + // Mirrors MovementImpl: impassable + swim on water is OK; otherwise block on + // cantWalk or impassable. + var landBlocks = (cantWalk || impassable) + && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); map.GetAverageZ(x, y, out var landZ, out var landCenter, out var landTop); @@ -99,8 +120,7 @@ public static class StepProbe 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) + if (isSet && calcTop < zCenter || locZ < calcTop || !id.Surface && !(canSwim && id.Wet)) { continue; } @@ -129,11 +149,13 @@ public static class StepProbe } /// - /// 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. + /// Mirrors MovementImpl.Check for static tiles only, parameterized by canSwim / cantWalk. + /// Items and mobile collision phases are omitted. /// - private static bool CheckStaticStep(Map map, int x, int y, int startZ, int startTop, out int newZ) + private static bool CheckStaticStep( + Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, + out int newZ + ) { newZ = 0; @@ -146,8 +168,8 @@ public static class StepProbe 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 landBlocks = (cantWalk || impassable) + && !(impassable && canSwim && (flags & TileFlag.Wet) != 0); var considerLand = !landTile.Ignored; @@ -163,10 +185,12 @@ public static class StepProbe foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) { var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var notWater = !itemData.Wet; - // CanSwim=false, CantWalk=false: - // Skip if not a passable surface (no swim path either) - if (!itemData.Surface || itemData.Impassable) + // Mirrors MovementImpl: skip if not a passable surface AND not swimmable water, + // OR if the mobile can't walk and this isn't water. + if ((!itemData.Surface || itemData.Impassable) && (!canSwim || notWater) + || cantWalk && notWater) { continue; } @@ -176,7 +200,6 @@ public static class StepProbe 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); @@ -209,7 +232,6 @@ public static class StepProbe continue; } - // IsOk equivalent: check static tiles don't block (ourZ, testTop) space if (StaticsBlockAt(map, x, y, ourZ, testTop)) { continue; @@ -219,7 +241,6 @@ public static class StepProbe moveIsOk = true; } - // Land surface fallback (mirrors Check's land block at the bottom) if (!considerLand || landBlocks || stepTop < landZ) { return moveIsOk; diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index 5c475f51c..583b1df77 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using Server.Engines.Pathing; using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; @@ -61,6 +62,7 @@ namespace Server { CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); CacheEvictionTimer.Configure(); + PathCacheCommands.Configure(); } [Usage("Path")] diff --git a/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs b/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs index 40ec83fcd..1ac86fc40 100644 --- a/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/PathAlgorithm.cs @@ -2,7 +2,7 @@ namespace Server.PathAlgorithms { public abstract class PathAlgorithm { - private static readonly Direction[] m_CalcDirections = + private static readonly Direction[] _calcDirections = { Direction.Up, Direction.North, @@ -18,7 +18,7 @@ namespace Server.PathAlgorithms public abstract bool CheckCondition(Mobile m, Map map, Point3D start, Point3D goal); public abstract Direction[] Find(Mobile m, Map map, Point3D start, Point3D goal); - public Direction GetDirection(int xSource, int ySource, int xDest, int yDest) + public static Direction GetDirection(int xSource, int ySource, int xDest, int yDest) { var x = xDest + 1 - xSource; var y = yDest + 1 - ySource; @@ -29,7 +29,7 @@ namespace Server.PathAlgorithms return Direction.North; } - return m_CalcDirections[v]; + return _calcDirections[v]; } } } diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs new file mode 100644 index 000000000..fe61c5c4f --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -0,0 +1,40 @@ +using Server.Engines.Pathing.Cache; + +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 and zero counters. +/// +public static class PathCacheCommands +{ + public static void Configure() + { + CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); + CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); + } + + [Usage("PathCacheStats")] + [Description("Reports StepCache resident-chunk count and hit/miss/eviction telemetry.")] + private static void OnPathCacheStats(CommandEventArgs e) + { + var stats = StepCache.Instance.GetStats(); + var from = e.Mobile; + + from.SendMessage($"StepCache: {stats.ResidentChunks} chunks resident"); + from.SendMessage($" builds={stats.BuildsTotal} hits={stats.Hits}"); + from.SendMessage($" miss(notBuilt)={stats.MissesNotBuilt} miss(dirty)={stats.MissesDirtyRebuild}"); + from.SendMessage($" fallthru(multiZ)={stats.FallthroughMultiZ} fallthru(offMap)={stats.FallthroughOffMap} fallthru(srcZ)={stats.FallthroughSourceZMismatch}"); + from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}"); + } + + [Usage("PathCacheClear")] + [Description("Drops all StepCache resident chunks and zeros the telemetry counters.")] + private static void OnPathCacheClear(CommandEventArgs e) + { + var residentBefore = StepCache.Instance.GetStats().ResidentChunks; + StepCache.Instance.Clear(); + e.Mobile.SendMessage($"StepCache cleared: {residentBefore} chunks dropped, counters reset."); + } +}