feat(pathfinding): multi-aware mask synthesizer + warm interior cache for house/boat cells (#2479)

## Problem

Houses and boats (multis) were pathed correctly only by **delegation to the slow path**: `StepCache.TryGetMask` returns `Fallthrough_Multi` for any multi-covered cell, and `GetSuccessors` ran `CheckMovement` **8× per cell** (each re-resolving the tile stack via `GetStaticAndMultiTiles`) — a sustained per-step cost near every house/boat. There was also no automated test pinning multi pathfinding.

This branch is the full multi-pathfinding effort in phases on one branch.

## Phase 1 — characterization tests (the oracle)

Implementation-agnostic invariants: a cache-on≡cache-off whole-path invariant, a per-cell sweep vs `CheckMovement` over footprint+halo (incl. destination Z), hand-verified routing (around walls, demolish-reopens, foundation-redesign-honored), classic-house / foundation / boat fixtures, non-vacuity guards. These gate every later phase byte-for-byte.

## Phase 2 — live single-pass synthesizer

`StepProbe.ComputeMultiMaskAt` synthesizes a covered cell's full 8-direction `StepMask` in one pass (the existing surface/step logic over `GetStaticAndMultiTiles` instead of 8× `CheckMovement`). `GetSuccessors` routes `Fallthrough_Multi` cells through it. No new cache, no `.swb` change. **~1.5×**, zero added allocations.

## Phase 3 / 3.1 — warm per-`multiID` interior cache (airtight)

`MultiMaskCache` caches each fixed multi's local-frame `StepMask` for **interior** cells (cell + all 8 neighbours covered → terrain-neighbour-free → position-invariant), keyed by `multiID & 0x3FFF`, built lazily from the MCL. Interior cells become ~20 ns lookups.

The cache is gated on a **per-instance footprint-clean flag** (`BaseMulti.PathInteriorCacheState`): an instance whose whole footprint terrain is below its floor (`maxTerrain < minFloor`) serves from the cache; a **dirty** instance (terrain intrudes — a contrived/GM placement) **degrades to live-synth, never a wrong mask**. This closes a cross-instance soundness gap (the cached mask depends on neighbour terrain too) found in a holistic review. The gate resets whenever the footprint's world-terrain relationship can change — **location, map, or ItemID** (a boat's heading swaps the MCL).

**Boats are cached too.** Their per-`multiID` deck masks are movement-invariant (built once per heading), so a sailing boat never rebuilds them; only the cheap clean-flag rescan repeats per move (and only when pathed near). Narrow existing boats have little interior; wide galleons (`multi.mul`) would gain Castle-class. `HouseFoundation` (per-instance runtime `DesignState`) is the one type that stays on the live path.

## Verification

- `UOContent.Tests` **454/454**, `Server.Tests` **708/708**, 0 failures.
- The Phase-1 oracle (`MultiPathInvariantTests`, cache-on ≡ cache-off) stays **byte-identical** with the synthesizer + interior cache active.
- Tests pin: footprint-cleanliness (clean vs sunk), dirty/cluttered placement degrades to live-synth while still pathing, clean placement serves, and the gate resets on move/ItemID change.

## Performance (modernuo/ModernUO-Benchmarks#8, full-fixture)

Houses at **Green Acres** (flat staff region → clean footprints, the legit-placement case):

| Route | Slow path | Phase 3.1 (interior cache) | Speedup |
|-------|----------:|---------------------------:|--------:|
| `around_a` (29 steps) | 238.3 µs | **49.1 µs** | **4.85×** |
| `around_b` (29 steps) | 224.3 µs | **49.5 µs** | **4.53×** |

~130 of ~167 multi cells/route serve from the cache (~20 ns) vs 37 live-synth. Per-cell, the slow path's 8× `CheckMovement` grows with multi complexity (GuildHouse ~857 ns → Castle ~1,194 ns), the synthesizer is a flat ~780 ns, and the cache serve is ~20 ns — so big/tall multis (and wide galleons) gain most. Identical allocations throughout.
This commit is contained in:
Kamron Batman 2026-06-09 08:02:05 -07:00 committed by GitHub
parent 92a540b2d0
commit 1c2e114b21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2028 additions and 56 deletions

View file

@ -19,6 +19,24 @@ using ModernUO.Serialization;
namespace Server.Items;
/// <summary>
/// Whether a multi instance's footprint is eligible for the pathfinding interior-mask cache
/// (Server.Engines.Pathing.Cache.MultiMaskCache). Stored on
/// <see cref="BaseMulti.PathInteriorCacheState"/>; recomputed when it is <see cref="Unknown"/>
/// (e.g. after a move resets it).
/// </summary>
public enum MultiInteriorCacheState : byte
{
/// <summary>Not yet determined; recompute on next use.</summary>
Unknown = 0,
/// <summary>Whole footprint terrain is below the floor → interior cells may serve from the cache.</summary>
Clean = 1,
/// <summary>Terrain intrudes into the footprint → fall back to live synthesis.</summary>
Dirty = 2
}
[SerializationGenerator(0, false)]
public abstract partial class BaseMulti : Item
{
@ -35,6 +53,10 @@ public abstract partial class BaseMulti : Item
Map?.OnLeave(this);
base.ItemID = value;
Map?.OnEnter(this);
// The footprint shape changes with ItemID (e.g. a boat's heading swaps the MCL), so
// the pathfinding interior-cache clean/dirty status must be recomputed.
PathInteriorCacheState = MultiInteriorCacheState.Unknown;
}
}
}
@ -65,6 +87,27 @@ public abstract partial class BaseMulti : Item
public virtual MultiComponentList Components => MultiData.GetComponents(ItemID);
/// <summary>
/// Pathfinding interior-mask cache gate (Server.Engines.Pathing.Cache.MultiMaskCache).
/// Reset to <see cref="MultiInteriorCacheState.Unknown"/> whenever the footprint's world-terrain
/// relationship can change — a location change, a map change, or an ItemID change (e.g. a boat's
/// heading swaps the MCL). Subclasses that override <see cref="OnLocationChange"/> /
/// <see cref="OnMapChange"/> MUST call base for the reset to fire.
/// </summary>
public MultiInteriorCacheState PathInteriorCacheState { get; set; }
public override void OnLocationChange(Point3D oldLocation)
{
base.OnLocationChange(oldLocation);
PathInteriorCacheState = MultiInteriorCacheState.Unknown;
}
public override void OnMapChange()
{
base.OnMapChange();
PathInteriorCacheState = MultiInteriorCacheState.Unknown;
}
public override int GetMaxUpdateRange() => 22;
public override int GetUpdateRange(Mobile m) => 22;

View file

@ -57,12 +57,25 @@ internal static class TestServerInitializer
Server.Network.NetState.Configure();
TestMapDefinitions.ConfigureTestMapDefinitions();
// 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 before anything that reads TileData (MultiData, MovementImpl,
// CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure()
// time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y].
ForceLoadTileData();
// Production runs every static Configure() via AssemblyHandler.Invoke("Configure");
// the fixture calls a curated subset, so configure the pathfinding singleton here so
// BitmapAStarAlgorithm.Instance carries its configured MaxSearchNodes before any test
// calls Find. ServerConfiguration is already loaded above, so the setting resolves.
BitmapAStarAlgorithm.Configure();
// Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via
// AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that
// BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of
// MultiComponentList.Empty. Required by the Multi pathfinding tests.
MultiData.Configure();
World.Configure();
Timer.Init(0);
RaceDefinitions.Configure();
@ -72,12 +85,6 @@ internal static class TestServerInitializer
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();
_initialized = true;

View file

@ -0,0 +1,87 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class BoatPathTests
{
private readonly ITestOutputHelper _output;
public BoatPathTests(ITestOutputHelper output) => _output = output;
// Open water in the south-Britain bay (Trammel), used by the existing swim-bake test.
private const int MapId = 1;
private const int WaterX = 1450;
private const int WaterY = 1770;
[Fact]
public void BoatDeck_HasWalkableSurfaceCells()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
// SmallBoat North heading = multiID 0x0. A deck is made of surface tiles.
var boat = new TestMulti(0x0);
map.GetAverageZ(WaterX, WaterY, out _, out var z, out _);
boat.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)z), map);
try
{
var floor = MultiArt.FindFloorCell(boat);
Assert.True(floor.HasValue, "non-vacuity: boat deck must have surface (floor) tiles");
// The deck cell falls through to the live multi-aware path.
var mask = StepCache.Instance.TryGetMask(map, floor.Value.X, floor.Value.Y, (sbyte)z);
Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind);
_output.WriteLine($"boat deck floor cell at ({floor.Value.X},{floor.Value.Y})");
}
finally
{
boat.Delete();
}
}
[Fact]
public void BoatDeck_FootprintShape_IsPositionInvariant()
{
// The property Phase 2's local-frame, movement-invariant boat cache must preserve:
// the deck's covered-cell shape (in local coords) is identical at two world positions.
var map = Map.Maps[MapId];
var a = new TestMulti(0x0);
map.GetAverageZ(WaterX, WaterY, out _, out var za, out _);
a.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)za), map);
var shapeA = LocalShape(a);
a.Delete();
var b = new TestMulti(0x0);
map.GetAverageZ(WaterX + 5, WaterY + 3, out _, out var zb, out _);
b.MoveToWorld(new Point3D(WaterX + 5, WaterY + 3, (sbyte)zb), map);
var shapeB = LocalShape(b);
b.Delete();
Assert.Equal(shapeA, shapeB);
Assert.NotEmpty(shapeA);
_output.WriteLine($"boat local deck shape stable across positions: {shapeA.Count} cells");
}
private static System.Collections.Generic.HashSet<(int lx, int ly)> LocalShape(BaseMulti multi)
{
var mcl = multi.Components;
var set = new System.Collections.Generic.HashSet<(int, int)>();
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
if (mcl.Tiles[lx][ly].Length > 0)
{
set.Add((lx, ly));
}
}
}
return set;
}
}

View file

@ -0,0 +1,87 @@
using Server;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class FoundationRedesignTests
{
private readonly ITestOutputHelper _output;
public FoundationRedesignTests(ITestOutputHelper output) => _output = output;
private const int MapId = 1;
private const int PlaceX = 1500;
private const int PlaceY = 1600;
[Fact]
public void SwappingComponents_ChangesFootprint()
{
var foundation = new SwappableFoundation(0x74); // GuildHouse footprint
try
{
var beforeCount = MultiArt.FootprintCells(foundation).Count;
Assert.True(beforeCount > 0, "non-vacuity: initial footprint must cover cells");
foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape)
var afterCount = MultiArt.FootprintCells(foundation).Count;
Assert.NotEqual(beforeCount, afterCount);
_output.WriteLine($"redesign footprint {beforeCount} -> {afterCount} cells");
}
finally
{
foundation.Delete();
}
}
[Fact]
public void RedesignReRegistered_RoutesNewFootprintToLivePath()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);
var foundation = new SwappableFoundation(0x74);
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var loc = new Point3D(PlaceX, PlaceY, (sbyte)z);
foundation.MoveToWorld(loc, map);
try
{
// Current design's covered cells route to the live (multi-aware) path.
var before = MultiArt.FindFloorCell(foundation);
Assert.True(before.HasValue, "non-vacuity: initial design must have a floor cell");
var maskBefore = StepCache.Instance.TryGetMask(map, before.Value.X, before.Value.Y, (sbyte)z);
Assert.Equal(CacheHitKind.Fallthrough_Multi, maskBefore.HitKind);
// Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit).
// Internalize() moves the multi to Map.Internal, which fires Map.OnLeave and removes
// the OLD footprint's sector registration. We then swap the MCL and MoveToWorld back,
// which fires Map.OnEnter -> AddMulti against the fresh Components (new footprint).
foundation.Internalize();
foundation.Redesign(MultiData.GetComponents(0x7A));
foundation.MoveToWorld(loc, map);
var after = MultiArt.FindFloorCell(foundation);
Assert.True(after.HasValue, "non-vacuity: redesigned design must have a floor cell");
// The new footprint cell must be registered (HasMultis) and route to the live path.
var sector = map.GetRealSector(after.Value.X >> 4, after.Value.Y >> 4);
Assert.True(sector.HasMultis, "redesigned footprint must re-register its sector");
var maskAfter = StepCache.Instance.TryGetMask(map, after.Value.X, after.Value.Y, (sbyte)z);
Assert.Equal(CacheHitKind.Fallthrough_Multi, maskAfter.HitKind);
_output.WriteLine($"redesign re-registered: before {before.Value.X},{before.Value.Y} after {after.Value.X},{after.Value.Y}");
}
finally
{
if (!foundation.Deleted)
{
foundation.Delete();
}
}
}
}

View file

@ -0,0 +1,225 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Server.Mobiles;
using Server.PathAlgorithms;
using Xunit;
using Xunit.Abstractions;
using CalcMoves = Server.Movement.Movement;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class HousePathRoutingTests
{
private readonly ITestOutputHelper _output;
public HousePathRoutingTests(ITestOutputHelper output) => _output = output;
private const int MapId = 1;
private sealed class WalkerStub : Mobile
{
public WalkerStub() => Body = 0xC9;
}
[Fact]
public void PathAround_NeverTraversesAWallCell()
{
StepCache.Instance.Clear();
var prevThreshold = StepCache.Instance.MissPromotionThreshold;
StepCache.Instance.MissPromotionThreshold = 1;
var map = Map.Maps[MapId];
Assert.NotNull(map);
// Open area with lateral room to flank a 7x7 house (verified reachable all 8 dirs).
const int hx = 1480, hy = 1620;
const int sx = 1480, sy = 1630;
const int gx = 1480, gy = 1610;
map.GetAverageZ(hx, hy, out _, out var hz, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
var wall = MultiArt.FindWallCell(multi);
Assert.True(wall.HasValue, "non-vacuity: house must have wall cells");
var walker = new WalkerStub();
map.GetAverageZ(sx, sy, out _, out var sz, out _);
var start = new Point3D(sx, sy, (sbyte)sz);
var goal = new Point3D(gx, gy, (sbyte)sz);
walker.MoveToWorld(start, map);
try
{
var path = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal);
Assert.NotNull(path); // non-vacuity: a route around must exist
Assert.NotEmpty(path);
// Walk the path; assert it never lands on the known wall cell.
var x = sx;
var y = sy;
foreach (var dir in path)
{
CalcMoves.Offset(dir, ref x, ref y);
Assert.False(x == wall.Value.X && y == wall.Value.Y,
$"path traversed wall cell ({wall.Value.X},{wall.Value.Y})");
}
_output.WriteLine($"around house: {path.Length} steps, avoided wall");
}
finally
{
StepCache.Instance.MissPromotionThreshold = prevThreshold;
walker.Delete();
multi.Delete();
}
}
[Fact]
public void Demolish_ReopensCoveredCells()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);
const int hx = 1480, hy = 1620;
map.GetAverageZ(hx, hy, out _, out var hz, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
// Find a wall cell that is blocked purely by the multi — not by underlying static/land
// terrain. FindWallCell returns the first MCL-impassable cell, which may land over
// terrain that is itself impassable. Instead scan until we find one where the base
// terrain is passable so that after demolish the cell provably reopens.
var w = FindPureMultiWallCell(multi, map);
Assert.True(w.HasValue, "non-vacuity: house must have a wall cell over passable terrain");
var walker = new WalkerStub();
var cell = w.Value;
try
{
// Before demolish: every neighbour fails to step onto the wall cell (it is blocked).
var blockedBefore = NeighbourBlockedInto(map, walker, cell);
Assert.True(blockedBefore, "wall cell should block entry while the house stands");
multi.Delete();
// After demolish: the cell reverts to open ground; entry from a neighbour should now
// succeed in at least one direction.
StepCache.Instance.Clear();
var openAfter = !NeighbourBlockedInto(map, walker, cell);
Assert.True(openAfter, $"cell ({cell.X},{cell.Y}) should reopen after demolish");
_output.WriteLine($"demolish reopened ({cell.X},{cell.Y})");
}
finally
{
if (!multi.Deleted)
{
multi.Delete();
}
walker.Delete();
}
}
/// <summary>
/// Find the first MCL wall cell (Impassable &amp;&amp; !Surface) over terrain that is not
/// independently blocked by land or statics. Using this instead of
/// <see cref="MultiArt.FindWallCell"/> ensures the cell reopens after demolish.
/// </summary>
private static MultiArt.Cell? FindPureMultiWallCell(BaseMulti multi, Map map)
{
var mcl = multi.Components;
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
var hasWall = false;
var hasSurface = false;
foreach (var t in mcl.Tiles[lx][ly])
{
var data = TileData.ItemTable[t.ID & TileData.MaxItemValue];
if (data.Impassable && !data.Surface)
{
hasWall = true;
}
if (data.Surface)
{
hasSurface = true;
}
}
if (!hasWall || hasSurface)
{
continue;
}
var wx = multi.X + mcl.Min.X + lx;
var wy = multi.Y + mcl.Min.Y + ly;
// Check the underlying terrain is not independently impassable.
if (IsTerrainBlocked(map, wx, wy))
{
continue;
}
return new MultiArt.Cell(wx, wy);
}
}
return null;
}
/// <summary>
/// Returns true if land tile or any static tile (non-multi) at (x,y) is impassable.
/// </summary>
private static bool IsTerrainBlocked(Map map, int x, int y)
{
var lt = map.Tiles.GetLandTile(x, y);
if (!lt.Ignored)
{
var landData = TileData.LandTable[lt.ID & TileData.MaxLandValue];
if ((landData.Flags & TileFlag.Impassable) != 0)
{
return true;
}
}
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
{
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
if (data.Impassable)
{
return true;
}
}
return false;
}
// True if EVERY in-range neighbour fails to step into target (target is blocked).
private static bool NeighbourBlockedInto(Map map, Mobile walker, MultiArt.Cell target)
{
for (var d = 0; d < 8; d++)
{
var nx = target.X;
var ny = target.Y;
CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny);
if (nx < 0 || ny < 0 || nx >= map.Width || ny >= map.Height)
{
continue;
}
map.GetAverageZ(nx, ny, out _, out var nz, out _);
walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map);
if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _))
{
var tx = nx;
var ty = ny;
CalcMoves.Offset((Direction)d, ref tx, ref ty);
if (tx == target.X && ty == target.Y)
{
return false; // an entry succeeded → not blocked
}
}
}
return true;
}
}

View file

@ -0,0 +1,50 @@
using Server.Engines.Pathing.Cache;
using Server.PathAlgorithms;
using Server.Systems.FeatureFlags;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiCacheUsedTests
{
private const int MapId = 1; // Trammel
private const int GuildHouseId = 0x74;
[Fact]
public void PathNearMulti_IncrementsMultiLocalHits()
{
var map = Map.Maps[MapId];
map.GetAverageZ(1480, 1620, out _, out var z, out _);
var houseLoc = new Point3D(1480, 1620, (sbyte)z);
var multi = new TestMulti(GuildHouseId);
var cacheWas = ContentFeatureFlags.BitmapPathfindingCache;
ContentFeatureFlags.BitmapPathfindingCache = true;
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(1480, 1630, (sbyte)z));
try
{
multi.MoveToWorld(houseLoc, map);
StepCache.Instance.Clear();
var before = StepCache.Instance.GetStats().MultiLocalHits;
// Start outside, goal on the far side, forcing expansion through the multi's halo.
var start = new Point3D(1480, 1630, (sbyte)z);
var goal = new Point3D(1480, 1610, (sbyte)z);
var path = BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal);
var after = StepCache.Instance.GetStats().MultiLocalHits;
Assert.NotNull(path);
Assert.True(after > before, $"expected MultiLocalHits to increase, before={before} after={after}");
}
finally
{
mover.Delete();
multi.Delete();
ContentFeatureFlags.BitmapPathfindingCache = cacheWas;
}
}
}

View file

@ -0,0 +1,158 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiEdgeCaseTests
{
private const int MapId = 1; // Trammel
private const int GuildHouseId = 0x74;
// GuildHouse placement (mirrors MultiMaskSynthesisTests) — open Trammel ground.
private const int HouseX = 1480;
private const int HouseY = 1620;
// Open water in the south-Britain bay (mirrors BoatPathTests).
private const int BoatMultiId = 0x0; // SmallBoat North heading
private const int WaterX = 1450;
private const int WaterY = 1770;
private const sbyte DeckZ = 0; // boat deck floor tiles stand at world Z 0 (not the water avgZ)
/// <summary>
/// Two overlapping GuildHouse multis whose footprints intersect. At the stacked cells
/// <c>GetStaticAndMultiTiles</c> yields tiles from BOTH multis; the synthesizer must still
/// agree with CheckMovement everywhere over the union footprint + halo.
/// </summary>
[Fact]
public void OverlappingMultis_SynthesizerMatchesCheckMovement()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(HouseX, HouseY, out _, out var z, out _);
var locA = new Point3D(HouseX, HouseY, (sbyte)z);
// Origins 3 tiles apart on X so the GuildHouse footprints overlap.
var locB = new Point3D(HouseX + 3, HouseY, (sbyte)z);
var multiA = new TestMulti(GuildHouseId);
var multiB = new TestMulti(GuildHouseId);
try
{
multiA.MoveToWorld(locA, map);
multiB.MoveToWorld(locB, map);
// Non-vacuity: prove the two footprints actually intersect at the chosen 3-tile
// separation. The per-sweep touchedMulti guard only proves each multi touched its OWN
// footprint; without this, "overlapping" would be an unverified comment.
var overlap = MultiArt.FootprintCells(multiA);
var setB = new System.Collections.Generic.HashSet<MultiArt.Cell>(MultiArt.FootprintCells(multiB));
overlap.RemoveAll(c => !setB.Contains(c));
Assert.NotEmpty(overlap); // the two footprints must actually intersect, else the test is meaningless
// The synthesizer must match the oracle over BOTH footprints (each sweep crosses
// the shared, doubly-covered cells).
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiA, map);
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiB, map);
}
finally
{
multiA.Delete();
multiB.Delete();
}
}
/// <summary>
/// A boat placed over open water: deck surface tiles are walkable, surrounding water blocks
/// the (non-swimming) walker. Exercises the synthesizer over water-adjacent deck-edge geometry.
/// </summary>
/// <remarks>
/// The boat is placed at Z=0 (the deck's world Z), NOT at the water average Z (-15 here).
/// The deck floor tiles stand at world Z 0, so a non-swimming walker only finds walkable
/// transitions when the sweep origin Z equals the deck Z. Placing at the water avgZ makes the
/// sweep vacuous ("no walkable transitions") because the deck is 15 tiles overhead and water
/// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the
/// synthesizer agrees with the oracle at every direction either way).
/// </remarks>
[Fact]
public void BoatOverWater_SynthesizerMatchesCheckMovement()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
var boat = new TestMulti(BoatMultiId);
boat.MoveToWorld(new Point3D(WaterX, WaterY, DeckZ), map);
try
{
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(boat, map);
}
finally
{
boat.Delete();
}
}
/// <summary>
/// Redesign a foundation in place (Internalize -> swap MCL -> MoveToWorld back, the same
/// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads
/// the LIVE, post-redesign <c>Components</c> — i.e. it matches CheckMovement on the NEW shape.
/// </summary>
[Fact]
public void RedesignedFoundation_SynthesizerMatchesNewFootprint()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
var foundation = new SwappableFoundation(GuildHouseId);
map.GetAverageZ(HouseX, HouseY, out _, out var z, out _);
var loc = new Point3D(HouseX, HouseY, (sbyte)z);
foundation.MoveToWorld(loc, map);
try
{
// Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit).
// Internalize() fires Map.OnLeave (removes the OLD footprint's registration); we swap
// the MCL and MoveToWorld back, firing Map.OnEnter -> AddMulti against the new shape.
foundation.Internalize();
foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape)
foundation.MoveToWorld(loc, map);
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(foundation, map);
}
finally
{
if (!foundation.Deleted)
{
foundation.Delete();
}
}
}
/// <summary>
/// Extensible slot for repo-owner-supplied gnarly placements. The assertion already covers
/// any (map,x,y) by construction — only the coordinates need filling in.
///
/// TODO(coords): repo owner to supply (map,x,y) for a static tree inside a footprint and a
/// dungeon cave-wall corner; add InlineData rows here — the assertion already covers them by
/// construction. (Left intentionally unhunted: do not invent tree/dungeon coords.)
/// </summary>
[Theory]
[InlineData(MapId, HouseX, HouseY)] // known-good open Trammel placement (passes today)
public void UserSuppliedScenarios_SynthesizerMatchesCheckMovement(int mapId, int x, int y)
{
StepCache.Instance.Clear();
var map = Map.Maps[mapId];
var multi = new TestMulti(GuildHouseId);
map.GetAverageZ(x, y, out _, out var z, out _);
multi.MoveToWorld(new Point3D(x, y, (sbyte)z), map);
try
{
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map);
}
finally
{
multi.Delete();
}
}
}

View file

@ -0,0 +1,273 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiMaskCacheTests
{
private const int MapId = 1;
private const int GuildHouseId = 0x74;
private const int PlaceX = 1480;
private const int PlaceY = 1620;
[Fact]
public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var loc = new Point3D(PlaceX, PlaceY, (sbyte)z);
var multi = new TestMulti(GuildHouseId);
try
{
multi.MoveToWorld(loc, map);
Assert.True(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX, PlaceY, out var found, out var lx, out var ly));
Assert.Same(multi, found);
var mcl = multi.Components;
Assert.Equal(PlaceX - multi.X - mcl.Min.X, lx);
Assert.Equal(PlaceY - multi.Y - mcl.Min.Y, ly);
Assert.False(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX + 200, PlaceY + 200, out _, out _, out _));
}
finally
{
multi.Delete();
}
}
[Fact]
public void IsInteriorLocalCell_TrueDeepInside_FalseAtEdge()
{
var multi = new TestMulti(GuildHouseId);
try
{
var mcl = multi.Components;
var foundInterior = false;
var foundEdge = false;
for (var ly = 0; ly < mcl.Height && (!foundInterior || !foundEdge); ly++)
{
for (var lx = 0; lx < mcl.Width; lx++)
{
if (mcl.Tiles[lx][ly].Length == 0)
{
continue;
}
if (MultiMaskCache.IsInteriorLocalCell(mcl, lx, ly))
{
foundInterior = true;
}
else
{
foundEdge = true;
}
}
}
Assert.True(foundInterior, "a guild house must have at least one interior cell");
Assert.True(foundEdge, "a guild house must have at least one edge/perimeter cell");
}
finally
{
multi.Delete();
}
}
[Fact]
public void LocalWorldZ_RoundTrips_AndPreservesMasks()
{
var world = new StepMask(
walkMask: 0b1010_1010, wetMask: 0b0101_0101,
walkZN: 10, walkZNE: 11, walkZE: 12, walkZSE: 13, walkZS: 14, walkZSW: 15, walkZW: 16, walkZNW: 17,
swimZN: -1, swimZNE: -2, swimZE: -3, swimZSE: -4, swimZS: -5, swimZSW: -6, swimZW: -7, swimZNW: -8
);
const int multiZ = 7;
Assert.True(MultiMaskCache.TryToLocalZ(world, multiZ, out var local));
var back = MultiMaskCache.ToWorldZ(local, multiZ);
Assert.Equal(world.WalkMask, back.WalkMask);
Assert.Equal(world.WetMask, back.WetMask);
for (var d = 0; d < 8; d++)
{
Assert.Equal(world.GetWalkZ((Direction)d), back.GetWalkZ((Direction)d));
Assert.Equal(world.GetSwimZ((Direction)d), back.GetSwimZ((Direction)d));
}
}
[Fact]
public void TryToLocalZ_RejectsOverflow()
{
var world = new StepMask(
walkMask: 0xFF, wetMask: 0,
walkZN: 100, walkZNE: 0, walkZE: 0, walkZSE: 0, walkZS: 0, walkZSW: 0, walkZW: 0, walkZNW: 0,
swimZN: 0, swimZNE: 0, swimZE: 0, swimZSE: 0, swimZS: 0, swimZSW: 0, swimZW: 0, swimZNW: 0
);
Assert.False(MultiMaskCache.TryToLocalZ(world, -100, out _));
}
[Fact]
public void TerrainTopBelow_TrueWhenFloorWellAboveGround()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var ground, out _);
// Floor far above ground → terrain is below → guard passes.
Assert.True(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground + 50)));
// Floor at/below ground → terrain reaches the envelope → guard fails.
Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50)));
}
[Fact]
public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits()
{
// (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it
// would never serve the interior cache under the footprint-clean gate. Use a known flat/clear
// spot so a house there is footprint-clean and its interior cells serve from the cache.
const int CleanX = 1560;
const int CleanY = 1616;
var map = Map.Maps[MapId];
map.GetAverageZ(CleanX, CleanY, out _, out var z, out _);
var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z);
var multi = new TestMulti(GuildHouseId);
var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache;
Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true;
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z));
try
{
multi.MoveToWorld(houseLoc, map);
Assert.True(MultiMaskCache.ComputeFootprintClean(map, multi),
"precondition: (1560,1616) must be footprint-clean for the cache to serve");
StepCache.Instance.Clear();
var start = new Point3D(CleanX, CleanY + 10, (sbyte)z);
var goal = new Point3D(CleanX, CleanY - 10, (sbyte)z);
// First pass builds the interior cache (live synth); second pass should hit it.
Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal);
var before = StepCache.Instance.GetStats().MultiMaskCacheHits;
Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal);
var after = StepCache.Instance.GetStats().MultiMaskCacheHits;
Assert.True(after > before, $"expected MultiMaskCacheHits to increase, before={before} after={after}");
}
finally
{
mover.Delete();
multi.Delete();
Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas;
}
}
[Fact]
public void ClutteredHouse_DoesNotServeCache_ButStillPaths()
{
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var multi = new TestMulti(GuildHouseId);
var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache;
Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true;
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z));
try
{
// (1480,1620) overlaps tall map statics → footprint dirty → cache must NOT serve.
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
Assert.False(MultiMaskCache.ComputeFootprintClean(map, multi),
"precondition: (1480,1620) must be footprint-dirty for this test to be meaningful");
StepCache.Instance.Clear();
var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z);
var goal = new Point3D(PlaceX, PlaceY - 10, (sbyte)z);
Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); // warm
var before = StepCache.Instance.GetStats().MultiMaskCacheHits;
var path = Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal);
var after = StepCache.Instance.GetStats().MultiMaskCacheHits;
Assert.NotNull(path); // pathfinding still works (degraded to live-synth)
Assert.Equal(before, after); // dirty footprint → zero cache serves
}
finally
{
mover.Delete();
multi.Delete();
Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas;
}
}
[Fact]
public void PathInteriorCacheState_ResetsOnMove()
{
var map = Map.Maps[MapId];
var multi = new TestMulti(GuildHouseId);
try
{
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map);
multi.PathInteriorCacheState = MultiInteriorCacheState.Clean;
// A move changes the footprint's world terrain, so the gate must reset to Unknown and
// recompute on next use. (BaseMulti.OnLocationChange does this; subclasses like BaseHouse
// and BaseBoat must call base for it to fire — this pins that contract.)
multi.MoveToWorld(new Point3D(PlaceX + 8, PlaceY + 8, 0), map);
Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState);
}
finally
{
multi.Delete();
}
}
[Fact]
public void PathInteriorCacheState_ResetsOnItemIdChange()
{
var map = Map.Maps[MapId];
var multi = new TestMulti(GuildHouseId);
try
{
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map);
multi.PathInteriorCacheState = MultiInteriorCacheState.Clean;
// Changing ItemID swaps the footprint (e.g. a boat changing heading), so the gate must
// reset to recompute cleanliness for the new shape.
multi.ItemID = 0x7A; // Tower
Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState);
}
finally
{
multi.Delete();
}
}
[Fact]
public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk()
{
// (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild
// house there is never footprint-clean. Use a known flat/clear spot for the clean assertion.
const int CleanX = 1560;
const int CleanY = 1616;
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(CleanX, CleanY, out _, out var ground, out _);
var normal = new TestMulti(GuildHouseId);
var sunk = new TestMulti(GuildHouseId);
try
{
normal.MoveToWorld(new Point3D(CleanX, CleanY, (sbyte)ground), map);
Assert.True(MultiMaskCache.ComputeFootprintClean(map, normal));
sunk.MoveToWorld(new Point3D(CleanX + 60, CleanY, (sbyte)(ground - 40)), map);
Assert.False(MultiMaskCache.ComputeFootprintClean(map, sunk));
}
finally
{
normal.Delete();
sunk.Delete();
}
}
}

View file

@ -0,0 +1,33 @@
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiMaskSynthesisTests
{
private const int MapId = 1; // Trammel
private const int GuildHouseId = 0x74; // static house: walls, door aperture, floor
private const int PlaceX = 1480;
private const int PlaceY = 1620;
[Fact]
public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var loc = new Point3D(PlaceX, PlaceY, (sbyte)z);
var multi = new TestMulti(GuildHouseId);
try
{
multi.MoveToWorld(loc, map);
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map);
}
finally
{
multi.Delete();
}
}
}

View file

@ -0,0 +1,158 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Server.Mobiles;
using Server.PathAlgorithms;
using Server.Systems.FeatureFlags;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiPathInvariantTests
{
private readonly ITestOutputHelper _output;
public MultiPathInvariantTests(ITestOutputHelper output) => _output = output;
private const int MapId = 1;
private sealed class WalkerStub : Mobile
{
public WalkerStub() => Body = 0xC9;
}
private static Direction[] FindWithFlag(Mobile m, Map map, Point3D start, Point3D goal, bool cacheOn)
{
var prev = ContentFeatureFlags.BitmapPathfindingCache;
try
{
ContentFeatureFlags.BitmapPathfindingCache = cacheOn;
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 1;
return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal);
}
finally
{
ContentFeatureFlags.BitmapPathfindingCache = prev;
}
}
[Theory]
// start, goal: straddle a house placed between them (house at ~ midpoint).
[InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint
[InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint
public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy)
{
var map = Map.Maps[MapId];
Assert.NotNull(map);
// Place the house at the midpoint so it sits between start and goal.
var hx = (sx + gx) / 2;
var hy = (sy + gy) / 2;
map.GetAverageZ(hx, hy, out _, out var hz, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
var walker = new WalkerStub();
map.GetAverageZ(sx, sy, out _, out var sz, out _);
var start = new Point3D(sx, sy, (sbyte)sz);
var goal = new Point3D(gx, gy, (sbyte)sz);
walker.MoveToWorld(start, map);
try
{
var on = FindWithFlag(walker, map, start, goal, cacheOn: true);
var off = FindWithFlag(walker, map, start, goal, cacheOn: false);
// Both-null or both-equal arrays. Equality of the direction sequence is the invariant.
Assert.Equal(off == null, on == null);
if (on != null)
{
Assert.Equal(off, on);
_output.WriteLine($"({sx},{sy})->({gx},{gy}) house: {on.Length} steps, cache==slow");
}
else
{
_output.WriteLine($"({sx},{sy})->({gx},{gy}) house: no path (both)");
}
}
finally
{
walker.Delete();
multi.Delete();
}
}
[Theory]
[InlineData(1500, 1600, 1498, 1598)]
[InlineData(1500, 1600, 1497, 1599)]
public void Find_CacheOn_EqualsCacheOff_NoMultiControl(int sx, int sy, int gx, int gy)
{
var map = Map.Maps[MapId];
var walker = new WalkerStub();
map.GetAverageZ(sx, sy, out _, out var sz, out _);
var start = new Point3D(sx, sy, (sbyte)sz);
var goal = new Point3D(gx, gy, (sbyte)sz);
walker.MoveToWorld(start, map);
try
{
var on = FindWithFlag(walker, map, start, goal, cacheOn: true);
var off = FindWithFlag(walker, map, start, goal, cacheOn: false);
Assert.Equal(off == null, on == null);
if (on != null)
{
Assert.Equal(off, on);
}
}
finally
{
walker.Delete();
}
}
[Fact]
public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse()
{
var map = Map.Maps[MapId];
Assert.NotNull(map);
// Open area at (1480,1620,z=20): paths exist in all 8 directions (probe confirmed).
// N-S route: start=(1480,1630) goal=(1480,1610). House at (1480,1620) on direct line.
// Detour exists E (~1488+) and W (~1472-). Both cache-on and cache-off must agree.
const int hx = 1480, hy = 1620;
const int sx = 1480, sy = 1630;
const int gx = 1480, gy = 1610;
map.GetAverageZ(hx, hy, out _, out var hz, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
var walker = new WalkerStub();
map.GetAverageZ(sx, sy, out _, out var sz, out _);
map.GetAverageZ(gx, gy, out _, out var gz, out _);
var start = new Point3D(sx, sy, (sbyte)sz);
var goal = new Point3D(gx, gy, (sbyte)gz);
walker.MoveToWorld(start, map);
try
{
var on = FindWithFlag(walker, map, start, goal, cacheOn: true);
var off = FindWithFlag(walker, map, start, goal, cacheOn: false);
// Non-vacuity: a real around-the-house route must exist on BOTH sides.
Assert.NotNull(off);
Assert.NotNull(on);
// The invariant: cache and slow path agree on that route.
Assert.Equal(off, on);
_output.WriteLine($"around-house: {on.Length} steps, cache==slow");
}
finally
{
walker.Delete();
multi.Delete();
}
}
}

View file

@ -0,0 +1,90 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiSplitRoutingTests
{
private readonly ITestOutputHelper _output;
public MultiSplitRoutingTests(ITestOutputHelper output) => _output = output;
// Open plain on Trammel used by existing pathfinding tests; room for a 7x7 house.
private const int MapId = 1;
private const int PlaceX = 1500;
private const int PlaceY = 1600;
[Fact]
public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var multi = new TestMulti(0x74); // GuildHouse footprint
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
try
{
var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4);
Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis");
var cells = MultiArt.FootprintWithHalo(multi);
var checkedCovered = 0;
foreach (var c in cells)
{
if (c.X < 0 || c.Y < 0 || c.X >= map.Width || c.Y >= map.Height)
{
continue;
}
var mask = StepCache.Instance.TryGetMask(map, c.X, c.Y, (sbyte)z);
Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind);
checkedCovered++;
}
Assert.True(checkedCovered > 0, "non-vacuity: expected at least one covered cell");
_output.WriteLine($"covered/halo cells routed to fallthrough: {checkedCovered}");
}
finally
{
multi.Delete();
}
}
[Fact]
public void CleanMap_AfterMultiRemoved_ServesStaticHitAgain()
{
StepCache.Instance.Clear();
var prevThreshold = StepCache.Instance.MissPromotionThreshold;
StepCache.Instance.MissPromotionThreshold = 1; // build on first touch
var map = Map.Maps[MapId];
Assert.NotNull(map);
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4);
Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis");
try
{
multi.Delete();
Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis");
// A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be
// eligible for a real static answer. Use the placement center, which is open plain.
var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z);
Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}");
_output.WriteLine($"post-removal hitKind at center: {mask.HitKind}");
}
finally
{
StepCache.Instance.MissPromotionThreshold = prevThreshold;
}
}
}

View file

@ -0,0 +1,242 @@
using System.Collections.Generic;
using Server;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
using CalcMoves = Server.Movement.Movement;
namespace Server.Tests.Pathfinding;
/// <summary>
/// Minimal concrete <see cref="BaseMulti"/> for tests. Walkability depends only on
/// Components (the shared MCL for the multiID) + Location, so this is a faithful stand-in
/// for any fixed-design multi (classic house, camp, boat heading) without the owning
/// house/boat machinery. Never serialized in tests.
/// </summary>
public sealed class TestMulti : BaseMulti
{
public TestMulti(int itemID) : base(itemID)
{
}
}
/// <summary>Default walker body, shared across pathfinding test fixtures.</summary>
public sealed class WalkerStub : Mobile
{
public WalkerStub() => Body = 0xC9;
}
/// <summary>
/// Stand-in for a customizable foundation: Components is a swappable MCL, exactly the
/// runtime-mutation shape HouseFoundation uses (it replaces its MCL wholesale on redesign
/// commit). Lets the test change the footprint and assert the engine reflects it without
/// driving full house placement/customization.
/// </summary>
public sealed class SwappableFoundation : BaseMulti
{
private MultiComponentList _mcl;
public SwappableFoundation(int baseMultiID) : base(baseMultiID) =>
_mcl = MultiData.GetComponents(baseMultiID);
public override MultiComponentList Components => _mcl;
public void Redesign(MultiComponentList replacement) => _mcl = replacement;
}
/// <summary>
/// Shared helpers for placing/probing multis in pathfinding tests.
/// </summary>
public static class MultiTestSupport
{
// A default-walker oracle mobile (CanSwim=false, CantWalk=false) placed in-world so MovementImpl
// state reads are valid. Caller MUST Delete() it (do it in a finally).
public static Mobile GetWalkerOracle(Map map, Point3D loc)
{
var w = new WalkerStub();
w.MoveToWorld(loc, map);
return w;
}
public static bool HasMultiTileAt(BaseMulti multi, int wx, int wy)
{
var mcl = multi.Components;
var lx = wx - multi.X + mcl.Center.X;
var ly = wy - multi.Y + mcl.Center.Y;
if (lx < 0 || ly < 0 || lx >= mcl.Width || ly >= mcl.Height)
{
return false;
}
return mcl.Tiles[lx][ly].Length > 0;
}
/// <summary>
/// Sweeps the multi's footprint + 1-cell halo and asserts the multi mask synthesizer
/// (<see cref="StepProbe.ComputeMultiMaskAt"/>) agrees with the <c>CheckMovement</c> oracle
/// for all 8 directions at every cell, including the exact forward walk-Z on allowed moves.
/// Creates its own walker oracle internally and Delete()s it; the caller owns the multi.
/// </summary>
public static void AssertSynthesizerMatchesCheckMovement(BaseMulti multi, Map map)
{
var loc = new Point3D(multi.X, multi.Y, multi.Z);
var mover = GetWalkerOracle(map, loc);
try
{
var cells = MultiArt.FootprintWithHalo(multi);
Assert.NotEmpty(cells);
var touchedMulti = 0;
var sawWalkable = 0;
var sawBlocked = 0;
foreach (var c in cells)
{
var sourceZ = (sbyte)loc.Z;
var p = new Point3D(c.X, c.Y, sourceZ);
if (HasMultiTileAt(multi, c.X, c.Y))
{
touchedMulti++;
}
var mask = StepProbe.ComputeMultiMaskAt(map, c.X, c.Y, sourceZ);
for (var d = 0; d < 8; d++)
{
var dir = (Direction)d;
var expectWalk = CalcMoves.CheckMovement(mover, map, p, dir, out var expectZ);
// The synthesizer reports the raw forward-cell step per direction and does NOT
// apply diagonal corner-cutting — by design, the caller ANDs the partner cells.
// CheckMovement (the oracle) DOES corner-cut. Replicate the caller's corner-cut
// on the mask so we compare like-for-like. The walker is not a player, so the
// diagonal is blocked only when BOTH orthogonal partner cells are blocked.
var forwardWalk = (mask.WalkMask & (1 << d)) != 0;
var gotWalk = forwardWalk;
var isDiagonal = (d & 0x1) == 0x1;
if (forwardWalk && isDiagonal)
{
var leftBit = (d - 1) & 0x7;
var rightBit = (d + 1) & 0x7;
var leftWalk = (mask.WalkMask & (1 << leftBit)) != 0;
var rightWalk = (mask.WalkMask & (1 << rightBit)) != 0;
if (!leftWalk && !rightWalk)
{
gotWalk = false;
}
}
Assert.Equal(expectWalk, gotWalk);
if (expectWalk)
{
// Z is taken from the forward cell only; corner-cut never alters newZ when
// the move is allowed.
Assert.Equal((sbyte)expectZ, mask.GetWalkZ(dir));
sawWalkable++;
}
else
{
sawBlocked++;
}
}
}
Assert.True(touchedMulti > 0, "sweep touched no multi-covered cells");
Assert.True(sawWalkable > 0, "sweep observed no walkable transitions");
Assert.True(sawBlocked > 0, "sweep observed no blocked transitions");
}
finally
{
mover.Delete();
}
}
}
/// <summary>
/// Helpers that derive expected geometry from a multi's MCL art at runtime, so tests
/// encode no hardcoded cell coordinates and survive art-data changes.
/// </summary>
public static class MultiArt
{
public readonly record struct Cell(int X, int Y);
/// <summary>Every world cell the multi's footprint covers (Tiles stack non-empty).</summary>
public static List<Cell> FootprintCells(BaseMulti multi)
{
var mcl = multi.Components;
var result = new List<Cell>();
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
if (mcl.Tiles[lx][ly].Length == 0)
{
continue;
}
result.Add(new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly));
}
}
return result;
}
/// <summary>Footprint cells plus a 1-cell halo ring (the cells the split also routes to slow path).</summary>
public static HashSet<Cell> FootprintWithHalo(BaseMulti multi)
{
var foot = FootprintCells(multi);
var set = new HashSet<Cell>();
foreach (var c in foot)
{
for (var dx = -1; dx <= 1; dx++)
{
for (var dy = -1; dy <= 1; dy++)
{
set.Add(new Cell(c.X + dx, c.Y + dy));
}
}
}
return set;
}
/// <summary>First world cell whose MCL stack contains an impassable, non-surface (wall) tile, or null.</summary>
public static Cell? FindWallCell(BaseMulti multi)
{
var mcl = multi.Components;
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
foreach (var t in mcl.Tiles[lx][ly])
{
var data = TileData.ItemTable[t.ID & TileData.MaxItemValue];
if (data.Impassable && !data.Surface)
{
return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly);
}
}
}
}
return null;
}
/// <summary>First world cell whose MCL stack contains a walkable surface (floor) tile, or null.</summary>
public static Cell? FindFloorCell(BaseMulti multi)
{
var mcl = multi.Components;
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
foreach (var t in mcl.Tiles[lx][ly])
{
var data = TileData.ItemTable[t.ID & TileData.MaxItemValue];
if (data.Surface && !data.Impassable)
{
return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly);
}
}
}
}
return null;
}
}

View file

@ -0,0 +1,137 @@
using Server.Engines.Pathing.Cache;
using Server.Items;
using Server.Mobiles;
using Xunit;
using Xunit.Abstractions;
using CalcMoves = Server.Movement.Movement;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class MultiWalkabilityTests
{
private readonly ITestOutputHelper _output;
public MultiWalkabilityTests(ITestOutputHelper output) => _output = output;
private const int MapId = 1;
private const int PlaceX = 1500;
private const int PlaceY = 1600;
private sealed class WalkerStub : Mobile
{
public WalkerStub() => Body = 0xC9;
}
[Fact]
public void WallCell_CannotBeEnteredFromAnyAdjacentCell()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
var walker = new WalkerStub();
walker.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
try
{
var wall = MultiArt.FindWallCell(multi);
Assert.True(wall.HasValue, "non-vacuity: house MCL must contain a wall tile");
var w = wall.Value;
// From each of the 8 cells surrounding the wall, try stepping in all 8 directions.
// No step may land ON the wall cell. We also require that SOME step succeeds, so a
// "zero wall entries" result reflects the wall blocking — not the walker being
// unable to move here at all (e.g. a Z mismatch blocking everything: a vacuous pass).
var entriesIntoWall = 0;
var successfulSteps = 0;
for (var around = 0; around < 8; around++)
{
var cx = w.X;
var cy = w.Y;
CalcMoves.Offset((Direction)around, ref cx, ref cy);
map.GetAverageZ(cx, cy, out _, out var cz, out _);
var from = new Point3D(cx, cy, (sbyte)cz);
for (var d = 0; d < 8; d++)
{
if (!CalcMoves.CheckMovement(walker, map, from, (Direction)d, out _))
{
continue;
}
successfulSteps++;
var tx = cx;
var ty = cy;
CalcMoves.Offset((Direction)d, ref tx, ref ty);
if (tx == w.X && ty == w.Y)
{
entriesIntoWall++;
_output.WriteLine($"UNEXPECTED entry into wall ({w.X},{w.Y}) from ({cx},{cy}) dir {d}");
}
}
}
Assert.True(successfulSteps > 0, "non-vacuity: walker must be able to move near the wall");
Assert.Equal(0, entriesIntoWall);
}
finally
{
walker.Delete();
multi.Delete();
}
}
[Fact]
public void FloorCell_IsStandable()
{
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
var multi = new TestMulti(0x74);
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
var walker = new WalkerStub();
try
{
var floor = MultiArt.FindFloorCell(multi);
Assert.True(floor.HasValue, "non-vacuity: house MCL must contain a floor tile");
var f = floor.Value;
// Stand the walker on a cardinal neighbour of the floor cell and require at least
// one direction that successfully steps onto the floor cell.
var enteredFloor = false;
for (var d = 0; d < 8 && !enteredFloor; d++)
{
var nx = f.X;
var ny = f.Y;
CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny);
map.GetAverageZ(nx, ny, out _, out var nz, out _);
walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map);
if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _))
{
var tx = nx;
var ty = ny;
CalcMoves.Offset((Direction)d, ref tx, ref ty);
if (tx == f.X && ty == f.Y)
{
enteredFloor = true;
}
}
}
Assert.True(enteredFloor, $"expected the floor cell ({f.X},{f.Y}) to be reachable from a neighbour");
}
finally
{
walker.Delete();
multi.Delete();
}
}
}

View file

@ -353,7 +353,22 @@ public class BitmapAStarAlgorithm : PathAlgorithm
if (!lookup.IsHit)
{
return GetSuccessorsSlowPath(m, map, px, py, p3D, vals);
// Multi-covered cells: synthesize a multi-aware mask in ONE pass (over land + statics +
// house/boat component tiles) instead of the slow path's 8x per-cell CheckMovement.
// Fliers and cache-off already returned at the top of GetSuccessors, so this only runs
// for cacheable walkers/swimmers. The synthesized mask flows through the SAME
// capability-overlay + diagonal corner-cut + dynamic-obstacle loop below as a static hit.
if (lookup.HitKind == CacheHitKind.Fallthrough_Multi)
{
// Multi-covered cell: the per-multiID interior cache serves a ~20 ns lookup for
// interior cells (and records the right counter); it falls back internally to the
// Phase-2 live synthesizer for perimeter / terrain-dirty / foundation cells.
lookup = MultiMaskCache.Instance.GetMask(map, p3D.X, p3D.Y, (sbyte)p3D.Z);
}
else
{
return GetSuccessorsSlowPath(m, map, px, py, p3D, vals);
}
}
// Capability overlay: walking allowed unless cantWalk; swimming allowed if canSwim.

View file

@ -14,6 +14,8 @@ public readonly struct CacheStats(
long fallthroughSourceZMismatch,
long fallthroughNotBuilt,
long fallthroughMulti,
long multiLocalHits,
long multiMaskCacheHits,
long evictionsByLruCap,
long buildsTotal
)
@ -27,6 +29,8 @@ public readonly struct CacheStats(
public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch;
public readonly long FallthroughNotBuilt = fallthroughNotBuilt;
public readonly long FallthroughMulti = fallthroughMulti;
public readonly long MultiLocalHits = multiLocalHits;
public readonly long MultiMaskCacheHits = multiMaskCacheHits;
public readonly long EvictionsByLruCap = evictionsByLruCap;
public readonly long BuildsTotal = buildsTotal;
}

View file

@ -0,0 +1,346 @@
using System;
using System.Collections.Generic;
using Server.Items;
using Server.Multis;
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Warm, in-memory cache of per-multiID local-frame walkability masks for INTERIOR multi cells
/// (cell + all 8 neighbours covered by the multi → terrain-neighbour-free → position-invariant).
/// Wraps the Phase-2 synthesizer (StepProbe.ComputeMultiMaskAt). Cleanliness is decided ONCE per
/// instance (BaseMulti.PathInteriorCacheState, via ComputeFootprintClean): a clean instance — whole
/// footprint terrain below the floor — serves interior cells from the shared per-multiID cache;
/// dirty instances, boats (movers), and HouseFoundation (runtime-mutable) fall back to live-synth.
/// Keyed by multiID &amp; 0x3FFF.
/// </summary>
public sealed class MultiMaskCache
{
public static MultiMaskCache Instance { get; } = new();
private const int StepHeight = 2;
private readonly Dictionary<int, MultiLocalMask> _byMultiId = [];
public void Clear() => _byMultiId.Clear();
/// <summary>
/// Returns the multi-aware StepMask for a covered cell (x,y,sourceZ). Serves a cached interior
/// mask when available and the guards pass (counted as a MultiMaskCacheHit); otherwise falls
/// back to the Phase-2 live synthesizer ComputeMultiMaskAt (counted as a MultiLocalHit), caching
/// the result if the cell is interior and clean. Always returns a usable mask (HitKind == Hit).
/// </summary>
public StepMask GetMask(Map map, int x, int y, sbyte sourceZ)
{
if (!TryResolveCoveringMulti(map, x, y, out var multi, out var lx, out var ly)
|| multi is HouseFoundation) // runtime-mutable per-instance DesignState MCL
{
return LiveSynth(map, x, y, sourceZ);
}
// Boats are cached too: their per-multiID deck masks are movement-invariant (built once per
// heading), and the per-instance clean gate below + the ItemID/location/map resets keep a
// moving/turning boat correct. Narrow boats have little interior; wide galleons gain a lot.
// Per-instance footprint cleanliness (computed once, stored on the multi; reset on move).
// Clean ⇒ no terrain intrusion anywhere in the footprint ⇒ interior cells are exact from the
// shared per-multiID cache. Dirty ⇒ degrade to the live synthesizer (never serve a wrong mask).
if (multi.PathInteriorCacheState == MultiInteriorCacheState.Unknown)
{
multi.PathInteriorCacheState =
ComputeFootprintClean(map, multi) ? MultiInteriorCacheState.Clean : MultiInteriorCacheState.Dirty;
}
if (multi.PathInteriorCacheState != MultiInteriorCacheState.Clean)
{
return LiveSynth(map, x, y, sourceZ);
}
var mcl = multi.Components;
var local = GetOrCreate(multi.ItemID & 0x3FFF, mcl.Width, mcl.Height);
var state = local.GetState(lx, ly);
if (state == MultiLocalMask.CellState.Cached)
{
// Footprint is clean, so only the source-Z match matters (terrain can't intrude).
var worldFloorZ = local.FloorZAt(lx, ly) + multi.Z;
if (Math.Abs(sourceZ - worldFloorZ) <= StepHeight)
{
StepCache.Instance.RecordMultiMaskCacheHit();
return ToWorldZ(local.MaskAt(lx, ly), multi.Z);
}
return LiveSynth(map, x, y, sourceZ);
}
if (state == MultiLocalMask.CellState.NonInterior)
{
return LiveSynth(map, x, y, sourceZ);
}
// Unknown → classify + (if interior) build & cache. No per-cell terrain guard needed: the
// instance is clean, so every interior cell's 3x3 terrain is below the floor.
var mask = LiveSynth(map, x, y, sourceZ);
if (IsInteriorLocalCell(mcl, lx, ly)
&& TryToLocalZ(mask, multi.Z, out var localMask)
&& sourceZ - multi.Z is >= sbyte.MinValue and <= sbyte.MaxValue)
{
local.SetCached(lx, ly, localMask, (sbyte)(sourceZ - multi.Z));
}
else
{
local.SetNonInterior(lx, ly);
}
return mask;
}
private static StepMask LiveSynth(Map map, int x, int y, sbyte sourceZ)
{
StepCache.Instance.RecordMultiLocalHit();
return StepProbe.ComputeMultiMaskAt(map, x, y, sourceZ);
}
private MultiLocalMask GetOrCreate(int key, int width, int height)
{
if (!_byMultiId.TryGetValue(key, out var m))
{
m = new MultiLocalMask(width, height);
_byMultiId[key] = m;
}
return m;
}
/// <summary>
/// Finds the multi covering (x,y) and the local cell indices into its MCL. Mirrors
/// Map.StaticTileEnumerator / BaseMulti.Contains. Returns false if no multi covers the cell.
/// </summary>
public static bool TryResolveCoveringMulti(Map map, int x, int y, out BaseMulti multi, out int lx, out int ly)
{
foreach (var candidate in map.GetMultisInSector(x, y))
{
var mcl = candidate.Components;
var cx = x - candidate.X - mcl.Min.X;
var cy = y - candidate.Y - mcl.Min.Y;
if (cx >= 0 && cy >= 0 && cx < mcl.Width && cy < mcl.Height && mcl.Tiles[cx][cy].Length > 0)
{
multi = candidate;
lx = cx;
ly = cy;
return true;
}
}
multi = null;
lx = ly = 0;
return false;
}
/// <summary>
/// True iff local cell (lx,ly) and all 8 neighbours are covered by the multi (have MCL tiles).
/// Such a cell's 8-direction transition is fully determined by the multi (no terrain neighbour),
/// so its mask is position-invariant. A pure function of the MCL.
/// </summary>
public static bool IsInteriorLocalCell(MultiComponentList mcl, int lx, int ly)
{
for (var dy = -1; dy <= 1; dy++)
{
for (var dx = -1; dx <= 1; dx++)
{
var nx = lx + dx;
var ny = ly + dy;
if (nx < 0 || ny < 0 || nx >= mcl.Width || ny >= mcl.Height || mcl.Tiles[nx][ny].Length == 0)
{
return false;
}
}
}
return true;
}
/// <summary>
/// Converts a world-frame mask's per-direction Zs to local Z (subtract multiZ). Returns false
/// if any local Z doesn't fit sbyte (caller must then NOT cache the cell — rare; only when
/// |multiZ| is large enough to push a world Z out of range). Mask (walk/wet) bits are copied.
/// </summary>
public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local)
{
local = default;
Span<sbyte> w = stackalloc sbyte[8];
Span<sbyte> s = stackalloc sbyte[8];
for (var d = 0; d < 8; d++)
{
var lw = world.GetWalkZ((Direction)d) - multiZ;
var ls = world.GetSwimZ((Direction)d) - multiZ;
if (lw < sbyte.MinValue || lw > sbyte.MaxValue || ls < sbyte.MinValue || ls > sbyte.MaxValue)
{
return false;
}
w[d] = (sbyte)lw;
s[d] = (sbyte)ls;
}
local = new StepMask(
world.WalkMask, world.WetMask,
w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7],
s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]
);
return true;
}
/// <summary>
/// True iff all terrain (land + statics) at (x,y) sits strictly below <paramref name="floorZ"/>,
/// so a creature standing on the multi floor never sees terrain in its envelope and the cached
/// (terrain-free) mask is exact. Cheap: one land-top read + the cell's static-tile array scan.
/// </summary>
public static bool TerrainTopBelow(Map map, int x, int y, sbyte floorZ)
{
map.GetAverageZ(x, y, out _, out _, out var landTop);
if (landTop >= floorZ)
{
return false;
}
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
{
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var top = tile.Z + data.CalcHeight;
if (top >= floorZ)
{
return false;
}
}
return true;
}
/// <summary>Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check.</summary>
public static int TerrainTop(Map map, int x, int y)
{
map.GetAverageZ(x, y, out _, out _, out var top);
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
{
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var t = tile.Z + data.CalcHeight;
if (t > top)
{
top = t;
}
}
return top;
}
/// <summary>
/// True iff the multi's WHOLE footprint terrain sits below its lowest standable floor — i.e.
/// maxTerrain &lt; minFloor over all covered cells. When true, no covered cell's terrain (nor any
/// neighbour's) can intrude into a creature's floor envelope, so interior cells of this design are
/// safe to serve from the shared per-multiID cache for THIS instance. One-time per instance.
/// </summary>
public static bool ComputeFootprintClean(Map map, BaseMulti multi)
{
var mcl = multi.Components;
var minFloorLocal = int.MaxValue;
var maxTerrain = int.MinValue;
for (var lx = 0; lx < mcl.Width; lx++)
{
for (var ly = 0; ly < mcl.Height; ly++)
{
var col = mcl.Tiles[lx][ly];
if (col.Length == 0)
{
continue; // uncovered local cell
}
foreach (var tile in col)
{
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
if (data.Surface && !data.Impassable)
{
var top = tile.Z + data.CalcHeight;
if (top < minFloorLocal)
{
minFloorLocal = top;
}
}
}
var terrain = TerrainTop(map, multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly);
if (terrain > maxTerrain)
{
maxTerrain = terrain;
}
}
}
if (minFloorLocal == int.MaxValue)
{
return false; // no standable floor anywhere → don't cache (defensive)
}
return maxTerrain < minFloorLocal + multi.Z;
}
/// <summary>Inverse of <see cref="TryToLocalZ"/>: add multiZ back to recover world Zs.</summary>
public static StepMask ToWorldZ(StepMask local, int multiZ)
{
Span<sbyte> w = stackalloc sbyte[8];
Span<sbyte> s = stackalloc sbyte[8];
for (var d = 0; d < 8; d++)
{
w[d] = (sbyte)(local.GetWalkZ((Direction)d) + multiZ);
s[d] = (sbyte)(local.GetSwimZ((Direction)d) + multiZ);
}
return new StepMask(
local.WalkMask, local.WetMask,
w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7],
s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]
);
}
}
/// <summary>
/// Per-multiID lazily-filled grid of interior-cell masks. Cell state: Unknown (not yet classified),
/// Cached (interior + clean → mask valid), NonInterior (perimeter/edge/terrain-dirty → live-synth).
/// </summary>
internal sealed class MultiLocalMask
{
public enum CellState : byte { Unknown = 0, Cached = 1, NonInterior = 2 }
private readonly int _width;
private readonly int _height;
private readonly CellState[] _state;
private readonly StepMask[] _mask; // local-Z mask, valid when state == Cached
private readonly sbyte[] _floorZ; // local floor Z, valid when state == Cached
public MultiLocalMask(int width, int height)
{
_width = width;
_height = height;
_state = new CellState[width * height];
_mask = new StepMask[width * height];
_floorZ = new sbyte[width * height];
}
public int Width => _width;
public int Height => _height;
public CellState GetState(int lx, int ly) => _state[ly * _width + lx];
public void SetCached(int lx, int ly, StepMask localMask, sbyte localFloorZ)
{
var i = ly * _width + lx;
_mask[i] = localMask;
_floorZ[i] = localFloorZ;
_state[i] = CellState.Cached;
}
public void SetNonInterior(int lx, int ly) => _state[ly * _width + lx] = CellState.NonInterior;
public StepMask MaskAt(int lx, int ly) => _mask[ly * _width + lx];
public sbyte FloorZAt(int lx, int ly) => _floorZ[ly * _width + lx];
}

View file

@ -55,11 +55,17 @@ public sealed class StepCache
private long _fallthroughSourceZMismatch;
private long _fallthroughNotBuilt;
private long _fallthroughMulti;
private long _multiLocalHits;
private long _multiMaskCacheHits;
private long _evictionsByLruCap;
private long _buildsTotal;
private StepCache() { }
public void RecordMultiLocalHit() => _multiLocalHits++;
public void RecordMultiMaskCacheHit() => _multiMaskCacheHits++;
/// <summary>Hard cap on resident chunk count. Default 8192. Override for tests / ops.</summary>
public int MaxResidentChunks { get; set; } = 8192;
@ -121,6 +127,8 @@ public sealed class StepCache
fallthroughSourceZMismatch: _fallthroughSourceZMismatch,
fallthroughNotBuilt: _fallthroughNotBuilt,
fallthroughMulti: _fallthroughMulti,
multiLocalHits: _multiLocalHits,
multiMaskCacheHits: _multiMaskCacheHits,
evictionsByLruCap: _evictionsByLruCap,
buildsTotal: _buildsTotal
);
@ -134,6 +142,7 @@ public sealed class StepCache
{
ClearResidentChunks();
CloseLazyReaders();
MultiMaskCache.Instance.Clear();
}
/// <summary>
@ -156,6 +165,8 @@ public sealed class StepCache
_fallthroughSourceZMismatch = 0;
_fallthroughNotBuilt = 0;
_fallthroughMulti = 0;
_multiLocalHits = 0;
_multiMaskCacheHits = 0;
_evictionsByLruCap = 0;
_buildsTotal = 0;
}

View file

@ -186,16 +186,38 @@ public static class StepProbe
return n;
}
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ)
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) =>
ComputeMaskCore(map, x, y, sourceZ, includeMultis: false);
/// <summary>
/// Multi-aware counterpart to <see cref="ComputeMaskAt"/>: synthesizes the full 8-direction
/// walkability mask for a cell covered by (or adjacent to) a multi, folding house/boat component
/// tiles into the surface/step logic via GetStaticAndMultiTiles. Replaces the slow path's 8x
/// per-cell CheckMovement for Fallthrough_Multi cells. Item/mobile collision is still handled by
/// the caller's dynamic-obstacle pass.
/// </summary>
public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) =>
ComputeMaskCore(map, x, y, sourceZ, includeMultis: true);
/// <summary>
/// Shared per-cell 8-direction mask builder. With includeMultis=false this reproduces the
/// static-only bake (land + statics.mul). With includeMultis=true it also folds in multi
/// (house/boat) component tiles via GetStaticAndMultiTiles — the multi-aware synthesizer used
/// for Fallthrough_Multi cells. Item/mobile collision phases are still omitted (the dynamic pass
/// owns them).
/// </summary>
private static StepMask ComputeMaskCore(Map map, int x, int y, sbyte sourceZ, bool includeMultis)
{
if (map == null || map == Map.Internal)
{
return default;
}
GetStaticStartZ(map, x, y, sourceZ, canSwim: false, cantWalk: false,
var srcTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(x, y) : map.Tiles.GetStaticTiles(x, y);
GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: false, cantWalk: false,
out var walkStartZ, out var walkStartTop, out _);
GetStaticStartZ(map, x, y, sourceZ, canSwim: true, cantWalk: true,
GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: true, cantWalk: true,
out var swimStartZ, out var swimStartTop, out _);
byte walkMask = 0;
@ -213,14 +235,16 @@ public static class StepProbe
var dy = y;
CalcMoves.Offset((Direction)d, ref dx, ref dy);
if (CheckStaticStep(map, dx, dy, walkStartZ, walkStartTop,
var dTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(dx, dy) : map.Tiles.GetStaticTiles(dx, dy);
if (CheckStaticStep(map, dx, dy, dTiles, walkStartZ, walkStartTop,
canSwim: false, cantWalk: false, out var walkZ))
{
walkMask |= (byte)(1 << d);
walkZs[d] = (sbyte)walkZ;
}
if (CheckStaticStep(map, dx, dy, swimStartZ, swimStartTop,
if (CheckStaticStep(map, dx, dy, dTiles, swimStartZ, swimStartTop,
canSwim: true, cantWalk: true, out var swimZ))
{
wetMask |= (byte)(1 << d);
@ -245,7 +269,7 @@ public static class StepProbe
/// </summary>
public static int ComputeStandingZ(Map map, int x, int y, int locZ)
{
GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, out _, out _, out var zCenter);
GetStaticStartZ(map, x, y, locZ, map.Tiles.GetStaticTiles(x, y), canSwim: false, cantWalk: false, out _, out _, out var zCenter);
return zCenter;
}
@ -289,7 +313,8 @@ public static class StepProbe
/// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
/// </summary>
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
Map map, int x, int y, int locZ, Map.StaticTileEnumerable tiles,
bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter
)
{
var landTile = map.Tiles.GetLandTile(x, y);
@ -315,7 +340,7 @@ public static class StepProbe
isSet = true;
}
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
foreach (var tile in tiles)
{
var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var calcTop = tile.Z + id.CalcHeight;
@ -353,7 +378,8 @@ public static class StepProbe
/// Items and mobile collision phases are omitted.
/// </summary>
private static bool CheckStaticStep(
Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, out int newZ
Map map, int x, int y, Map.StaticTileEnumerable tiles, int startZ, int startTop,
bool canSwim, bool cantWalk, out int newZ
)
{
newZ = 0;
@ -380,7 +406,7 @@ public static class StepProbe
int testTop;
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
foreach (var tile in tiles)
{
var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var notWater = !itemData.Wet;

View file

@ -162,6 +162,7 @@ public static class PathCacheCommands
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($" fallthru(multi)={stats.FallthroughMulti} fallthru(notBuilt)={stats.FallthroughNotBuilt}");
from.SendMessage($" multiLocalHits={stats.MultiLocalHits}");
from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}");
}

View file

@ -371,52 +371,27 @@ namespace Server.Multis
public override void OnLocationChange(Point3D old)
{
if (TillerMan != null)
{
TillerMan.Location = new Point3D(
X + (TillerMan.X - old.X),
Y + (TillerMan.Y - old.Y),
Z + (TillerMan.Z - old.Z)
);
}
base.OnLocationChange(old);
if (Hold != null)
{
Hold.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z));
}
TillerMan?.Location = new Point3D(
X + (TillerMan.X - old.X),
Y + (TillerMan.Y - old.Y),
Z + (TillerMan.Z - old.Z)
);
if (PPlank != null)
{
PPlank.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z));
}
if (SPlank != null)
{
SPlank.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z));
}
Hold?.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z));
PPlank?.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z));
SPlank?.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z));
}
public override void OnMapChange()
{
if (TillerMan != null)
{
TillerMan.Map = Map;
}
base.OnMapChange();
if (Hold != null)
{
Hold.Map = Map;
}
if (PPlank != null)
{
PPlank.Map = Map;
}
if (SPlank != null)
{
SPlank.Map = Map;
}
TillerMan?.Map = Map;
Hold?.Map = Map;
PPlank?.Map = Map;
SPlank?.Map = Map;
}
public bool CanCommand(Mobile m) => true;

View file

@ -1556,6 +1556,8 @@ namespace Server.Multis
public override void OnMapChange()
{
base.OnMapChange();
if (LockDowns == null)
{
return;
@ -1627,6 +1629,8 @@ namespace Server.Multis
public override void OnLocationChange(Point3D oldLocation)
{
base.OnLocationChange(oldLocation);
if (LockDowns == null)
{
return;