feat: expand cache to nearly all mobiles + dynamic-obstacle pass (#2447)

## Summary

Builds on PR #2446's cache-direct A*. The previous PR conservatively routed players + creatures with capability flags entirely through the slow path. This PR pushes that line: most mobile classes now use the cache, with the right rule set layered on top per-mobile, and the cache fast-path now does the dynamic items / mobiles check that PR #2446 had silently skipped.

## What changed

- **Non-GM players** now use the cache. Diagonal corner-cut applies the strict AND-rule (BOTH cardinal partners walkable) by reading the same source-cell mask byte the creature OR-rule reads — both rules are evaluable from one byte.
- **Creatures with `CanOpenDoors` / `CanMoveOverObstacles`** now use the cache. Reading `MovementImpl` confirmed those flags only affect dynamic items, never static tiles, so they were over-conservatively excluded before.
- **Swim creatures** now use the cache via a capability overlay. `StepProbe` bakes a second rule set (`canSwim=true, cantWalk=true`) producing `WetMask` + `SwimZ_*`. The algorithm composes `effectiveMask = (walkMask & !cantWalk) | (wetMask & canSwim)` per direction; walk Z preferred when both apply.
- **Dynamic-obstacle pass.** Cache fast-path now mirrors `MovementImpl`'s per-cell items + mobiles collision check (`GetItemsAt` / `GetMobilesAt` at the target cell, with `CanOpenDoors` / `CanMoveOverObstacles` / spell-field overrides). This closes a correctness gap from PR #2446 — the cache fast-path was silently skipping dynamic obstacles entirely.
- **`StepCache.TryGetMask` returns `StepMask` struct** instead of 11 out parameters. `HitKind` rolls into the struct with an `IsHit` accessor. Sets up wet/swim without ballooning the call site.
- **`StepChunk.MultiZCells` is lazy-init.** Most chunks are entirely single-Z; allocating the 32-byte bitmap up-front wasted ~256KB at full cap.
- **Admin commands.** `[PathCacheStats` (resident chunks + hit/miss/eviction counters) and `[PathCacheClear` (drop everything, zero counters).
- **Feature flag.** `bitmap_pathfinding_cache` (default true) gates the cache fast-path. Flipped off, every cell expansion routes to `MovementImpl` — equivalent to PR #2446's slow-path-only behavior. Safety net for shipping the new behavior.

`RequiresSlowPath` shrinks to just `CanFly` — flying creatures Z-jump arbitrarily, which the cache's static-Z model can't accommodate.
This commit is contained in:
Kamron Batman 2026-05-06 00:14:08 -07:00 • committed by GitHub
parent 6a3804addc
commit 9066e8fd00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 856 additions and 290 deletions

View file

@ -1,6 +1,7 @@
using Server.Engines.Pathing.Cache; using Server.Engines.Pathing.Cache;
using Server.Mobiles; using Server.Mobiles;
using Server.PathAlgorithms.BitmapAStar; using Server.PathAlgorithms.BitmapAStar;
using Server.Systems.FeatureFlags;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@ -54,7 +55,7 @@ public class BitmapAStarAlgorithmTests
[Theory] [Theory]
[InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal [InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal
[InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N [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(); StepCache.Instance.Clear();
var map = Map.Maps[1]; var map = Map.Maps[1];
@ -62,27 +63,270 @@ public class BitmapAStarAlgorithmTests
var stub = new SwimmingStub(World.NewMobile); var stub = new SwimmingStub(World.NewMobile);
stub.DefaultMobileInit(); 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 _); map.GetAverageZ(sx, sy, out _, out var startZ, out _);
var start = new Point3D(sx, sy, (sbyte)startZ); var start = new Point3D(sx, sy, (sbyte)startZ);
var goal = new Point3D(gx, gy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ);
stub.MoveToWorld(start, map); stub.MoveToWorld(start, map);
var statsBefore = StepCache.Instance.GetStats();
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
var statsAfter = StepCache.Instance.GetStats();
stub.Delete(); 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.NotNull(result);
Assert.NotEmpty(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"); _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<ushort>(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)");
}
/// <summary> /// <summary>
/// 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. /// fast path on every expansion.
/// </summary> /// </summary>
private sealed class DefaultWalkerStub : Mobile private sealed class DefaultWalkerStub : Mobile
@ -94,10 +338,23 @@ public class BitmapAStarAlgorithmTests
} }
/// <summary> /// <summary>
/// BaseCreature with CanSwim=true — IsDefaultWalker returns false, the bitmap /// Mobile with Player=true and default AccessLevel (Player). Triggers the strict
/// algorithm short-circuits GetSuccessors to GetSuccessorsSlowPath on every cell. /// AND-rule for diagonal corner-cut while still using the cache.
/// Use the Serial constructor (deserialization path) to bypass NPCSpeeds init, /// </summary>
/// which requires the npc-speeds.json table loaded — not available in tests. private sealed class PlayerStub : Mobile
{
public PlayerStub()
{
Body = 0xC9;
Player = true;
}
}
/// <summary>
/// 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.
/// </summary> /// </summary>
private sealed class SwimmingStub : BaseCreature private sealed class SwimmingStub : BaseCreature
{ {
@ -106,4 +363,38 @@ public class BitmapAStarAlgorithmTests
Body = 0xC9; Body = 0xC9;
} }
} }
/// <summary>
/// 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.
/// </summary>
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;
}
} }

View file

@ -35,20 +35,14 @@ public class StepCacheLifecycleTests
Assert.NotNull(map); Assert.NotNull(map);
// Pinned cell (1500, 1600, z=10): mask=0xC1 // Pinned cell (1500, 1600, z=10): mask=0xC1
var ok = cache.TryGetMask( var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
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.True(lookup.IsHit);
Assert.Equal(CacheHitKind.Miss_NotBuilt, hitKind); Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
Assert.Equal((byte)0xC1, mask); Assert.Equal((byte)0xC1, lookup.WalkMask);
Assert.Equal((sbyte)10, dN); Assert.Equal((sbyte)10, lookup.WalkZ_N);
Assert.Equal((sbyte)10, dW); Assert.Equal((sbyte)10, lookup.WalkZ_W);
Assert.Equal((sbyte)10, dNW); Assert.Equal((sbyte)10, lookup.WalkZ_NW);
var stats = cache.GetStats(); var stats = cache.GetStats();
Assert.Equal(1, stats.ResidentChunks); Assert.Equal(1, stats.ResidentChunks);
@ -56,15 +50,10 @@ public class StepCacheLifecycleTests
Assert.Equal(1L, stats.BuildsTotal); Assert.Equal(1L, stats.BuildsTotal);
// Second query of same cell → Hit // Second query of same cell → Hit
var ok2 = cache.TryGetMask( var lookup2 = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
map, 1500, 1600, sourceZ: 10, Assert.True(lookup2.IsHit);
out var mask2, Assert.Equal(CacheHitKind.Hit, lookup2.HitKind);
out _, out _, out _, out _, out _, out _, out _, out _, Assert.Equal((byte)0xC1, lookup2.WalkMask);
out var hitKind2
);
Assert.True(ok2);
Assert.Equal(CacheHitKind.Hit, hitKind2);
Assert.Equal((byte)0xC1, mask2);
var stats2 = cache.GetStats(); var stats2 = cache.GetStats();
Assert.Equal(1, stats2.ResidentChunks); Assert.Equal(1, stats2.ResidentChunks);
@ -79,15 +68,11 @@ public class StepCacheLifecycleTests
var map = Map.Maps[1]; var map = Map.Maps[1];
var ok = cache.TryGetMask( var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0);
map, -1, -1, sourceZ: 0,
out var mask, out _, out _, out _, out _, out _, out _, out _, out _,
out var hitKind
);
Assert.False(ok); Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_OffMap, hitKind); Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
Assert.Equal((byte)0, mask); Assert.Equal((byte)0, lookup.WalkMask);
} }
[Fact] [Fact]
@ -100,10 +85,7 @@ public class StepCacheLifecycleTests
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4); var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
// First query: builds chunk, snapshots current MultisVersion. // First query: builds chunk, snapshots current MultisVersion.
cache.TryGetMask(map, 1500, 1600, 10, Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
out _, out _, out _, out _, out _, out _, out _, out _, out _,
out var firstHitKind);
Assert.Equal(CacheHitKind.Miss_NotBuilt, firstHitKind);
// Bump _multisVersion via reflection. // Bump _multisVersion via reflection.
var versionField = typeof(Map.Sector).GetField( var versionField = typeof(Map.Sector).GetField(
@ -115,10 +97,7 @@ public class StepCacheLifecycleTests
versionField.SetValue(sector, current + 1); versionField.SetValue(sector, current + 1);
// Second query: detects version mismatch, rebuilds. // Second query: detects version mismatch, rebuilds.
cache.TryGetMask(map, 1500, 1600, 10, Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
out _, out _, out _, out _, out _, out _, out _, out _, out _,
out var secondHitKind);
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, secondHitKind);
var stats = cache.GetStats(); var stats = cache.GetStats();
Assert.Equal(1L, stats.MissesDirtyRebuild); Assert.Equal(1L, stats.MissesDirtyRebuild);
@ -142,8 +121,7 @@ public class StepCacheLifecycleTests
var map = Map.Maps[1]; var map = Map.Maps[1];
// Build a chunk first so it exists. // Build a chunk first so it exists.
cache.TryGetMask(map, 1500, 1600, 10, 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 // 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. // 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)); var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
chunk.MarkCellMultiZ(cellIndex); chunk.MarkCellMultiZ(cellIndex);
var ok = cache.TryGetMask(map, 1500, 1600, 10, var lookup = cache.TryGetMask(map, 1500, 1600, 10);
out _, out _, out _, out _, out _, out _, out _, out _, out _,
out var hitKind);
Assert.False(ok); Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, hitKind); Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
var stats = cache.GetStats(); var stats = cache.GetStats();
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ); Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
@ -192,8 +168,7 @@ public class StepCacheLifecycleTests
{ {
var x = 1500 + (i * 16); var x = 1500 + (i * 16);
var y = 1600; var y = 1600;
cache.TryGetMask(map, x, y, 10, cache.TryGetMask(map, x, y, 10);
out _, out _, out _, out _, out _, out _, out _, out _, out _, out _);
System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
} }

View file

@ -29,6 +29,7 @@ public class StepCacheParityTests
var disagreements = 0; var disagreements = 0;
var samples = 0; var samples = 0;
var multiZ = 0; var multiZ = 0;
var wetCells = 0;
for (var x = xStart; x < xStart + size; x++) for (var x = xStart; x < xStart + size; x++)
{ {
@ -43,41 +44,49 @@ public class StepCacheParityTests
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ); var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
var ok = cache.TryGetMask( var lookup = cache.TryGetMask(map, x, y, sourceZ);
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++; samples++;
if (hitKind == CacheHitKind.Fallthrough_MultiZ) if (lookup.HitKind == CacheHitKind.Fallthrough_MultiZ)
{ {
multiZ++; multiZ++;
continue; 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++; 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; continue;
} }
if (dN != baker.DestZ_N || dNE != baker.DestZ_NE || dE != baker.DestZ_E || dSE != baker.DestZ_SE if (lookup.WetMask != baker.WetMask)
|| dS != baker.DestZ_S || dSW != baker.DestZ_SW || dW != baker.DestZ_W || dNW != baker.DestZ_NW)
{ {
disagreements++; 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. // Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
if (label == "britain_inn_dense") if (label == "britain_inn_dense")
@ -87,4 +96,40 @@ public class StepCacheParityTests
Assert.Equal(0, disagreements); Assert.Equal(0, disagreements);
} }
/// <summary>
/// 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.
/// </summary>
[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");
}
} }

View file

@ -62,7 +62,7 @@ public class StaticWalkabilityParityTests
} }
} }
var newZ = bakerResult.GetDestZ(dir); var newZ = bakerResult.GetWalkZ(dir);
if (oldOk) if (oldOk)
{ {

View file

@ -19,10 +19,10 @@ public class StepProbeTests
{ {
var result = StepProbe.ComputeMaskAt(null, 100, 100, 0); 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++) 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); 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++) 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 sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
if (result.Mask != 0xFF) if (result.WalkMask != 0xFF)
{ {
continue; continue;
} }
@ -106,7 +106,7 @@ public class StepProbeTests
var allSameZ = true; var allSameZ = true;
for (var d = 0; d < 8; d++) for (var d = 0; d < 8; d++)
{ {
if (result.GetDestZ((Direction)d) != sourceZ) if (result.GetWalkZ((Direction)d) != sourceZ)
{ {
allSameZ = false; allSameZ = false;
break; break;
@ -143,7 +143,7 @@ public class StepProbeTests
var sourceZ = (sbyte)avgZ; var sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
if (result.Mask != 0xFF) if (result.WalkMask != 0xFF)
{ {
blockedCellCount++; blockedCellCount++;
} }
@ -172,12 +172,12 @@ public class StepProbeTests
var second = StepProbe.ComputeMaskAt(map, x, y, sourceZ); var second = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
var third = StepProbe.ComputeMaskAt(map, x, y, sourceZ); var third = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
Assert.Equal(first.Mask, second.Mask); Assert.Equal(first.WalkMask, second.WalkMask);
Assert.Equal(first.Mask, third.Mask); Assert.Equal(first.WalkMask, third.WalkMask);
for (var d = 0; d < 8; d++) for (var d = 0; d < 8; d++)
{ {
Assert.Equal(first.GetDestZ((Direction)d), second.GetDestZ((Direction)d)); Assert.Equal(first.GetWalkZ((Direction)d), second.GetWalkZ((Direction)d));
Assert.Equal(first.GetDestZ((Direction)d), third.GetDestZ((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); var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ);
Assert.Equal((sbyte)10, 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.True(result.IsWalkable(Direction.North));
Assert.False(result.IsWalkable(Direction.Right)); Assert.False(result.IsWalkable(Direction.Right));
@ -209,9 +209,9 @@ public class StepProbeTests
Assert.True(result.IsWalkable(Direction.West)); Assert.True(result.IsWalkable(Direction.West));
Assert.True(result.IsWalkable(Direction.Up)); Assert.True(result.IsWalkable(Direction.Up));
Assert.Equal((sbyte)10, result.GetDestZ(Direction.North)); Assert.Equal((sbyte)10, result.GetWalkZ(Direction.North));
Assert.Equal((sbyte)10, result.GetDestZ(Direction.West)); Assert.Equal((sbyte)10, result.GetWalkZ(Direction.West));
Assert.Equal((sbyte)10, result.GetDestZ(Direction.Up)); Assert.Equal((sbyte)10, result.GetWalkZ(Direction.Up));
} }
[Fact] [Fact]
@ -229,7 +229,7 @@ public class StepProbeTests
var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ); var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ);
Assert.Equal((sbyte)20, 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.North));
Assert.True(result.IsWalkable(Direction.Right)); Assert.True(result.IsWalkable(Direction.Right));
@ -242,7 +242,7 @@ public class StepProbeTests
for (var d = 0; d < 6; d++) for (var d = 0; d < 6; d++)
{ {
Assert.Equal((sbyte)20, result.GetDestZ((Direction)d)); Assert.Equal((sbyte)20, result.GetWalkZ((Direction)d));
} }
} }
} }

View file

@ -14,4 +14,5 @@ public static class ContentFeatureFlags
public static bool BulkOrders { get; set; } = true; public static bool BulkOrders { get; set; } = true;
public static bool PassiveDetectHidden { get; set; } = true; public static bool PassiveDetectHidden { get; set; } = true;
public static bool YoungPlayerSystem { get; set; } = true; public static bool YoungPlayerSystem { get; set; } = true;
public static bool BitmapPathfindingCache { get; set; } = true;
} }

View file

@ -976,6 +976,7 @@ public static class FeatureFlagManager
"bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled,
"passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled,
"young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled,
"bitmap_pathfinding_cache" => ContentFeatureFlags.BitmapPathfindingCache = enabled,
}; };
} }

View file

@ -18,6 +18,7 @@ using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Server.Engines.Pathing.Cache; using Server.Engines.Pathing.Cache;
using Server.Mobiles; using Server.Mobiles;
using Server.Systems.FeatureFlags;
using CalcMoves = Server.Movement.Movement; using CalcMoves = Server.Movement.Movement;
using MoveImpl = Server.Movement.MovementImpl; using MoveImpl = Server.Movement.MovementImpl;
@ -61,9 +62,27 @@ public class BitmapAStarAlgorithm : PathAlgorithm
private static int _yOffset; private static int _yOffset;
// When set, GetSuccessors delegates to the per-cell slow path on every expansion // 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; 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; private Point3D _goal;
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -89,7 +108,25 @@ public class BitmapAStarAlgorithm : PathAlgorithm
return null; 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); Array.Clear(_nodeStates);
@ -220,12 +257,24 @@ public class BitmapAStarAlgorithm : PathAlgorithm
_openQueue.Clear(); _openQueue.Clear();
_currentMobileNeedsSlowPath = false; _currentMobileNeedsSlowPath = false;
_currentMobilePlayerStrict = false;
_currentMobileCanSwim = false;
_currentMobileCantWalk = false;
_currentMobileIgnoreDoors = false;
_currentMobileIgnoreSpellFields = false;
_currentMobileIgnoreMovableImpassables = false;
return dirs; return dirs;
} }
} }
_openQueue.Clear(); _openQueue.Clear();
_currentMobileNeedsSlowPath = false; _currentMobileNeedsSlowPath = false;
_currentMobilePlayerStrict = false;
_currentMobileCanSwim = false;
_currentMobileCantWalk = false;
_currentMobileIgnoreDoors = false;
_currentMobileIgnoreSpellFields = false;
_currentMobileIgnoreMovableImpassables = false;
return null; return null;
} }
@ -256,28 +305,26 @@ public class BitmapAStarAlgorithm : PathAlgorithm
var vals = _successors; var vals = _successors;
if (_currentMobileNeedsSlowPath) if (_currentMobileNeedsSlowPath || !ContentFeatureFlags.BitmapPathfindingCache)
{ {
return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); return GetSuccessorsSlowPath(m, map, px, py, p3D, vals);
} }
var count = 0; var count = 0;
StepCache.Instance.TryGetMask( var lookup = StepCache.Instance.TryGetMask(map, p3D.X, p3D.Y, (sbyte)p3D.Z);
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 if (!lookup.IsHit)
or CacheHitKind.Fallthrough_OffMap
or CacheHitKind.Fallthrough_SourceZMismatch)
{ {
return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); 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) for (var i = 0; i < 8; ++i)
{ {
var x = px; var x = px;
@ -294,31 +341,62 @@ public class BitmapAStarAlgorithm : PathAlgorithm
continue; 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) if ((i & 1) == 1)
{ {
var leftBit = 1 << ((i - 1) & 0x7); var leftBit = 1 << ((i - 1) & 0x7);
var rightBit = 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; continue;
} }
} }
var z = i switch // Walking takes precedence over swimming when both apply (matches MovementImpl's
{ // surface-selection: closest-to-startZ wins, and walk surface is always closer
0 => dN, // when the creature is currently standing on land).
1 => dNE, var useWalkZ = (walkBits & (1 << i)) != 0;
2 => dE, var z = useWalkZ
3 => dSE, ? i switch
4 => dS, {
5 => dSW, 0 => lookup.WalkZ_N,
6 => dW, 1 => lookup.WalkZ_NE,
7 => dNW, 2 => lookup.WalkZ_E,
_ => (sbyte)0 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) if (idx >= 0 && idx < NodeCount)
{ {
@ -330,6 +408,79 @@ public class BitmapAStarAlgorithm : PathAlgorithm
return count; return count;
} }
private const int PersonHeightConst = 16;
private const int MobileHeight = 15;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Mirrors MovementImpl.CanMoveOver — true when m can step onto t's cell (dead bodies,
/// hidden staff, etc.).
/// </summary>
private static bool CanMoveOver(Mobile m, Mobile t) =>
!t.Alive || !m.Alive || t.IsDeadBondedPet || m.IsDeadBondedPet
|| t.Hidden && t.AccessLevel > AccessLevel.Player;
/// <summary> /// <summary>
/// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell. /// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell.
/// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set. /// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set.
@ -365,22 +516,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm
} }
/// <summary> /// <summary>
/// Default walker = the cache's baked rules apply directly (lenient OR-rule for /// True for creatures whose movement rules the static cache can't model. Currently
/// diagonal corner-cut, no capability overlays). Non-GM players (strict AND-rule) /// only CanFly — flying creatures Z-jump arbitrarily and the cache's source-Z guard
/// and creatures with swim/fly/door/clip capabilities require the slow path. /// 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.
/// </summary> /// </summary>
private static bool IsDefaultWalker(Mobile m) private static bool RequiresSlowPath(Mobile m) =>
{ m is BaseCreature bc && bc.CanFly;
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;
}
} }

View file

@ -1,8 +1,9 @@
namespace Server.Engines.Pathing.Cache; namespace Server.Engines.Pathing.Cache;
/// <summary> /// <summary>
/// Outcome categories for StaticWalkabilityCache.TryGetMask. Used for telemetry /// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive
/// and to drive the slow-path fallthrough decision in callers. /// the slow-path fallthrough decision in callers. Ordering is load-bearing:
/// values 0-2 are hits, values 3-5 are fallthroughs (see StepMask.IsHit).
/// </summary> /// </summary>
public enum CacheHitKind : byte public enum CacheHitKind : byte
{ {

View file

@ -143,25 +143,16 @@ public sealed class StepCache
private const int ChunkSize = 16; private const int ChunkSize = 16;
/// <summary> /// <summary>
/// Hot-path query. Returns the cached mask + 8 destination Z values for (map, x, y, sourceZ). /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind.
/// Returns false on off-map or multi-Z fallthrough; the caller should use the slow path. /// Inspect <see cref="StepMask.IsHit"/> to decide whether to use the result or fall
/// back to the slow path.
/// </summary> /// </summary>
public bool TryGetMask( public StepMask TryGetMask(Map map, int x, int y, sbyte sourceZ)
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) if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height)
{ {
hitKind = CacheHitKind.Fallthrough_OffMap;
_fallthroughOffMap++; _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; var chunkX = x >> 4;
@ -184,9 +175,8 @@ public sealed class StepCache
chunk = BuildChunk(map, chunkX, chunkY); chunk = BuildChunk(map, chunkX, chunkY);
_chunks[key] = chunk; _chunks[key] = chunk;
hitKindResult = CacheHitKind.Miss_DirtyRebuild; hitKindResult = CacheHitKind.Miss_DirtyRebuild;
// _missesDirtyRebuild++ moved into the success switch below to // _missesDirtyRebuild++ deferred to the outcome switch below so a
// preserve the mutual-exclusivity invariant (a multi-Z fallthrough // multi-Z fallthrough on a freshly dirty-rebuilt chunk doesn't double-count.
// on a freshly dirty-rebuilt chunk must NOT count both counters).
} }
} }
@ -196,9 +186,8 @@ public sealed class StepCache
if (chunk.IsCellMultiZ(cellIndex)) if (chunk.IsCellMultiZ(cellIndex))
{ {
hitKind = CacheHitKind.Fallthrough_MultiZ;
_fallthroughMultiZ++; _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. // 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. // because tile reachability shifts at step-height boundaries.
if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight) if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight)
{ {
hitKind = CacheHitKind.Fallthrough_SourceZMismatch;
_fallthroughSourceZMismatch++; _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) switch (hitKindResult)
{ {
case CacheHitKind.Miss_NotBuilt: case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
{ case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
_missesNotBuilt++; case CacheHitKind.Hit: { _hits++; break; }
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
);
} }
/// <summary> /// <summary>
@ -276,16 +261,25 @@ public sealed class StepCache
var result = StepProbe.ComputeMaskAt(map, x, y, standingZ); var result = StepProbe.ComputeMaskAt(map, x, y, standingZ);
chunk.Mask[cell] = result.Mask; chunk.WalkMask[cell] = result.WalkMask;
chunk.SourceZ[cell] = standingZ; chunk.WetMask[cell] = result.WetMask;
chunk.DestZN[cell] = result.DestZ_N; chunk.SourceZ[cell] = standingZ;
chunk.DestZNE[cell] = result.DestZ_NE; chunk.WalkZN[cell] = result.WalkZ_N;
chunk.DestZE[cell] = result.DestZ_E; chunk.WalkZNE[cell] = result.WalkZ_NE;
chunk.DestZSE[cell] = result.DestZ_SE; chunk.WalkZE[cell] = result.WalkZ_E;
chunk.DestZS[cell] = result.DestZ_S; chunk.WalkZSE[cell] = result.WalkZ_SE;
chunk.DestZSW[cell] = result.DestZ_SW; chunk.WalkZS[cell] = result.WalkZ_S;
chunk.DestZW[cell] = result.DestZ_W; chunk.WalkZSW[cell] = result.WalkZ_SW;
chunk.DestZNW[cell] = result.DestZ_NW; 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 // Multi-Z = ≥2 surfaces reachable from standingZ. Mirrors the baker's
// CheckStaticStep filter so we don't over-mark. // CheckStaticStep filter so we don't over-mark.

View file

@ -1,35 +1,45 @@
namespace Server.Engines.Pathing.Cache; namespace Server.Engines.Pathing.Cache;
/// <summary> /// <summary>
/// Per-chunk storage backing StepCache. Holds raw walkability masks and /// Per-chunk storage backing StepCache. Holds raw walk + swim masks and destination Z
/// destination Z values for each of 256 cells in a 16x16 chunk, plus build-time /// values for each of 256 cells in a 16x16 chunk, plus build-time metadata (multis
/// metadata (multis version, multi-Z bitmap) and LRU bookkeeping. /// version, multi-Z bitmap) and LRU bookkeeping.
/// </summary> /// </summary>
internal sealed class StepChunk internal sealed class StepChunk
{ {
public const int CellsPerChunk = 256; // 16 x 16 public const int CellsPerChunk = 256; // 16 x 16
/// <summary>Bit i of Mask[c] = "can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here.</summary> /// <summary>Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i". Raw — no diagonal corner-cut applied here.</summary>
public readonly byte[] Mask = new byte[CellsPerChunk]; public readonly byte[] WalkMask = new byte[CellsPerChunk];
/// <summary>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.</summary>
public readonly byte[] WetMask = new byte[CellsPerChunk];
public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk]; public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZN = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZN = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZNE = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZNE = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZE = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZE = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZSE = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZSE = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZS = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZS = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZSW = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZSW = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZW = new sbyte[CellsPerChunk]; public readonly sbyte[] WalkZW = new sbyte[CellsPerChunk];
public readonly sbyte[] DestZNW = 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];
/// <summary> /// <summary>
/// 32 bytes = 256 bits. Bit set = cell has &gt;1 walkable surface; route to slow path. /// 32 bytes = 256 bits when allocated. Lazy: most chunks are entirely single-Z,
/// TODO(PR2): lazy-init this. The vast majority of chunks are entirely single-Z, so /// so we only pay the 32 bytes on chunks that actually need it.
/// allocating 32 bytes per chunk wastes ~256KB at full cap. Make nullable; allocate
/// on first MarkCellMultiZ; IsCellMultiZ short-circuits to false when null.
/// </summary> /// </summary>
public readonly byte[] MultiZCells = new byte[32]; private byte[] _multiZCells;
/// <summary>Snapshot of Sector.MultisVersion at the time this chunk was built.</summary> /// <summary>Snapshot of Sector.MultisVersion at the time this chunk was built.</summary>
public int BuiltMultisVersion; public int BuiltMultisVersion;
@ -37,14 +47,12 @@ internal sealed class StepChunk
/// <summary>Updated on every cache hit/miss. Used by LRU fallback eviction.</summary> /// <summary>Updated on every cache hit/miss. Used by LRU fallback eviction.</summary>
public long LastTouchedTicks; public long LastTouchedTicks;
/// <summary>True if any cell in this chunk has more than one walkable surface (set during build).</summary> public bool IsCellMultiZ(int cellIndex) =>
public bool HasAnyMultiZ; _multiZCells != null && (_multiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0;
public bool IsCellMultiZ(int cellIndex) => (MultiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0;
public void MarkCellMultiZ(int cellIndex) public void MarkCellMultiZ(int cellIndex)
{ {
MultiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); _multiZCells ??= new byte[32];
HasAnyMultiZ = true; _multiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7));
} }
} }

View file

@ -1,39 +1,85 @@
namespace Server.Engines.Pathing.Cache; namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Per-cell, per-direction static walkability data baked by <see cref="StepProbe"/>
/// and stored by <see cref="StepCache"/>. 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.
/// </summary>
public readonly struct StepMask( public readonly struct StepMask(
byte mask, byte walkMask,
sbyte destZn, byte wetMask,
sbyte destZne, sbyte walkZN,
sbyte destZe, sbyte walkZNE,
sbyte destZse, sbyte walkZE,
sbyte destZs, sbyte walkZSE,
sbyte destZsw, sbyte walkZS,
sbyte destZw, sbyte walkZSW,
sbyte destZnw 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 byte WalkMask = walkMask;
public readonly sbyte DestZ_N = destZn; public readonly byte WetMask = wetMask;
public readonly sbyte DestZ_NE = destZne; public readonly sbyte WalkZ_N = walkZN;
public readonly sbyte DestZ_E = destZe; public readonly sbyte WalkZ_NE = walkZNE;
public readonly sbyte DestZ_SE = destZse; public readonly sbyte WalkZ_E = walkZE;
public readonly sbyte DestZ_S = destZs; public readonly sbyte WalkZ_SE = walkZSE;
public readonly sbyte DestZ_SW = destZsw; public readonly sbyte WalkZ_S = walkZS;
public readonly sbyte DestZ_W = destZw; public readonly sbyte WalkZ_SW = walkZSW;
public readonly sbyte DestZ_NW = destZnw; 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; /// <summary>
/// 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.
/// </summary>
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.North => WalkZ_N,
Direction.Right => DestZ_NE, Direction.Right => WalkZ_NE,
Direction.East => DestZ_E, Direction.East => WalkZ_E,
Direction.Down => DestZ_SE, Direction.Down => WalkZ_SE,
Direction.South => DestZ_S, Direction.South => WalkZ_S,
Direction.Left => DestZ_SW, Direction.Left => WalkZ_SW,
Direction.West => DestZ_W, Direction.West => WalkZ_W,
Direction.Up => DestZ_NW, 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 _ => 0
}; };
} }

View file

@ -9,10 +9,10 @@ namespace Server.Engines.Pathing.Cache;
/// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases. /// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Default-walker scope: assumes CanSwim=false, CanFly=false, CanOpenDoors=false, /// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only
/// CantWalk=false. Single source-Z per cell. Diagonal corner-cut is NOT applied here; /// (canSwim=true, cantWalk=true). Item / mobile collision phases are omitted (they're
/// callers must AND the partner-cell results at query time per the creature rule /// the dynamic-obstacle pass's job). Diagonal corner-cut is NOT applied here; callers
/// (one cardinal partner walkable suffices). /// must AND the partner-cell results at query time.
/// </remarks> /// </remarks>
public static class StepProbe public static class StepProbe
{ {
@ -26,10 +26,19 @@ public static class StepProbe
return default; 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; byte walkMask = 0;
Span<sbyte> destZs = stackalloc sbyte[8]; byte wetMask = 0;
Span<sbyte> walkZs = stackalloc sbyte[8];
Span<sbyte> 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++) for (var d = 0; d < 8; d++)
{ {
@ -37,47 +46,59 @@ public static class StepProbe
var dy = y; var dy = y;
CalcMoves.Offset((Direction)d, ref dx, ref dy); 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); walkMask |= (byte)(1 << d);
destZs[d] = (sbyte)newZ; 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( return new StepMask(
mask, walkMask, wetMask,
destZs[0], destZs[1], destZs[2], destZs[3], walkZs[0], walkZs[1], walkZs[2], walkZs[3],
destZs[4], destZs[5], destZs[6], destZs[7] walkZs[4], walkZs[5], walkZs[6], walkZs[7],
swimZs[0], swimZs[1], swimZs[2], swimZs[3],
swimZs[4], swimZs[5], swimZs[6], swimZs[7]
); );
} }
/// <summary> /// <summary>
/// Returns the slow path's standing-Z for a default walker at (x, y) with hint locZ. /// Returns the slow path's standing-Z for a default walker at (x, y). Mirrors
/// This is the Z the creature ends up STANDING AT — typically the topmost walkable /// MovementImpl.Check's surface-selection — paver Z+1 for paver-over-ground,
/// surface that's reachable from locZ (paver Z+1 for paver-over-ground; landCenter /// landCenter for bare land. Used by <see cref="StepCache"/> to bake SourceZ so
/// for bare land). Mirrors MovementImpl.Check's surface-selection logic for the /// A*'s tracked-per-cell Z matches the cache's bake-time assumption.
/// 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.
/// </summary> /// </summary>
public static int ComputeStandingZ(Map map, int x, int y, int locZ) 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; return zCenter;
} }
/// <summary> /// <summary>
/// Mirrors GetStartZ from MovementImpl, but static-only (no item list). /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
/// Assumes default walker: CanSwim=false, CantWalk=false.
/// </summary> /// </summary>
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 landTile = map.Tiles.GetLandTile(x, y);
var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
var impassable = (flags & TileFlag.Impassable) != 0; var impassable = (flags & TileFlag.Impassable) != 0;
// CantWalk=false, CanSwim=false → landBlocks = impassable // Mirrors MovementImpl: impassable + swim on water is OK; otherwise block on
var landBlocks = impassable; // 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); 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 id = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var calcTop = tile.Z + id.CalcHeight; var calcTop = tile.Z + id.CalcHeight;
// CanSwim=false → only check Surface; CantWalk=false if (isSet && calcTop < zCenter || locZ < calcTop || !id.Surface && !(canSwim && id.Wet))
if (isSet && calcTop < zCenter || locZ < calcTop || !id.Surface)
{ {
continue; continue;
} }
@ -129,11 +149,13 @@ public static class StepProbe
} }
/// <summary> /// <summary>
/// Mirrors MovementImpl.Check for static tiles only. /// Mirrors MovementImpl.Check for static tiles only, parameterized by canSwim / cantWalk.
/// Assumes default walker: CanSwim=false, CanFly=false, CantWalk=false, /// Items and mobile collision phases are omitted.
/// AlwaysIgnoreDoors=false. Items and mobile collision phases are omitted.
/// </summary> /// </summary>
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; newZ = 0;
@ -146,8 +168,8 @@ public static class StepProbe
var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
var impassable = (flags & TileFlag.Impassable) != 0; var impassable = (flags & TileFlag.Impassable) != 0;
// CantWalk=false, CanSwim=false → landBlocks = impassable var landBlocks = (cantWalk || impassable)
var landBlocks = impassable; && !(impassable && canSwim && (flags & TileFlag.Wet) != 0);
var considerLand = !landTile.Ignored; var considerLand = !landTile.Ignored;
@ -163,10 +185,12 @@ public static class StepProbe
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
{ {
var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var notWater = !itemData.Wet;
// CanSwim=false, CantWalk=false: // Mirrors MovementImpl: skip if not a passable surface AND not swimmable water,
// Skip if not a passable surface (no swim path either) // OR if the mobile can't walk and this isn't water.
if (!itemData.Surface || itemData.Impassable) if ((!itemData.Surface || itemData.Impassable) && (!canSwim || notWater)
|| cantWalk && notWater)
{ {
continue; continue;
} }
@ -176,7 +200,6 @@ public static class StepProbe
var ourZ = itemZ + itemData.CalcHeight; var ourZ = itemZ + itemData.CalcHeight;
testTop = checkTop; testTop = checkTop;
// Pick the candidate closest to startZ; ties broken by higher ourZ
if (moveIsOk) if (moveIsOk)
{ {
var cmp = Math.Abs(ourZ - startZ) - Math.Abs(newZ - startZ); var cmp = Math.Abs(ourZ - startZ) - Math.Abs(newZ - startZ);
@ -209,7 +232,6 @@ public static class StepProbe
continue; continue;
} }
// IsOk equivalent: check static tiles don't block (ourZ, testTop) space
if (StaticsBlockAt(map, x, y, ourZ, testTop)) if (StaticsBlockAt(map, x, y, ourZ, testTop))
{ {
continue; continue;
@ -219,7 +241,6 @@ public static class StepProbe
moveIsOk = true; moveIsOk = true;
} }
// Land surface fallback (mirrors Check's land block at the bottom)
if (!considerLand || landBlocks || stepTop < landZ) if (!considerLand || landBlocks || stepTop < landZ)
{ {
return moveIsOk; return moveIsOk;

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using Server.Engines.Pathing;
using Server.Engines.Pathing.Cache; using Server.Engines.Pathing.Cache;
using Server.Items; using Server.Items;
using Server.PathAlgorithms; using Server.PathAlgorithms;
@ -61,6 +62,7 @@ namespace Server
{ {
CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand);
CacheEvictionTimer.Configure(); CacheEvictionTimer.Configure();
PathCacheCommands.Configure();
} }
[Usage("Path")] [Usage("Path")]

View file

@ -2,7 +2,7 @@ namespace Server.PathAlgorithms
{ {
public abstract class PathAlgorithm public abstract class PathAlgorithm
{ {
private static readonly Direction[] m_CalcDirections = private static readonly Direction[] _calcDirections =
{ {
Direction.Up, Direction.Up,
Direction.North, Direction.North,
@ -18,7 +18,7 @@ namespace Server.PathAlgorithms
public abstract bool CheckCondition(Mobile m, Map map, Point3D start, Point3D goal); 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 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 x = xDest + 1 - xSource;
var y = yDest + 1 - ySource; var y = yDest + 1 - ySource;
@ -29,7 +29,7 @@ namespace Server.PathAlgorithms
return Direction.North; return Direction.North;
} }
return m_CalcDirections[v]; return _calcDirections[v];
} }
} }
} }

View file

@ -0,0 +1,40 @@
using Server.Engines.Pathing.Cache;
namespace Server.Engines.Pathing;
/// <summary>
/// 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.
/// </summary>
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.");
}
}