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

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