feat: Replace FastAStarAlgorithm with BitmapAStarAlgorithm (#2446)

## Summary

Replaces `FastAStarAlgorithm` with `BitmapAStarAlgorithm`: one cache lookup per cell expansion (8-direction mask + per-direction destination Z) instead of 8 separate `MovementImpl.CheckMovement` calls. Adds the supporting cache infrastructure to back it.

Public API unchanged — `MovementPath` / `Mobile.Move` / `CalcMoves.Find` return the same shapes; the algorithm swap is internal.

## What's in this PR

- **`BitmapAStarAlgorithm`** — A* that issues one `StepCache.TryGetMask` call per cell expansion. Inline fallthrough to the per-cell slow path for multi-Z, off-map, source-Z mismatch, and non-default walkers.
- **`StepCache`** — singleton chunk store keyed by `(mapId, chunkX, chunkY)`. Lazily built on first query, invalidated by `Sector.MultisVersion` mismatch, memory-bounded by sampled probabilistic LRU.
- **`StepProbe`** — computes static-only walkability for a single cell, mirroring `MovementImpl.Check` minus the item / mobile collision phases.
- **`StepMask` / `StepChunk`** — value / storage types for the per-cell results.
- **`CacheEvictionTimer`** — periodic cap backstop (60s interval; early-returns when not over cap).
- **`Map.Sector.MultisVersion`** promoted to `public` so the cache can detect dynamic-static invalidations cheaply.

## Eviction strategy

Sampled probabilistic LRU (Redis-style). Per eviction, sample 5 random keys from a parallel `List<long>` kept in lockstep with the chunk dictionary; evict the oldest of the sample via swap-and-pop. O(1) per eviction regardless of resident count, so sustained cap pressure has no perpetual perf hit.

## Capability handling (interim)

Non-default walkers (non-GM players, creatures with `CanSwim` / `CanFly` / `CanOpenDoors` / `CanMoveOverObstacles`) route entirely through the per-cell slow path via `BitmapAStarAlgorithm.GetSuccessorsSlowPath`. The 2-pass design (cache + capability overlay + dynamic-obstacle pass) lands in the follow-up PR.
This commit is contained in:
Kamron Batman 2026-05-05 21:53:43 -07:00 committed by GitHub
parent ee1bf23b72
commit 6a3804addc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1832 additions and 50 deletions

View file

@ -1875,7 +1875,7 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
internal List<BaseMulti> Multis => _multis ?? m_DefaultMultiList;
internal int MultisVersion => _multisVersion;
public int MultisVersion => _multisVersion;
internal ref readonly ValueLinkList<Mobile> Mobiles => ref _mobiles;

View file

@ -0,0 +1,91 @@
using System;
using System.IO;
using System.Reflection;
using Server.Items;
using Server.Misc;
using Server.Movement;
using Server.Tests.Maps;
using Xunit;
namespace Server.Tests.Pathfinding;
[CollectionDefinition("Sequential Pathfinding Tests", DisableParallelization = true)]
public class PathfindingTestFixture : ICollectionFixture<PathfindingTestFixture>, IDisposable
{
public PathfindingTestFixture()
{
Core.ApplicationAssembly = Assembly.GetExecutingAssembly();
Core.LoopContext = new EventLoopContext();
Core.Expansion = Expansion.EJ;
ServerConfiguration.Load(true);
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR")
?? @"C:\Ultima Online Classic";
ServerConfiguration.DataDirectories.Add(clientFiles);
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
SkillsInfo.Configure();
Server.Network.NetState.Configure();
TestMapDefinitions.ConfigureTestMapDefinitions();
World.Configure();
Timer.Init(0);
RaceDefinitions.Configure();
MovementImpl.Configure();
World.Load();
World.ExitSerializationThreads();
DecayScheduler.Configure();
// TileData's static cctor short-circuits when running under xUnit
// (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable
// flags are populated; without this, every tile reads as flag=None and
// MovementImpl.CheckMovement treats everything as walkable.
ForceLoadTileData();
VerifyTrammelTileDataLoaded();
}
private static void ForceLoadTileData()
{
var loadMethod = typeof(TileData).GetMethod(
"Load",
BindingFlags.Static | BindingFlags.NonPublic
);
if (loadMethod == null)
{
throw new InvalidOperationException(
"TileData.Load not found via reflection — engine may have refactored."
);
}
loadMethod.Invoke(null, null);
}
private static void VerifyTrammelTileDataLoaded()
{
var trammel = Map.Maps[1];
if (trammel == null)
{
throw new InvalidOperationException(
"Trammel (mapId=1) was not registered. Check TestMapDefinitions."
);
}
var tile = trammel.Tiles.GetLandTile(1500, 1600);
if (tile.ID == 0)
{
throw new InvalidOperationException(
$"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " +
$"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " +
$"{Path.Combine(Core.BaseDirectory, "Data")}."
);
}
}
public void Dispose()
{
Timer.Init(0);
}
}

View file

@ -0,0 +1,109 @@
using Server.Engines.Pathing.Cache;
using Server.Mobiles;
using Server.PathAlgorithms.BitmapAStar;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
/// <summary>
/// Smoke tests for <see cref="BitmapAStarAlgorithm"/>'s two branches: cache-direct fast
/// path (default walkers) and per-cell slow path (capability creatures and non-GM players).
/// Exercised end-to-end against the real Trammel TileMatrix to ensure neither regresses
/// to "no path found" on reachable goals.
/// </summary>
[Collection("Sequential Pathfinding Tests")]
public class BitmapAStarAlgorithmTests
{
private readonly ITestOutputHelper _output;
public BitmapAStarAlgorithmTests(ITestOutputHelper output)
{
_output = output;
}
[Theory]
// Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable.
// Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches
// during expansion (GetAverageZ at the goal cell may differ from the engine's
// runtime-computed standing Z, which would break the destNode equality check).
[InlineData(1500, 1600, 1498, 1598)] // NW, 2 cells diagonal
[InlineData(1500, 1600, 1497, 1599)] // NW-ish, 3 W + 1 N
public void DefaultWalker_FindsPath_ViaCacheFastPath(int sx, int sy, int gx, int gy)
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new DefaultWalkerStub();
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 result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
stub.Delete();
Assert.NotNull(result);
Assert.NotEmpty(result);
_output.WriteLine($"default-walker ({sx},{sy})->({gx},{gy}): {result.Length} steps");
}
[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)
{
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new SwimmingStub(World.NewMobile);
stub.DefaultMobileInit();
stub.CanSwim = true; // forces non-default-walker → inline GetSuccessorsSlowPath
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 result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
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);
_output.WriteLine($"swimmer ({sx},{sy})->({gx},{gy}): {result.Length} steps");
}
/// <summary>
/// Plain Mobile — IsDefaultWalker returns true, the bitmap algorithm uses the cache
/// fast path on every expansion.
/// </summary>
private sealed class DefaultWalkerStub : Mobile
{
public DefaultWalkerStub()
{
Body = 0xC9;
}
}
/// <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.
/// </summary>
private sealed class SwimmingStub : BaseCreature
{
public SwimmingStub(Serial serial) : base(serial)
{
Body = 0xC9;
}
}
}

View file

@ -0,0 +1,229 @@
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StepCacheLifecycleTests
{
[Fact]
public void Singleton_IsAvailable()
{
var cache = StepCache.Instance;
Assert.NotNull(cache);
}
[Fact]
public void Clear_OnEmptyCache_LeavesStatsZero()
{
var cache = StepCache.Instance;
cache.Clear();
var stats = cache.GetStats();
Assert.Equal(0, stats.ResidentChunks);
Assert.Equal(0L, stats.Hits);
Assert.Equal(0L, stats.BuildsTotal);
}
[Fact]
public void TryGetMask_FirstQuery_BuildsChunkAndReturnsBakerOutput()
{
var cache = StepCache.Instance;
cache.Clear();
var map = Map.Maps[1];
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
);
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);
var stats = cache.GetStats();
Assert.Equal(1, stats.ResidentChunks);
Assert.Equal(1L, stats.MissesNotBuilt);
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 stats2 = cache.GetStats();
Assert.Equal(1, stats2.ResidentChunks);
Assert.Equal(1L, stats2.Hits);
}
[Fact]
public void TryGetMask_OffMap_ReturnsFalseFallthrough()
{
var cache = StepCache.Instance;
cache.Clear();
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
);
Assert.False(ok);
Assert.Equal(CacheHitKind.Fallthrough_OffMap, hitKind);
Assert.Equal((byte)0, mask);
}
[Fact]
public void MultisVersion_Bump_TriggersDirtyRebuild()
{
var cache = StepCache.Instance;
cache.Clear();
var map = Map.Maps[1];
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);
// Bump _multisVersion via reflection.
var versionField = typeof(Map.Sector).GetField(
"_multisVersion",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
Assert.NotNull(versionField);
var current = (int)versionField.GetValue(sector);
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);
var stats = cache.GetStats();
Assert.Equal(1L, stats.MissesDirtyRebuild);
Assert.Equal(2L, stats.BuildsTotal);
// Mutual-exclusivity invariant: every successful TryGetMask hits exactly one
// outcome counter. Two queries above both returned true (the test cell is not
// multi-Z and not off-map), so the three outcome counters must sum to 2 and the
// fallthrough counters must be zero.
Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits);
Assert.Equal(0L, stats.FallthroughMultiZ);
Assert.Equal(0L, stats.FallthroughOffMap);
}
[Fact]
public void MultiZCell_RoutesToFallthrough()
{
var cache = StepCache.Instance;
cache.Clear();
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 _);
// 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.
var preInjectionFallthroughMultiZ = cache.GetStats().FallthroughMultiZ;
// Inject a multi-Z bit via reflection on the resident chunk.
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
Assert.NotNull(chunksField);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
Assert.True(chunks.ContainsKey(key));
var chunk = chunks[key];
// Mark cell at (1500, 1600) within the chunk as multi-Z.
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);
Assert.False(ok);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, hitKind);
var stats = cache.GetStats();
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
}
[Fact]
public void LruCap_OverflowEvictsToCap()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MaxResidentChunks = 4;
try
{
var map = Map.Maps[1];
// Build 5 distinct chunks by querying different sectors.
for (var i = 0; i < 5; i++)
{
var x = 1500 + (i * 16);
var y = 1600;
cache.TryGetMask(map, x, y, 10,
out _, out _, out _, out _, out _, out _, out _, out _, out _, out _);
System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
}
cache.EnforceLruCap();
Assert.Equal(4, cache.GetStats().ResidentChunks);
Assert.True(cache.GetStats().EvictionsByLruCap >= 1L);
// _keysList must stay in lockstep with _chunks. A desync would silently
// break sampled eviction (KeyNotFoundException on stale keys, or a stuck
// resident set on missing keys).
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var keysListField = typeof(StepCache).GetField(
"_keysList",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var keysList = (System.Collections.Generic.List<long>)keysListField.GetValue(cache);
Assert.Equal(chunks.Count, keysList.Count);
foreach (var k in keysList)
{
Assert.True(chunks.ContainsKey(k), $"keysList holds key {k} not in _chunks");
}
}
finally
{
cache.MaxResidentChunks = 8192;
}
}
}

View file

@ -0,0 +1,90 @@
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StepCacheParityTests
{
private readonly ITestOutputHelper _output;
public StepCacheParityTests(ITestOutputHelper output)
{
_output = output;
}
[Theory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
[InlineData("britain_causeway", 1475, 1641, 32)]
public void CacheMatchesBaker(string label, int xStart, int yStart, int size)
{
var cache = StepCache.Instance;
cache.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);
var disagreements = 0;
var samples = 0;
var multiZ = 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 _);
// The cache bakes from the slow path's standing Z (the Z a creature actually
// stands at on this cell). Query with the same Z so the source-Z guard
// doesn't false-positive on every paver cell.
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
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
);
samples++;
if (hitKind == CacheHitKind.Fallthrough_MultiZ)
{
multiZ++;
continue;
}
Assert.True(ok, $"Cache returned !ok at ({x},{y}) hitKind={hitKind}");
if (mask != baker.Mask)
{
disagreements++;
_output.WriteLine($"MASK DIFF @ ({x},{y}) cache=0x{mask:X2} baker=0x{baker.Mask: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)
{
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($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}");
// Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
if (label == "britain_inn_dense")
{
Assert.True(samples - multiZ > 0, "expected real cache answers in dense region");
}
Assert.Equal(0, disagreements);
}
}

View file

@ -0,0 +1,126 @@
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StaticWalkabilityParityTests
{
private readonly ITestOutputHelper _output;
public StaticWalkabilityParityTests(ITestOutputHelper output)
{
_output = output;
}
[Theory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
public void BakerMatchesCheckMovement(string label, int xStart, int yStart, int size)
{
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new ParityStubMobile();
stub.MoveToWorld(new Point3D(xStart, yStart, 0), map);
var disagreements = 0;
var samples = 0;
var oldWalkable = 0;
var newWalkable = 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)avgZ;
var loc = new Point3D(x, y, sourceZ);
var bakerResult = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
for (var d = 0; d < 8; d++)
{
var dir = (Direction)d;
samples++;
var oldOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var oldZ);
// Apply creature diagonal corner-cut rule at query time:
// diagonal walkable iff raw-diagonal AND (left-partner OR right-partner).
// (Raw masks are correct per spec; baker omits diagonal logic per design.)
var newOk = bakerResult.IsWalkable(dir);
if (newOk && ((d & 1) == 1))
{
var leftPartner = (Direction)((d - 1) & 7);
var rightPartner = (Direction)((d + 1) & 7);
if (!bakerResult.IsWalkable(leftPartner) && !bakerResult.IsWalkable(rightPartner))
{
newOk = false;
}
}
var newZ = bakerResult.GetDestZ(dir);
if (oldOk)
{
oldWalkable++;
}
if (newOk)
{
newWalkable++;
}
if (oldOk != newOk)
{
disagreements++;
_output.WriteLine(
$"DISAGREE walkable @ ({x},{y},{sourceZ}) dir={dir}: " +
$"old={oldOk} new={newOk}"
);
}
else if (oldOk && oldZ != newZ)
{
disagreements++;
_output.WriteLine(
$"DISAGREE destZ @ ({x},{y},{sourceZ}) dir={dir}: " +
$"old={oldZ} new={newZ}"
);
}
}
}
}
stub.Delete();
_output.WriteLine(
$"[{label}] Samples: {samples}, Disagreements: {disagreements}, " +
$"OldWalkable: {oldWalkable}, NewWalkable: {newWalkable}"
);
// Non-vacuity guard for the variety case: at least one region must show some
// blocked directions. The open_plain region is allowed to be all-walkable.
if (label == "britain_inn_dense")
{
Assert.NotEqual(0, oldWalkable);
Assert.NotEqual(samples, oldWalkable);
}
Assert.Equal(0, disagreements);
}
/// <summary>
/// Minimal Mobile stub for parity testing. Inherits directly from Mobile so that
/// MovementImpl sees no BaseCreature-specific flags (CanSwim=false, CanFly=false,
/// bc==null → BaseCreature branches skipped) giving us the default static walker baseline.
/// </summary>
private class ParityStubMobile : Mobile
{
public ParityStubMobile()
{
Body = 0xC9; // arbitrary horse body
}
}
}

View file

@ -0,0 +1,248 @@
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StepProbeTests
{
private readonly ITestOutputHelper _output;
public StepProbeTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void ComputeMaskAt_NullMap_ReturnsDefault()
{
var result = StepProbe.ComputeMaskAt(null, 100, 100, 0);
Assert.Equal(0, result.Mask);
for (var d = 0; d < 8; d++)
{
Assert.Equal(0, result.GetDestZ((Direction)d));
}
}
[Fact]
public void ComputeMaskAt_InternalMap_ReturnsDefault()
{
var result = StepProbe.ComputeMaskAt(Map.Internal, 100, 100, 0);
Assert.Equal(0, result.Mask);
for (var d = 0; d < 8; d++)
{
Assert.Equal(0, result.GetDestZ((Direction)d));
}
}
[Fact]
public void ComputeMaskAt_TopLeftCorner_OffMapDirectionsBlocked()
{
// From source (0, 0), these neighbor cells are off-map and MUST be blocked,
// regardless of map content:
// N = (0, -1) bit 0
// NE = (1, -1) bit 1
// SW = (-1, 1) bit 5
// W = (-1, 0) bit 6
// NW = (-1, -1) bit 7
// Bits 2 (E), 3 (SE), 4 (S) depend on map content and aren't asserted.
var map = Map.Maps[1];
Assert.NotNull(map);
var result = StepProbe.ComputeMaskAt(map, 0, 0, 0);
Assert.False(result.IsWalkable(Direction.North), "N should be off-map");
Assert.False(result.IsWalkable(Direction.Right), "NE should be off-map");
Assert.False(result.IsWalkable(Direction.Left), "SW should be off-map");
Assert.False(result.IsWalkable(Direction.West), "W should be off-map");
Assert.False(result.IsWalkable(Direction.Up), "NW should be off-map");
}
[Fact]
public void ComputeMaskAt_BottomRightCorner_OffMapDirectionsBlocked()
{
var map = Map.Maps[1];
Assert.NotNull(map);
var x = map.Width - 1;
var y = map.Height - 1;
var result = StepProbe.ComputeMaskAt(map, x, y, 0);
// From the SE corner, S/SE/SW/E/NE all go off-map (only N/NW/W in-map).
Assert.False(result.IsWalkable(Direction.Right), "NE should be off-map");
Assert.False(result.IsWalkable(Direction.East), "E should be off-map");
Assert.False(result.IsWalkable(Direction.Down), "SE should be off-map");
Assert.False(result.IsWalkable(Direction.South), "S should be off-map");
Assert.False(result.IsWalkable(Direction.Left), "SW should be off-map");
}
[Fact]
public void ComputeMaskAt_TrammelOpenPlain_HasFlatCellWithAllDirectionsWalkable()
{
// Invariant: somewhere in the open-plain region, there must be a fully-flat cell
// whose mask is 0xFF and all 8 destZ values equal sourceZ (no slope).
// This protects against a regression where the baker stops detecting flat cells.
var map = Map.Maps[1];
Assert.NotNull(map);
var found = false;
for (var x = 1500; x < 1532 && !found; x++)
{
for (var y = 1600; y < 1632 && !found; y++)
{
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
if (result.Mask != 0xFF)
{
continue;
}
var allSameZ = true;
for (var d = 0; d < 8; d++)
{
if (result.GetDestZ((Direction)d) != sourceZ)
{
allSameZ = false;
break;
}
}
if (allSameZ)
{
found = true;
_output.WriteLine($"Flat-open cell found at ({x}, {y}, {sourceZ})");
}
}
}
Assert.True(found, "Expected at least one fully-flat open cell in (1500..1532, 1600..1632)");
}
[Fact]
public void ComputeMaskAt_BritainInnDense_HasCellWithBlockedDirections()
{
// Invariant: inside the dense Britain inn region, at least one cell must have
// at least one direction blocked by a static. Protects against a regression
// where the baker reports everything as walkable (the original false-pass bug).
var map = Map.Maps[1];
Assert.NotNull(map);
var blockedCellCount = 0;
for (var x = 1480; x < 1512; x++)
{
for (var y = 1610; y < 1642; y++)
{
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
if (result.Mask != 0xFF)
{
blockedCellCount++;
}
}
}
_output.WriteLine($"Cells with at least one blocked direction: {blockedCellCount} / 1024");
Assert.True(blockedCellCount > 0, "Expected at least one cell with a blocked direction in the dense region");
}
[Theory]
[InlineData(1500, 1600)]
[InlineData(1480, 1610)]
[InlineData(0, 0)]
public void ComputeMaskAt_IsDeterministic(int x, int y)
{
// Same inputs must always produce identical output. Catches accidental
// statefulness in the baker (e.g., a static cache that gets corrupted).
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var first = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
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);
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));
}
}
[Fact]
public void ComputeMaskAt_PinnedCell_TrammelOpenPlainOrigin()
{
// PINNING test: locks specific output for Trammel (1500, 1600).
// Cell at z=10 with mask 0xC1 (N + W + NW only walkable). The other five
// directions are blocked by water (E/SE/S/SW are wet tiles, NE is shore).
// If this changes, either the baker logic changed or the underlying tile
// data changed; re-run the parity test to confirm before updating expected values.
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ);
Assert.Equal((sbyte)10, sourceZ);
Assert.Equal((byte)0xC1, result.Mask);
Assert.True(result.IsWalkable(Direction.North));
Assert.False(result.IsWalkable(Direction.Right));
Assert.False(result.IsWalkable(Direction.East));
Assert.False(result.IsWalkable(Direction.Down));
Assert.False(result.IsWalkable(Direction.South));
Assert.False(result.IsWalkable(Direction.Left));
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));
}
[Fact]
public void ComputeMaskAt_PinnedCell_BritainInnDenseOrigin()
{
// PINNING test: locks specific output for Trammel (1480, 1610).
// Cell at z=20 with mask 0x3F (N/NE/E/SE/S/SW walkable, W/NW blocked by
// a wall to the west). All walkable directions stay flat at z=20.
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1480, 1610, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ);
Assert.Equal((sbyte)20, sourceZ);
Assert.Equal((byte)0x3F, result.Mask);
Assert.True(result.IsWalkable(Direction.North));
Assert.True(result.IsWalkable(Direction.Right));
Assert.True(result.IsWalkable(Direction.East));
Assert.True(result.IsWalkable(Direction.Down));
Assert.True(result.IsWalkable(Direction.South));
Assert.True(result.IsWalkable(Direction.Left));
Assert.False(result.IsWalkable(Direction.West));
Assert.False(result.IsWalkable(Direction.Up));
for (var d = 0; d < 6; d++)
{
Assert.Equal((sbyte)20, result.GetDestZ((Direction)d));
}
}
}

View file

@ -2,7 +2,7 @@
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FastAStarAlgorithm.cs *
* File: BitmapAStarAlgorithm.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
@ -14,15 +14,24 @@
************************************************************************/
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Server.Engines.Pathing.Cache;
using Server.Mobiles;
using CalcMoves = Server.Movement.Movement;
using MoveImpl = Server.Movement.MovementImpl;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace Server.PathAlgorithms.FastAStar;
namespace Server.PathAlgorithms.BitmapAStar;
public class FastAStarAlgorithm : PathAlgorithm
/// <summary>
/// A* pathfinder with a single bitmap-cache lookup per cell expansion. Default walkers
/// take one <see cref="StepCache.TryGetMask"/> call returning the 8-direction
/// mask + per-direction Z. Non-default walkers (non-GM players, creatures with swim/fly/
/// door/clip capabilities) and per-cell cache fallthroughs route through
/// <see cref="GetSuccessorsSlowPath"/>, which runs the per-direction
/// <see cref="CalcMoves.CheckMovement"/> loop for that one cell.
/// </summary>
public class BitmapAStarAlgorithm : PathAlgorithm
{
private struct PathNode
{
@ -40,7 +49,7 @@ public class FastAStarAlgorithm : PathAlgorithm
private const int PlaneOffset = 128;
private const int PlaneCount = 13;
private const int PlaneHeight = 20;
public static readonly PathAlgorithm Instance = new FastAStarAlgorithm();
public static readonly PathAlgorithm Instance = new BitmapAStarAlgorithm();
private static readonly Direction[] _path = new Direction[AreaSize * AreaSize];
private static readonly PathNode[] _nodes = new PathNode[NodeCount];
@ -51,6 +60,10 @@ public class FastAStarAlgorithm : PathAlgorithm
private static int _xOffset;
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.
private static bool _currentMobileNeedsSlowPath;
private Point3D _goal;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -76,6 +89,8 @@ public class FastAStarAlgorithm : PathAlgorithm
return null;
}
_currentMobileNeedsSlowPath = !IsDefaultWalker(m);
Array.Clear(_nodeStates);
_goal = goal;
@ -120,6 +135,7 @@ public class FastAStarAlgorithm : PathAlgorithm
_nodeStates[bestNode] = 2;
// Set MovementImpl globals so per-cell slow-path fallthroughs see the right state.
if (bc != null)
{
MoveImpl.AlwaysIgnoreDoors = bc.CanOpenDoors;
@ -203,11 +219,13 @@ public class FastAStarAlgorithm : PathAlgorithm
}
_openQueue.Clear();
_currentMobileNeedsSlowPath = false;
return dirs;
}
}
_openQueue.Clear();
_currentMobileNeedsSlowPath = false;
return null;
}
@ -221,6 +239,13 @@ public class FastAStarAlgorithm : PathAlgorithm
return x + y * AreaSize + z * AreaSize * AreaSize;
}
/// <summary>
/// One <see cref="StepCache.TryGetMask"/> call returns the 8-direction
/// walkable mask + destination Zs. Diagonal corner-cut applies the lenient creature
/// OR-rule using partner bits in the same mask byte — no neighbor-chunk lookup needed.
/// On cache fallthrough or for non-default walkers, defers to
/// <see cref="GetSuccessorsSlowPath"/> for THIS cell only.
/// </summary>
private static int GetSuccessors(int p, Mobile m, Map map)
{
var px = p % AreaSize;
@ -230,50 +255,94 @@ public class FastAStarAlgorithm : PathAlgorithm
var p3D = new Point3D(px + _xOffset, py + _yOffset, pz);
var vals = _successors;
if (_currentMobileNeedsSlowPath)
{
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
);
if (hitKind is CacheHitKind.Fallthrough_MultiZ
or CacheHitKind.Fallthrough_OffMap
or CacheHitKind.Fallthrough_SourceZMismatch)
{
return GetSuccessorsSlowPath(m, map, px, py, p3D, vals);
}
for (var i = 0; i < 8; ++i)
{
var x = px;
var y = py;
CalcMoves.Offset((Direction)i, ref x, ref y);
if (x is < 0 or >= AreaSize || y is < 0 or >= AreaSize)
{
continue;
}
if ((mask & (1 << i)) == 0)
{
continue;
}
// Diagonal corner-cut (creature OR-rule): partner bits live in the same mask byte.
if ((i & 1) == 1)
{
var leftBit = 1 << ((i - 1) & 0x7);
var rightBit = 1 << ((i + 1) & 0x7);
if ((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
};
var idx = GetIndex(x + _xOffset, y + _yOffset, z);
if (idx >= 0 && idx < NodeCount)
{
_nodes[idx].z = z;
vals[count++] = idx;
}
}
return count;
}
/// <summary>
/// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell.
/// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set.
/// </summary>
private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals)
{
var count = 0;
for (var i = 0; i < 8; ++i)
{
int x;
int y;
switch (i)
{
default: // 0
x = 0;
y = -1;
break;
case 1:
x = 1;
y = -1;
break;
case 2:
x = 1;
y = 0;
break;
case 3:
x = 1;
y = 1;
break;
case 4:
x = 0;
y = 1;
break;
case 5:
x = -1;
y = 1;
break;
case 6:
x = -1;
y = 0;
break;
case 7:
x = -1;
y = -1;
break;
}
x += px;
y += py;
var x = px;
var y = py;
CalcMoves.Offset((Direction)i, ref x, ref y);
if (x is < 0 or >= AreaSize || y is < 0 or >= AreaSize)
{
@ -294,4 +363,24 @@ public class FastAStarAlgorithm : PathAlgorithm
return count;
}
/// <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.
/// </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;
}
}

View file

@ -0,0 +1,31 @@
using System;
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Periodic backstop that enforces the StaticWalkabilityCache resident-chunk cap.
/// Steady-state cost is a single early-return; only fires real work when the cache
/// has overflowed MaxResidentChunks. Runs on the game thread; no locking required.
/// </summary>
public class CacheEvictionTimer : Timer
{
private static CacheEvictionTimer _instance;
public static void Configure()
{
if (_instance != null)
{
return;
}
_instance = new CacheEvictionTimer();
_instance.Start();
}
private CacheEvictionTimer() : base(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) { }
protected override void OnTick()
{
StepCache.Instance.EnforceLruCap();
}
}

View file

@ -0,0 +1,15 @@
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Outcome categories for StaticWalkabilityCache.TryGetMask. Used for telemetry
/// and to drive the slow-path fallthrough decision in callers.
/// </summary>
public enum CacheHitKind : byte
{
Hit = 0, // clean default-walker answer from the resident chunk
Miss_NotBuilt = 1, // chunk wasn't resident; built and returned
Miss_DirtyRebuild = 2, // version mismatch; rebuilt and returned
Fallthrough_MultiZ = 3, // cell has multiple walkable surfaces; caller must use slow path
Fallthrough_OffMap = 4, // out of bounds
Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge
}

View file

@ -0,0 +1,28 @@
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Snapshot of StaticWalkabilityCache counters. Returned by GetStats() and consumed
/// by the [PathCacheStats admin command. All counters are monotonic except ResidentChunks.
/// </summary>
public readonly struct CacheStats(
int residentChunks,
long hits,
long missesNotBuilt,
long missesDirtyRebuild,
long fallthroughMultiZ,
long fallthroughOffMap,
long fallthroughSourceZMismatch,
long evictionsByLruCap,
long buildsTotal
)
{
public readonly int ResidentChunks = residentChunks;
public readonly long Hits = hits;
public readonly long MissesNotBuilt = missesNotBuilt;
public readonly long MissesDirtyRebuild = missesDirtyRebuild;
public readonly long FallthroughMultiZ = fallthroughMultiZ;
public readonly long FallthroughOffMap = fallthroughOffMap;
public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch;
public readonly long EvictionsByLruCap = evictionsByLruCap;
public readonly long BuildsTotal = buildsTotal;
}

View file

@ -0,0 +1,355 @@
using System;
using System.Collections.Generic;
using Server.Logging;
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Singleton store of per-chunk static walkability data. Chunks correspond to
/// Map.SectorSize = 16; key encoding packs (mapId, chunkX, chunkY) into a long.
/// Lazily built on first query; invalidated by version-check vs Sector.MultisVersion;
/// memory bounded by MaxResidentChunks via probabilistic LRU eviction.
///
/// Default-walker scope only. Cells with multi-Z surfaces and queries for non-default
/// walkers route to the MovementImpl slow path via the Fallthrough_* hit kinds.
/// </summary>
public sealed class StepCache
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(StepCache));
public static StepCache Instance { get; } = new();
private readonly Dictionary<long, StepChunk> _chunks = new();
// Parallel list of keys for O(1) random sampling during eviction. Kept in lockstep
// with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction.
private readonly List<long> _keysList = new();
// Telemetry counters
private long _hits;
private long _missesNotBuilt;
private long _missesDirtyRebuild;
private long _fallthroughMultiZ;
private long _fallthroughOffMap;
private long _fallthroughSourceZMismatch;
private long _evictionsByLruCap;
private long _buildsTotal;
private StepCache() { }
/// <summary>Hard cap on resident chunk count. Default 8192. Override for tests / ops.</summary>
public int MaxResidentChunks { get; set; } = 8192;
/// <summary>
/// Pack (mapId, chunkX, chunkY) into a single long key.
/// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16].
/// </summary>
internal static long EncodeKey(int mapId, int chunkX, int chunkY) =>
((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF);
public CacheStats GetStats() => new CacheStats(
residentChunks: _chunks.Count,
hits: _hits,
missesNotBuilt: _missesNotBuilt,
missesDirtyRebuild: _missesDirtyRebuild,
fallthroughMultiZ: _fallthroughMultiZ,
fallthroughOffMap: _fallthroughOffMap,
fallthroughSourceZMismatch: _fallthroughSourceZMismatch,
evictionsByLruCap: _evictionsByLruCap,
buildsTotal: _buildsTotal
);
/// <summary>
/// Drop all cached chunks AND zero every telemetry counter. Used by tests and
/// benchmarks that need a known cold-start state. Counter reset is intentional —
/// counters are since-last-clear, not since-startup.
/// </summary>
public void Clear()
{
_chunks.Clear();
_keysList.Clear();
_hits = 0;
_missesNotBuilt = 0;
_missesDirtyRebuild = 0;
_fallthroughMultiZ = 0;
_fallthroughOffMap = 0;
_fallthroughSourceZMismatch = 0;
_evictionsByLruCap = 0;
_buildsTotal = 0;
}
/// <summary>
/// Probabilistic LRU sample size — picks SampleSize random resident chunks per
/// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny
/// fraction of the cost (no full sort). Redis uses the same approach (`maxmemory-samples`).
/// 5 yields ~quality-of-true-LRU for cache eviction; higher values trade speed for accuracy.
/// </summary>
private const int LruSampleSize = 5;
/// <summary>
/// If resident chunk count exceeds MaxResidentChunks, evict via probabilistic LRU
/// until the count is at or below the cap. Per-eviction cost is O(LruSampleSize),
/// independent of resident count — sustained cap pressure has no perpetual perf hit.
/// Called from CacheEvictionTimer; also callable directly from tests.
/// </summary>
public void EnforceLruCap()
{
var overflow = _chunks.Count - MaxResidentChunks;
if (overflow <= 0)
{
return;
}
while (overflow-- > 0 && _keysList.Count > 0)
{
var oldestIdx = -1;
long oldestTouched = long.MaxValue;
long oldestKey = 0;
// Sample LruSampleSize random keys; track the oldest by LastTouchedTicks.
// With replacement is fine — collisions are rare and don't break correctness.
var samples = Math.Min(LruSampleSize, _keysList.Count);
for (var s = 0; s < samples; s++)
{
var idx = Utility.Random(_keysList.Count);
var k = _keysList[idx];
var touched = _chunks[k].LastTouchedTicks;
if (touched < oldestTouched)
{
oldestTouched = touched;
oldestKey = k;
oldestIdx = idx;
}
}
_chunks.Remove(oldestKey);
// Swap-and-pop _keysList[oldestIdx] with the tail; O(1) regardless of position.
var last = _keysList.Count - 1;
if (oldestIdx != last)
{
_keysList[oldestIdx] = _keysList[last];
}
_keysList.RemoveAt(last);
_evictionsByLruCap++;
}
}
internal static void DecodeKey(long key, out int mapId, out int chunkX, out int chunkY)
{
mapId = (int)((key >> 32) & 0xFFFF);
chunkX = (int)((key >> 16) & 0xFFFF);
chunkY = (int)(key & 0xFFFF);
}
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.
/// </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
)
{
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;
}
var chunkX = x >> 4;
var chunkY = y >> 4;
var key = EncodeKey(map.MapID, chunkX, chunkY);
var hitKindResult = CacheHitKind.Hit;
if (!_chunks.TryGetValue(key, out var chunk))
{
chunk = ResolveMissingChunk(map, chunkX, chunkY);
_chunks[key] = chunk;
_keysList.Add(key);
hitKindResult = CacheHitKind.Miss_NotBuilt;
}
else
{
var sector = map.GetRealSector(chunkX, chunkY);
if (chunk.BuiltMultisVersion != sector.MultisVersion)
{
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).
}
}
chunk.LastTouchedTicks = Core.TickCount;
var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4));
if (chunk.IsCellMultiZ(cellIndex))
{
hitKind = CacheHitKind.Fallthrough_MultiZ;
_fallthroughMultiZ++;
return false;
}
// Source-Z guard: the cache stores one answer per cell baked at SourceZ.
// StepHeight tolerance accepts incremental Z jitter; loosening it breaks parity
// because tile reachability shifts at step-height boundaries.
if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight)
{
hitKind = CacheHitKind.Fallthrough_SourceZMismatch;
_fallthroughSourceZMismatch++;
return false;
}
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;
}
}
hitKind = hitKindResult;
return true;
}
/// <summary>
/// Chunk-miss resolution: build the chunk via the runtime baker.
/// </summary>
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) =>
BuildChunk(map, chunkX, chunkY);
private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
{
var chunk = new StepChunk();
var sector = map.GetRealSector(chunkX, chunkY);
chunk.BuiltMultisVersion = sector.MultisVersion;
var baseX = chunkX << 4;
var baseY = chunkY << 4;
for (var dy = 0; dy < ChunkSize; dy++)
{
for (var dx = 0; dx < ChunkSize; dx++)
{
var x = baseX + dx;
var y = baseY + dy;
var cell = (dy << 4) | dx;
map.GetAverageZ(x, y, out _, out var avgZ, out _);
// Bake from the slow path's "standing Z" (the surface Z a creature actually
// stands at, not the ground avg). A* tracks newZ as standing Z, so SourceZ
// must match for the source-Z guard not to over-fire.
var standingZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
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;
// Multi-Z = ≥2 surfaces reachable from standingZ. Mirrors the baker's
// CheckStaticStep filter so we don't over-mark.
if (CountReachableSurfaces(map, x, y, standingZ) > 1)
{
chunk.MarkCellMultiZ(cell);
}
}
}
_buildsTotal++;
return chunk;
}
private const int PersonHeight = 16;
private const int StepHeight = 2;
/// <summary>
/// Counts walkable surfaces actually reachable from a creature standing at sourceZ.
/// Mirrors <see cref="StepProbe"/>.CheckStaticStep so cells flagged multi-Z
/// here are exactly those where the baker would have multiple candidate destinations.
/// Reachable when: surface and !impassable; stepTop ≥ itemTop; vertical overlap with
/// the creature's PersonHeight envelope.
/// </summary>
internal static int CountReachableSurfaces(Map map, int x, int y, sbyte sourceZ)
{
var startTop = sourceZ + PersonHeight;
var stepTop = startTop + StepHeight;
var count = 0;
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
{
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
if (!data.Surface || data.Impassable)
{
continue;
}
var itemZ = tile.Z;
var itemTop = data.Bridge ? itemZ : itemZ + data.Height;
if (stepTop < itemTop)
{
continue;
}
if (sourceZ + PersonHeight > itemZ && itemZ + data.Height > sourceZ)
{
count++;
}
}
// Land surface check — same shape, but use GetAverageZ for the land's effective top.
var landTile = map.Tiles.GetLandTile(x, y);
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0)
{
map.GetAverageZ(x, y, out var landZ, out _, out var landTop);
if (stepTop >= landZ && sourceZ + PersonHeight > landZ && landTop > sourceZ)
{
count++;
}
}
return count;
}
}

View file

@ -0,0 +1,50 @@
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.
/// </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];
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];
/// <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.
/// </summary>
public readonly byte[] MultiZCells = new byte[32];
/// <summary>Snapshot of Sector.MultisVersion at the time this chunk was built.</summary>
public int BuiltMultisVersion;
/// <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 void MarkCellMultiZ(int cellIndex)
{
MultiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7));
HasAnyMultiZ = true;
}
}

View file

@ -0,0 +1,39 @@
namespace Server.Engines.Pathing.Cache;
public readonly struct StepMask(
byte mask,
sbyte destZn,
sbyte destZne,
sbyte destZe,
sbyte destZse,
sbyte destZs,
sbyte destZsw,
sbyte destZw,
sbyte destZnw
)
{
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 bool IsWalkable(Direction d) => (Mask & (1 << (int)d)) != 0;
public sbyte GetDestZ(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,
_ => 0
};
}

View file

@ -0,0 +1,280 @@
using System;
using CalcMoves = Server.Movement.Movement;
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Computes static-only walkability for a single cell — the per-cell, per-direction
/// "can step" mask and destination Z, based purely on land + statics + multis. Mirrors
/// <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).
/// </remarks>
public static class StepProbe
{
private const int PersonHeight = 16;
private const int StepHeight = 2;
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ)
{
if (map == null || map == Map.Internal)
{
return default;
}
GetStaticStartZ(map, x, y, sourceZ, out var startZ, out var startTop, out _);
byte mask = 0;
Span<sbyte> destZs = stackalloc sbyte[8];
for (var d = 0; d < 8; d++)
{
var dx = x;
var dy = y;
CalcMoves.Offset((Direction)d, ref dx, ref dy);
if (CheckStaticStep(map, dx, dy, startZ, startTop, out var newZ))
{
mask |= (byte)(1 << d);
destZs[d] = (sbyte)newZ;
}
}
return new StepMask(
mask,
destZs[0], destZs[1], destZs[2], destZs[3],
destZs[4], destZs[5], destZs[6], destZs[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.
/// </summary>
public static int ComputeStandingZ(Map map, int x, int y, int locZ)
{
GetStaticStartZ(map, x, y, locZ, 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.
/// </summary>
private static void GetStaticStartZ(Map map, int x, int y, int locZ, 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;
map.GetAverageZ(x, y, out var landZ, out var landCenter, out var landTop);
var considerLand = !landTile.Ignored;
zCenter = zLow = zTop = 0;
var isSet = false;
if (considerLand && !landBlocks && locZ >= landCenter)
{
zLow = landZ;
zCenter = landCenter;
zTop = landTop;
isSet = true;
}
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
{
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)
{
continue;
}
zLow = tile.Z;
zCenter = calcTop;
var top = tile.Z + id.Height;
if (!isSet || top > zTop)
{
zTop = top;
}
isSet = true;
}
if (!isSet)
{
zLow = zTop = locZ;
}
else if (locZ > zTop)
{
zTop = locZ;
}
}
/// <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.
/// </summary>
private static bool CheckStaticStep(Map map, int x, int y, int startZ, int startTop, out int newZ)
{
newZ = 0;
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
{
return false;
}
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;
var considerLand = !landTile.Ignored;
map.GetAverageZ(x, y, out var landZ, out var landCenter, out _);
var moveIsOk = false;
var stepTop = startTop + StepHeight;
var checkTop = startZ + PersonHeight;
int testTop;
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
{
var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
// CanSwim=false, CantWalk=false:
// Skip if not a passable surface (no swim path either)
if (!itemData.Surface || itemData.Impassable)
{
continue;
}
var itemZ = tile.Z;
var itemTop = itemZ;
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);
if (cmp > 0 || cmp == 0 && ourZ > newZ)
{
continue;
}
}
if (ourZ + PersonHeight > testTop)
{
testTop = ourZ + PersonHeight;
}
if (!itemData.Bridge)
{
itemTop += itemData.Height;
}
if (stepTop < itemTop)
{
continue;
}
var landCheck = itemZ + Math.Min(itemData.Height, StepHeight);
if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ)
{
continue;
}
// IsOk equivalent: check static tiles don't block (ourZ, testTop) space
if (StaticsBlockAt(map, x, y, ourZ, testTop))
{
continue;
}
newZ = ourZ;
moveIsOk = true;
}
// Land surface fallback (mirrors Check's land block at the bottom)
if (!considerLand || landBlocks || stepTop < landZ)
{
return moveIsOk;
}
testTop = checkTop;
if (landCenter + PersonHeight > testTop)
{
testTop = landCenter + PersonHeight;
}
var shouldCheck = true;
if (moveIsOk)
{
var cmp = Math.Abs(landCenter - startZ) - Math.Abs(newZ - startZ);
if (cmp > 0 || cmp == 0 && landCenter > newZ)
{
shouldCheck = false;
}
}
if (shouldCheck && !StaticsBlockAt(map, x, y, landCenter, testTop))
{
newZ = landCenter;
moveIsOk = true;
}
return moveIsOk;
}
/// <summary>
/// Mirrors the static-tile portion of IsOk: returns true if any static tile at (x,y)
/// has ImpassableSurface and overlaps the vertical range (ourZ, testTop).
/// </summary>
private static bool StaticsBlockAt(Map map, int x, int y, int ourZ, int testTop)
{
foreach (var check in map.Tiles.GetStaticAndMultiTiles(x, y))
{
var itemData = TileData.ItemTable[check.ID & TileData.MaxItemValue];
if (itemData.ImpassableSurface)
{
var checkZ = check.Z;
var checkTop = checkZ + itemData.CalcHeight;
if (checkTop > ourZ && testTop > checkZ)
{
return true;
}
}
}
return false;
}
}

View file

@ -1,8 +1,9 @@
using System;
using System.Diagnostics;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Server.PathAlgorithms;
using Server.PathAlgorithms.FastAStar;
using Server.PathAlgorithms.BitmapAStar;
using Server.Spells;
using Server.Targeting;
@ -31,7 +32,7 @@ namespace Server
try
{
var alg = OverrideAlgorithm ?? FastAStarAlgorithm.Instance;
var alg = OverrideAlgorithm ?? BitmapAStarAlgorithm.Instance;
if (alg?.CheckCondition(m, map, start, goal) == true)
{
@ -59,6 +60,7 @@ namespace Server
public static void Configure()
{
CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand);
CacheEvictionTimer.Configure();
}
[Usage("Path")]
@ -111,7 +113,7 @@ namespace Server
SpellHelper.GetSurfaceTop(ref p);
Path(from, p, FastAStarAlgorithm.Instance, "Fast", 0);
Path(from, p, BitmapAStarAlgorithm.Instance, "Bitmap", 0);
OverrideAlgorithm = null;
}
}