diff --git a/Projects/Server/Items/BaseMulti.cs b/Projects/Server/Items/BaseMulti.cs
index e723d3ae3..0ebcac7c5 100644
--- a/Projects/Server/Items/BaseMulti.cs
+++ b/Projects/Server/Items/BaseMulti.cs
@@ -19,6 +19,24 @@ using ModernUO.Serialization;
namespace Server.Items;
+///
+/// Whether a multi instance's footprint is eligible for the pathfinding interior-mask cache
+/// (Server.Engines.Pathing.Cache.MultiMaskCache). Stored on
+/// ; recomputed when it is
+/// (e.g. after a move resets it).
+///
+public enum MultiInteriorCacheState : byte
+{
+ /// Not yet determined; recompute on next use.
+ Unknown = 0,
+
+ /// Whole footprint terrain is below the floor → interior cells may serve from the cache.
+ Clean = 1,
+
+ /// Terrain intrudes into the footprint → fall back to live synthesis.
+ 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);
+ ///
+ /// Pathfinding interior-mask cache gate (Server.Engines.Pathing.Cache.MultiMaskCache).
+ /// Reset to 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 /
+ /// MUST call base for the reset to fire.
+ ///
+ 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;
diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
index 888baa83f..d84ad7309 100644
--- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
+++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
@@ -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;
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs
new file mode 100644
index 000000000..a9796de7f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs
@@ -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;
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs
new file mode 100644
index 000000000..3fdb4fa3e
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs
@@ -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();
+ }
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs
new file mode 100644
index 000000000..41caa6255
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs
@@ -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();
+ }
+ }
+
+ ///
+ /// Find the first MCL wall cell (Impassable && !Surface) over terrain that is not
+ /// independently blocked by land or statics. Using this instead of
+ /// ensures the cell reopens after demolish.
+ ///
+ 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;
+ }
+
+ ///
+ /// Returns true if land tile or any static tile (non-multi) at (x,y) is impassable.
+ ///
+ 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;
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs
new file mode 100644
index 000000000..fc9e58898
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs
@@ -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;
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs
new file mode 100644
index 000000000..eb12df7d6
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs
@@ -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)
+
+ ///
+ /// Two overlapping GuildHouse multis whose footprints intersect. At the stacked cells
+ /// GetStaticAndMultiTiles yields tiles from BOTH multis; the synthesizer must still
+ /// agree with CheckMovement everywhere over the union footprint + halo.
+ ///
+ [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.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();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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).
+ ///
+ [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();
+ }
+ }
+
+ ///
+ /// 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 Components — i.e. it matches CheckMovement on the NEW shape.
+ ///
+ [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();
+ }
+ }
+ }
+
+ ///
+ /// 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.)
+ ///
+ [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();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs
new file mode 100644
index 000000000..8a544897c
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs
@@ -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();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs
new file mode 100644
index 000000000..923784f4f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs
@@ -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();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs
new file mode 100644
index 000000000..9a920282f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs
@@ -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();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs
new file mode 100644
index 000000000..4775b8fac
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs
@@ -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;
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs
new file mode 100644
index 000000000..092b28711
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs
@@ -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;
+
+///
+/// Minimal concrete 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.
+///
+public sealed class TestMulti : BaseMulti
+{
+ public TestMulti(int itemID) : base(itemID)
+ {
+ }
+}
+
+/// Default walker body, shared across pathfinding test fixtures.
+public sealed class WalkerStub : Mobile
+{
+ public WalkerStub() => Body = 0xC9;
+}
+
+///
+/// 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.
+///
+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;
+}
+
+///
+/// Shared helpers for placing/probing multis in pathfinding tests.
+///
+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;
+ }
+
+ ///
+ /// Sweeps the multi's footprint + 1-cell halo and asserts the multi mask synthesizer
+ /// () agrees with the CheckMovement 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.
+ ///
+ 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();
+ }
+ }
+}
+
+///
+/// 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.
+///
+public static class MultiArt
+{
+ public readonly record struct Cell(int X, int Y);
+
+ /// Every world cell the multi's footprint covers (Tiles stack non-empty).
+ public static List FootprintCells(BaseMulti multi)
+ {
+ var mcl = multi.Components;
+ var result = new List();
+ 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;
+ }
+
+ /// Footprint cells plus a 1-cell halo ring (the cells the split also routes to slow path).
+ public static HashSet FootprintWithHalo(BaseMulti multi)
+ {
+ var foot = FootprintCells(multi);
+ var set = new HashSet| ();
+ 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;
+ }
+
+ /// First world cell whose MCL stack contains an impassable, non-surface (wall) tile, or null.
+ 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;
+ }
+
+ /// First world cell whose MCL stack contains a walkable surface (floor) tile, or null.
+ 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;
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs
new file mode 100644
index 000000000..59e583ac6
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs
@@ -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();
+ }
+ }
+}
diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
index db4aa35b8..9008c18bd 100644
--- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
+++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
@@ -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.
diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
index 4b3cfc974..72c225423 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
@@ -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;
}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
new file mode 100644
index 000000000..efb047d23
--- /dev/null
+++ b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
@@ -0,0 +1,346 @@
+using System;
+using System.Collections.Generic;
+using Server.Items;
+using Server.Multis;
+
+namespace Server.Engines.Pathing.Cache;
+
+///
+/// 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 & 0x3FFF.
+///
+public sealed class MultiMaskCache
+{
+ public static MultiMaskCache Instance { get; } = new();
+
+ private const int StepHeight = 2;
+
+ private readonly Dictionary _byMultiId = [];
+
+ public void Clear() => _byMultiId.Clear();
+
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local)
+ {
+ local = default;
+ Span w = stackalloc sbyte[8];
+ Span 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;
+ }
+
+ ///
+ /// True iff all terrain (land + statics) at (x,y) sits strictly below ,
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check.
+ 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;
+ }
+
+ ///
+ /// True iff the multi's WHOLE footprint terrain sits below its lowest standable floor — i.e.
+ /// maxTerrain < 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.
+ ///
+ 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;
+ }
+
+ /// Inverse of : add multiZ back to recover world Zs.
+ public static StepMask ToWorldZ(StepMask local, int multiZ)
+ {
+ Span w = stackalloc sbyte[8];
+ Span 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]
+ );
+ }
+}
+
+///
+/// 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).
+///
+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];
+}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
index 62a1363ac..5955ddd35 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
@@ -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++;
+
/// Hard cap on resident chunk count. Default 8192. Override for tests / ops.
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();
}
///
@@ -156,6 +165,8 @@ public sealed class StepCache
_fallthroughSourceZMismatch = 0;
_fallthroughNotBuilt = 0;
_fallthroughMulti = 0;
+ _multiLocalHits = 0;
+ _multiMaskCacheHits = 0;
_evictionsByLruCap = 0;
_buildsTotal = 0;
}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
index d42cdc91f..a8c89991b 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
@@ -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);
+
+ ///
+ /// Multi-aware counterpart to : 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.
+ ///
+ public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) =>
+ ComputeMaskCore(map, x, y, sourceZ, includeMultis: true);
+
+ ///
+ /// 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).
+ ///
+ 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
///
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.
///
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.
///
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;
diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
index 01ec4cdee..14a41b6c0 100644
--- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
+++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
@@ -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}");
}
diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs
index 167ae2202..2082056da 100644
--- a/Projects/UOContent/Multis/Boats/BaseBoat.cs
+++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs
@@ -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;
diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs
index 325ec479d..aea0cb8bc 100644
--- a/Projects/UOContent/Multis/Houses/BaseHouse.cs
+++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs
@@ -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;
| | | |