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

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