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