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.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<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>
/// 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.
/// </summary>
private sealed class DefaultWalkerStub : Mobile
@ -94,10 +338,23 @@ public class BitmapAStarAlgorithmTests
}
/// <summary>
/// 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.
/// </summary>
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>
private sealed class SwimmingStub : BaseCreature
{
@ -106,4 +363,38 @@ public class BitmapAStarAlgorithmTests
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);
// 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
}

View file

@ -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);
}
/// <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)
{

View file

@ -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));
}
}
}

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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;
/// <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>
/// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell.
/// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set.
@ -365,22 +516,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
/// <summary>
/// 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.
/// </summary>
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;
}

View file

@ -1,8 +1,9 @@
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// 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).
/// </summary>
public enum CacheHitKind : byte
{

View file

@ -143,25 +143,16 @@ public sealed class StepCache
private const int ChunkSize = 16;
/// <summary>
/// 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 <see cref="StepMask.IsHit"/> to decide whether to use the result or fall
/// back to the slow path.
/// </summary>
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
);
}
/// <summary>
@ -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.

View file

@ -1,35 +1,45 @@
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// 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.
/// </summary>
internal sealed class StepChunk
{
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>
public readonly byte[] Mask = new byte[CellsPerChunk];
/// <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[] 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[] 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];
/// <summary>
/// 32 bytes = 256 bits. Bit set = cell has &gt;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.
/// </summary>
public readonly byte[] MultiZCells = new byte[32];
private byte[] _multiZCells;
/// <summary>Snapshot of Sector.MultisVersion at the time this chunk was built.</summary>
public int BuiltMultisVersion;
@ -37,14 +47,12 @@ internal sealed class StepChunk
/// <summary>Updated on every cache hit/miss. Used by LRU fallback eviction.</summary>
public long LastTouchedTicks;
/// <summary>True if any cell in this chunk has more than one walkable surface (set during build).</summary>
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));
}
}

View file

@ -1,39 +1,85 @@
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(
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;
/// <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.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
};
}

View file

@ -9,10 +9,10 @@ namespace Server.Engines.Pathing.Cache;
/// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<sbyte> destZs = stackalloc sbyte[8];
byte walkMask = 0;
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++)
{
@ -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]
);
}
/// <summary>
/// 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 <see cref="StepCache"/> to bake SourceZ so
/// A*'s tracked-per-cell Z matches the cache's bake-time assumption.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </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 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
}
/// <summary>
/// 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.
/// </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;
@ -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;

View file

@ -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")]

View file

@ -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];
}
}
}

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.");
}
}