diff --git a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
index 9008c18bd..2c830db20 100644
--- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
+++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs
@@ -26,12 +26,11 @@ using MoveImpl = Server.Movement.MovementImpl;
namespace Server.PathAlgorithms;
///
-/// A* pathfinder with a single bitmap-cache lookup per cell expansion. Default walkers
-/// take one call returning the 8-direction
-/// mask + per-direction Z. Non-default walkers (non-GM players, creatures with swim/fly/
-/// door/clip capabilities) and per-cell cache fallthroughs route through
-/// , which runs the per-direction
-/// loop for that one cell.
+/// A* pathfinder that expands a cell with a single lookup,
+/// which returns all 8 directions' walkability and destination Zs at once. Where the cache can't
+/// answer — a fallthrough on that cell, or a flying creature the static cache can't model —
+/// runs the per-direction
+/// loop for that one cell instead, so a partial cache miss costs only the cells it affects.
///
public class BitmapAStarAlgorithm : PathAlgorithm
{
@@ -50,61 +49,50 @@ public class BitmapAStarAlgorithm : PathAlgorithm
private const int PlaneOffset = 128;
private const int PlaneCount = 13;
private const int PlaneHeight = 20;
- // Default shared singleton (MaxSearchNodes = 1000, set from config in Configure). Typed
- // as the concrete class so Configure can set its instance config; assignable anywhere a
- // PathAlgorithm is expected. Specialized variants are just additional instances.
+ // The shared default. A differently-configured variant is just another instance.
public static readonly BitmapAStarAlgorithm Instance = new();
- // Scratch buffers — reused across every Find on THIS instance. Per-instance (not static)
- // so independently-configured algorithms don't share state. ~320 KB per instance; create
- // specialized instances once (static readonly), never per-call. Safe to reuse per Find
- // because the game loop is single-threaded and Find is never re-entered.
+ // Scratch reused across every Find on this instance — roughly 320 KB of it, so create
+ // instances once and hold them, never per call. Per-instance rather than static so two
+ // differently-configured algorithms don't share state. Reuse is safe because the game loop is
+ // single-threaded and Find never re-enters.
private readonly Direction[] _path = new Direction[AreaSize * AreaSize];
private readonly PathNode[] _nodes = new PathNode[NodeCount];
private readonly byte[] _nodeStates = new byte[NodeCount];
private readonly int[] _successors = new int[8];
private readonly PriorityQueue _openQueue = new();
- // A* node-expansion budget: the search bails (returning null) after this many node
- // expansions. Benchmarked as near-optimal: above the ~500 needed to solve walled-off
- // indoor routes, below the ~1500 window-exhaustion cost ceiling where a failed
- // (unreachable) search's worst-case cost spikes for no solving benefit. Successful
- // searches terminate on goal-found, so this never touches the common open-terrain case.
- // Per-instance so specialized algorithms (e.g. a wider-budget variant for special NPCs)
- // can coexist; the shared default lives on Instance and is set from config in Configure.
+ // Expansion budget: the search gives up and returns null past this many nodes. It bounds the
+ // cost of an unreachable goal, which would otherwise exhaust the whole search window. A
+ // successful search stops when it finds the goal, so the budget only binds on hard or hopeless
+ // routes — it needs to stay high enough to solve walled-off indoor ones.
public int MaxSearchNodes { get; set; } = 1000;
private int _xOffset;
private int _yOffset;
- // When set, GetSuccessors delegates to the per-cell slow path on every expansion
- // (creature has CanFly — Z-jumping is beyond the cache's static-only scope).
+ // Every expansion goes to the slow path: the creature can fly, and arbitrary Z-jumping is
+ // outside what a static cache can model.
private bool _currentMobileNeedsSlowPath;
- // When set, diagonal corner-cut uses the strict AND-rule (BOTH cardinal partners
- // must be walkable) instead of the lenient creature OR-rule. Cache still applies —
- // partner bits live in the same source-cell mask byte. Non-GM players only.
+ // Diagonal corner-cut uses the strict rule — both cardinal partners walkable, not just one.
+ // Non-GM players only. The cache still applies; the partner bits are in the same mask byte.
private bool _currentMobilePlayerStrict;
- // Capability overlay applied to cache results. Layered each cell:
+ // Capability overlay on the cache's two rule sets, applied per cell as
// effective = (walkMask & !cantWalk) | (wetMask & canSwim)
- // Reset at end of Find.
private bool _currentMobileCanSwim;
private bool _currentMobileCantWalk;
- // Dynamic-obstacle pass capability flags (per-mobile, captured in Find).
- // Mirrors MovementImpl.Check's per-mobile derivations so per-cell items/mobiles
- // checks can be evaluated without re-deriving.
+ // Per-mobile flags for the dynamic-obstacle pass, derived once in Find rather than per cell.
private bool _currentMobileIgnoreDoors;
private bool _currentMobileIgnoreSpellFields;
private bool _currentMobileIgnoreMovableImpassables;
public static void Configure()
{
- // A* node-expansion budget. Default 1000 is benchmarked near-optimal (see
- // MaxSearchNodes). Applied to the shared singleton; specialized instances pass their
- // own value. Written back to server.cfg on first boot. Auto-invoked at startup via
- // AssemblyHandler.Invoke("Configure").
+ // Shard-tunable expansion budget for the shared instance; see MaxSearchNodes. Written back
+ // to server.cfg on first boot so it's discoverable.
Instance.MaxSearchNodes = ServerConfiguration.GetOrUpdateSetting(
"pathfinding.maxSearchNodes",
1000
@@ -136,10 +124,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
return null;
}
- // Mark a new Find generation so the StepCache promotion gate counts THIS pathfind
- // as one touch per chunk regardless of how many times the expansion frontier
- // probes a given chunk. Without this, A* hits each visited chunk dozens of times
- // and trips the threshold immediately.
+ // The frontier probes a given chunk dozens of times over one search; opening a generation
+ // is what makes the cache's promotion gate count all of that as a single touch.
StepCache.Instance.BeginFindGeneration();
PathfindRecorder.RecordIfEnabled(m, map, start, goal);
@@ -161,7 +147,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm
_currentMobileIgnoreMovableImpassables = false;
}
- // Mirrors MovementImpl: dead/spectral mobiles also ignore doors.
+ // Dead and spectral mobiles pass through doors too. Mirrors MovementImpl.
_currentMobileIgnoreDoors |= !m.Alive || m.Body.BodyID == 0x3DB || m.IsDeadBondedPet;
_currentMobileIgnoreSpellFields = m is PlayerMobile && map != Map.Felucca;
@@ -209,7 +195,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
_nodeStates[bestNode] = 2;
- // Set MovementImpl globals so per-cell slow-path fallthroughs see the right state.
+ // MovementImpl reads these statics, so a slow-path fallthrough on any cell below needs
+ // them set for this mobile.
if (bc != null)
{
MoveImpl.AlwaysIgnoreDoors = bc.CanOpenDoors;
@@ -326,11 +313,10 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
///
- /// One call returns the 8-direction
- /// walkable mask + destination Zs. Diagonal corner-cut applies the lenient creature
- /// OR-rule using partner bits in the same mask byte — no neighbor-chunk lookup needed.
- /// On cache fallthrough or for non-default walkers, defers to
- /// for THIS cell only.
+ /// Expands one cell into its walkable neighbours. A single cache lookup covers all 8
+ /// directions, including the partner bits the diagonal corner-cut needs, so no neighbouring
+ /// cell has to be consulted. Falls back to for this cell
+ /// alone when the cache can't answer.
///
private int GetSuccessors(int p, Mobile m, Map map)
{
@@ -353,16 +339,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm
if (!lookup.IsHit)
{
- // 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.
+ // A multi-covered cell still gets a whole-cell mask, synthesized over the house or boat
+ // components rather than looked up. That keeps it out of the slow path's 8 separate
+ // CheckMovement calls, and the result flows through the same overlay, corner-cut and
+ // dynamic-obstacle logic below as a cache hit would.
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
@@ -371,8 +353,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
}
- // Capability overlay: walking allowed unless cantWalk; swimming allowed if canSwim.
- // Partner bits used for diagonal corner-cut also use the effective mask.
+ // Overlay the mobile's capabilities onto the cache's two rule sets. The corner-cut below
+ // reads its partner bits from this effective mask, not the raw one.
var walkBits = _currentMobileCantWalk ? (byte)0 : lookup.WalkMask;
var swimBits = _currentMobileCanSwim ? lookup.WetMask : (byte)0;
var mask = (byte)(walkBits | swimBits);
@@ -393,9 +375,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
continue;
}
- // Diagonal corner-cut. Creatures (default): OR-rule — at least one cardinal
- // partner walkable. Non-GM players: AND-rule — BOTH partners must be walkable.
- // Partner bits live in the same source-cell mask byte either way.
+ // Diagonal corner-cut: a creature needs at least one of the two flanking cardinals to
+ // be walkable, a non-GM player needs both.
if ((i & 1) == 1)
{
var leftBit = 1 << ((i - 1) & 0x7);
@@ -408,9 +389,9 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
}
- // Walking takes precedence over swimming when both apply (matches MovementImpl's
- // surface-selection: closest-to-startZ wins, and walk surface is always closer
- // when the creature is currently standing on land).
+ // Walking wins over swimming where both are possible. MovementImpl picks the surface
+ // closest to the start Z, and for a creature standing on land that is always the walk
+ // surface.
var useWalkZ = (walkBits & (1 << i)) != 0;
var z = useWalkZ
? i switch
@@ -441,8 +422,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
var absX = x + _xOffset;
var absY = y + _yOffset;
- // Dynamic-obstacle pass: items + mobiles at the target cell. Cache only
- // covers static walkability; dynamic state has to be checked at query time.
+ // The cache only knows static terrain, so items and mobiles at the target cell have to
+ // be checked live.
if (IsBlockedByDynamic(m, map, absX, absY, z))
{
continue;
@@ -464,11 +445,9 @@ public class BitmapAStarAlgorithm : PathAlgorithm
private const int MobileHeight = 15;
///
- /// Mirrors MovementImpl's dynamic-item / mobile collision phase for a target cell.
- /// Items: ImpassableSurface that overlap (z, z+PersonHeight), respecting capability
- /// overrides (CanOpenDoors → ignore door items; CanMoveOverObstacles → ignore movables;
- /// non-Felucca players → ignore spell fields). Mobiles: any other mobile whose Z range
- /// overlaps and which we can't move over.
+ /// MovementImpl's item and mobile collision phase for one target cell: impassable items
+ /// overlapping the mobile's vertical envelope block it, subject to the capability overrides,
+ /// as does any other mobile it can't move over.
///
private bool IsBlockedByDynamic(Mobile m, Map map, int x, int y, int z)
{
@@ -509,9 +488,9 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
}
- // A* must be able to plan a path to the goal cell even when the target mobile is
- // standing on it (the follower stops within range short of it). Skip the mob-block
- // check at the goal cell ONLY; everywhere else dynamic mobiles still block.
+ // The goal cell is usually occupied by whatever the mobile is chasing, so blocking on it
+ // would fail every pursuit. The follower stops short of the goal anyway. Every other cell
+ // still blocks on mobiles.
var skipMobCheck = x == MoveImpl.Goal.X && y == MoveImpl.Goal.Y;
if (!skipMobCheck)
@@ -534,18 +513,16 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
///
- /// Mirrors MovementImpl.CanMoveOver — true when m can step onto t's cell (dead bodies,
- /// hidden staff, etc.).
+ /// True when m can step onto t's cell — a corpse, hidden staff, and so on. Mirrors
+ /// MovementImpl.CanMoveOver.
///
private static bool CanMoveOver(Mobile m, Mobile t) =>
!t.Alive || !m.Alive || t.IsDeadBondedPet || m.IsDeadBondedPet
|| t.Hidden && t.AccessLevel > AccessLevel.Player;
///
- /// Per-direction loop for a single source cell.
- /// Runs on cache fallthrough or when is set.
- /// CheckMovement validates land/statics/items via MovementImpl; dynamic mobile blocking
- /// is layered on top because MovementImpl doesn't iterate same-cell mobiles.
+ /// Expands one cell the long way, with a CheckMovement call per direction. The same-cell
+ /// mobile check is layered on top because MovementImpl doesn't iterate those.
///
private int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals)
{
@@ -586,11 +563,10 @@ public class BitmapAStarAlgorithm : PathAlgorithm
}
///
- /// True for creatures whose movement rules the static cache can't model. Currently
- /// only CanFly — flying creatures Z-jump arbitrarily and the cache's source-Z guard
- /// would over-fire. CanSwim / CantWalk are handled via the capability overlay (walkMask
- /// + wetMask). CanOpenDoors / CanMoveOverObstacles only affect dynamic items and don't
- /// disqualify the cache.
+ /// True for creatures the static cache can't model at all. Only flying ones qualify: they
+ /// Z-jump freely, so the cache's source-Z guard would reject nearly every cell anyway. Swim
+ /// and cant-walk are handled by the capability overlay, and the door / obstacle capabilities
+ /// only affect dynamic items, so none of those disqualify the cache.
///
private static bool RequiresSlowPath(Mobile m) => m is BaseCreature bc && bc.CanFly;
}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs
index 031d3ed21..e1cdd2261 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/CacheEvictionTimer.cs
@@ -3,9 +3,8 @@ using System;
namespace Server.Engines.Pathing.Cache;
///
-/// Periodic backstop that enforces the StaticWalkabilityCache resident-chunk cap.
-/// Steady-state cost is a single early-return; only fires real work when the cache
-/// has overflowed MaxResidentChunks. Runs on the game thread; no locking required.
+/// Periodic backstop that enforces 's resident-chunk cap. Costs a
+/// single early-return unless the cache has overflowed MaxResidentChunks.
///
public class CacheEvictionTimer : Timer
{
diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs
index ad4d8b3e7..f851559b1 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs
@@ -1,18 +1,18 @@
namespace Server.Engines.Pathing.Cache;
///
-/// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive
-/// the slow-path fallthrough decision in callers. Ordering is load-bearing:
-/// values 0-2 are hits, values 3+ are fallthroughs (see StepMask.IsHit).
+/// Outcome of . Drives both telemetry and the caller's
+/// decision to fall back to the slow path. Ordering is load-bearing: 0-2 are usable answers,
+/// 3+ are fallthroughs, and tests that boundary.
///
public enum CacheHitKind : byte
{
- Hit = 0, // clean default-walker answer from the resident chunk
+ Hit = 0, // served from the resident chunk
Miss_NotBuilt = 1, // chunk wasn't resident; built and returned
- Miss_DirtyRebuild = 2, // version mismatch; rebuilt and returned
- Fallthrough_MultiZ = 3, // cell has multiple walkable surfaces; caller must use slow path
+ Miss_DirtyRebuild = 2, // chunk was stale; rebuilt and returned
+ Fallthrough_MultiZ = 3, // stacked walkable surfaces, none matching the query Z
Fallthrough_OffMap = 4, // out of bounds
- Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge
- Fallthrough_NotBuilt = 6, // first-touch miss without lazy file hit; build deferred until second touch
- Fallthrough_Multi = 7, // a multi (house/boat) covers this cell or its halo; use the live path
+ Fallthrough_SourceZMismatch = 5, // |query Z - baked SourceZ| > StepHeight; a cached answer would diverge
+ Fallthrough_NotBuilt = 6, // first touch of an unbuilt chunk; the promotion gate defers the build
+ Fallthrough_Multi = 7, // a multi (house/boat) covers this cell or its halo
}
diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
index 72c225423..419481e87 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs
@@ -1,8 +1,9 @@
namespace Server.Engines.Pathing.Cache;
///
-/// Snapshot of StaticWalkabilityCache counters. Returned by GetStats() and consumed
-/// by the [PathCacheStats admin command. All counters are monotonic except ResidentChunks.
+/// Snapshot of 's counters, as returned by GetStats() and reported by
+/// the [PathCacheStats command. Every counter is monotonic except ResidentChunks, and all of
+/// them reset on Clear() — they count since the last clear, not since startup.
///
public readonly struct CacheStats(
int residentChunks,
diff --git a/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
index efb047d23..9cfd32f8c 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
@@ -6,13 +6,19 @@ 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.
+/// Caches walkability masks for the interior cells of a multi, shared across every instance of the
+/// same multiID.
+///
+/// An interior cell — one whose 8 neighbours are all covered by the multi — has no terrain
+/// neighbour, so its mask depends only on the multi's own component tiles and is identical at every
+/// position the design is placed. That makes it cacheable in the multi's local frame and reusable
+/// across instances; perimeter cells are not, and fall back to .
+///
+/// The catch is terrain intruding into the multi's floor envelope, which would make a cell's mask
+/// position-dependent after all. rules that out per instance,
+/// once: if the whole footprint's terrain sits below the lowest floor, no interior cell can see it.
+/// A dirty instance, or a (whose design mutates at runtime), always
+/// synthesizes live rather than risk serving a wrong mask.
///
public sealed class MultiMaskCache
{
@@ -25,26 +31,20 @@ public sealed class MultiMaskCache
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).
+ /// The multi-aware mask for a covered cell, always usable (HitKind is always Hit). Served from
+ /// the shared cache when the cell is a clean interior one, synthesized live otherwise.
///
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
+ || multi is HouseFoundation) // its DesignState MCL changes at runtime
{
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.
+ // Boats are cached despite moving: a deck mask is built in the local frame and is invariant
+ // under translation, and the clean gate plus the ItemID/location/map resets cover turning.
- // 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 =
@@ -62,7 +62,7 @@ public sealed class MultiMaskCache
if (state == MultiLocalMask.CellState.Cached)
{
- // Footprint is clean, so only the source-Z match matters (terrain can't intrude).
+ // A clean footprint rules terrain out, so the source-Z match is the only guard left.
var worldFloorZ = local.FloorZAt(lx, ly) + multi.Z;
if (Math.Abs(sourceZ - worldFloorZ) <= StepHeight)
{
@@ -78,8 +78,7 @@ public sealed class MultiMaskCache
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.
+ // First touch of this cell: synthesize, then classify and cache if it's interior.
var mask = LiveSynth(map, x, y, sourceZ);
if (IsInteriorLocalCell(mcl, lx, ly)
&& TryToLocalZ(mask, multi.Z, out var localMask)
@@ -113,8 +112,7 @@ public sealed class MultiMaskCache
}
///
- /// 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.
+ /// Finds the multi covering (x,y) and the cell's indices into its MCL, or false if none does.
///
public static bool TryResolveCoveringMulti(Map map, int x, int y, out BaseMulti multi, out int lx, out int ly)
{
@@ -138,9 +136,9 @@ public sealed class MultiMaskCache
}
///
- /// 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.
+ /// True when (lx,ly) and all 8 of its neighbours carry MCL tiles. Such a cell has no terrain
+ /// neighbour, so the multi alone determines its transitions and its mask is position-invariant.
+ /// A pure function of the MCL.
///
public static bool IsInteriorLocalCell(MultiComponentList mcl, int lx, int ly)
{
@@ -161,9 +159,9 @@ public sealed class MultiMaskCache
}
///
- /// 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.
+ /// Rebases a world-frame mask's per-direction Zs into the multi's local frame. Returns false
+ /// when a local Z overflows sbyte — only reachable at extreme |multiZ| — and the caller must
+ /// then leave the cell uncached.
///
public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local)
{
@@ -191,9 +189,8 @@ public sealed class MultiMaskCache
}
///
- /// 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.
+ /// True when all terrain (land + statics) at (x,y) sits strictly below ,
+ /// so a creature standing on the multi's floor never sees terrain in its envelope.
///
public static bool TerrainTopBelow(Map map, int x, int y, sbyte floorZ)
{
@@ -216,7 +213,7 @@ public sealed class MultiMaskCache
return true;
}
- /// Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check.
+ /// Highest terrain (land + statics) top at (x,y).
public static int TerrainTop(Map map, int x, int y)
{
map.GetAverageZ(x, y, out _, out _, out var top);
@@ -234,10 +231,10 @@ public sealed class MultiMaskCache
}
///
- /// 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.
+ /// True when the multi's entire footprint terrain sits below its lowest standable floor. No
+ /// covered cell's terrain — nor any neighbour's — can then intrude into a creature's floor
+ /// envelope, which is what makes this instance's interior cells safe to serve from the shared
+ /// per-multiID cache. Evaluated once per instance and cached on the multi.
///
public static bool ComputeFootprintClean(Map map, BaseMulti multi)
{
@@ -252,7 +249,7 @@ public sealed class MultiMaskCache
var col = mcl.Tiles[lx][ly];
if (col.Length == 0)
{
- continue; // uncovered local cell
+ continue;
}
foreach (var tile in col)
@@ -278,7 +275,7 @@ public sealed class MultiMaskCache
if (minFloorLocal == int.MaxValue)
{
- return false; // no standable floor anywhere → don't cache (defensive)
+ return false; // no standable floor anywhere; refuse to cache rather than guess
}
return maxTerrain < minFloorLocal + multi.Z;
@@ -304,8 +301,9 @@ public sealed class MultiMaskCache
}
///
-/// 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).
+/// One multiID's grid of interior-cell masks, filled in as cells are first touched. A cell is
+/// Unknown until classified, then either Cached (interior — the mask is valid) or NonInterior
+/// (perimeter — synthesize live).
///
internal sealed class MultiLocalMask
{
@@ -314,8 +312,8 @@ internal sealed class MultiLocalMask
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
+ private readonly StepMask[] _mask; // local-frame mask, valid only when state == Cached
+ private readonly sbyte[] _floorZ; // local floor Z, valid only when state == Cached
public MultiLocalMask(int width, int height)
{
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
index cde1382fd..f66ae5739 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs
@@ -10,13 +10,15 @@ using Server.Logging;
namespace Server.Engines.Pathing.Cache;
///
-/// Singleton store of per-chunk static walkability data. Chunks correspond to
-/// Map.SectorSize = 16; key encoding packs (mapId, chunkX, chunkY) into a long.
-/// Lazily built on first query; invalidated by version-check vs Sector.MultisVersion;
-/// memory bounded by MaxResidentChunks via probabilistic LRU eviction.
+/// Singleton store of static walkability, keyed by 16x16 chunk (one per map sector). Chunks build
+/// on demand and memory stays bounded by MaxResidentChunks through probabilistic LRU eviction, so
+/// the cache is usable with no on-disk bake at all; a baked .swb file only removes the first-touch
+/// build cost.
///
-/// Default-walker scope only. Cells with multi-Z surfaces and queries for non-default
-/// walkers route to the MovementImpl slow path via the Fallthrough_* hit kinds.
+/// The cache answers for a default walker on static terrain. Anything outside that — a multi
+/// covering the cell, a query Z that doesn't match what the cell was baked at, stacked surfaces
+/// with no matching stratum — returns a Fallthrough_* kind, and the caller resolves that cell
+/// through MovementImpl instead. Callers must check .
///
public sealed class StepCache
{
@@ -25,25 +27,22 @@ public sealed class StepCache
public static StepCache Instance { get; } = new();
private readonly Dictionary _chunks = [];
- // Parallel list of keys for O(1) random sampling during eviction. Kept in lockstep
- // with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction.
+ // Keys of _chunks, kept in lockstep with it, so eviction can sample a random resident chunk
+ // in O(1). Appended on insert, swap-and-popped on eviction.
private readonly List _keysList = [];
- // Second-touch promotion tracker. A chunk's first miss within the window returns
- // Fallthrough_NotBuilt; the caller takes the slow path. The Nth DISTINCT-FIND miss
- // within the same window (where N = MissPromotionThreshold) promotes to BuildChunk +
- // serve. We count distinct Find generations, not raw TryGetMask calls — A* expansion
- // hits each visited chunk many times in one Find, so per-call counting hits threshold
- // immediately and defeats the gate. Per-Find counting filters single-Find pass-throughs
- // (pet following a moving player) while still promoting chunks revisited by multiple
- // Finds (NPC patrolling fixed territory).
+ // Promotion gate. A chunk's first miss returns Fallthrough_NotBuilt and the caller takes the
+ // slow path; only once misses reach MissPromotionThreshold within MissPromotionWindowMs does
+ // the chunk get built and served. This keeps one-off traffic — a pet trailing a player across
+ // the map — from building chunks nothing will query again, while a creature working a fixed
+ // territory still warms the chunks it revisits.
+ //
+ // The gate counts distinct Finds, not TryGetMask calls: A* probes each chunk it visits dozens
+ // of times within a single pathfind, so per-call counting would cross any threshold instantly
+ // and gate nothing.
private readonly Dictionary _chunkMissTracker = [];
private const int MaxMissTrackerEntries = 4096;
- // Generation counter incremented by BeginFindGeneration(). Sentinel 0 = "no Find started
- // yet"; treated as a distinct generation per call so callers that bypass BeginFindGeneration
- // (single-call tests, BakeMap with threshold=1) get sensible behavior.
-
private struct ChunkMissState
{
public byte MissCount;
@@ -83,24 +82,21 @@ public sealed class StepCache
public bool PreloadOnLazyOpen { get; set; }
///
- /// Number of misses on the same chunk within
- /// required to trigger a build. 1 = eager (legacy behavior). 2 = second-touch (default,
- /// filters single-touch pass-throughs).
+ /// Misses on the same chunk, within , needed to build it.
+ /// 1 builds eagerly on first touch; the default 2 waits for a second Find to show interest.
///
public int MissPromotionThreshold { get; set; } = 2;
///
- /// Window over which misses against the same chunk accumulate toward promotion.
- /// Misses spaced wider than this restart the count. Default 30s.
+ /// How long misses on a chunk accumulate toward promotion. A gap wider than this restarts
+ /// the count.
///
public uint MissPromotionWindowMs { get; set; } = 30_000;
///
- /// Marks the start of a new pathfind. The promotion gate counts distinct Find
- /// generations per chunk, not raw TryGetMask calls — call this once at the top of
- /// each pathfind invocation so multiple cell expansions within one Find don't trip
- /// the threshold. Wraps at uint.MaxValue back to 1 (0 is reserved as the
- /// "no Find started yet" sentinel).
+ /// Opens a new pathfind for the promotion gate. Call once per pathfind: the gate counts
+ /// distinct Finds, so without this every cell expansion would count separately and the
+ /// threshold would be met immediately. Wraps back to 1, since 0 means "no Find open".
///
public void BeginFindGeneration()
{
@@ -112,12 +108,11 @@ public sealed class StepCache
}
}
- /// Test-only: read the current Find generation.
+ /// The open pathfind's generation, or 0 if none. See .
internal uint CurrentFindGeneration { get; private set; }
///
- /// Pack (mapId, chunkX, chunkY) into a single long key.
- /// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16].
+ /// Packs (mapId, chunkX, chunkY) into one key: [reserved 16][mapId 16][chunkX 16][chunkY 16].
///
internal static long EncodeKey(int mapId, int chunkX, int chunkY) =>
((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF);
@@ -139,9 +134,8 @@ public sealed class StepCache
);
///
- /// Drop all cached chunks AND zero every telemetry counter. Used by tests and
- /// benchmarks that need a known cold-start state. Counter reset is intentional —
- /// counters are since-last-clear, not since-startup.
+ /// Returns the cache to a cold-start state: drops every chunk, closes the .swb readers, and
+ /// zeroes the counters.
///
public void Clear()
{
@@ -151,10 +145,8 @@ public sealed class StepCache
}
///
- /// Drop all resident chunks AND zero counters, but keep lazy readers open.
- /// Useful in benchmark loops that want to measure "first query after boot" cost
- /// without paying the lazy-reader reopen overhead each iteration. Same intent as
- /// minus the file-handle teardown.
+ /// without the file-handle teardown: drops the resident chunks and zeroes
+ /// the counters, but leaves the .swb readers open so the next query can refill from them.
///
public void ClearResidentChunks()
{
@@ -176,16 +168,14 @@ public sealed class StepCache
_buildsTotal = 0;
}
- // Per-map open .swb readers, populated by TryOpenLazyReader at startup. Chunks are
- // fetched on demand from the file when ResolveMissingChunk fires; resident memory
- // stays bounded by MaxResidentChunks regardless of file size.
- private readonly Dictionary _lazyReaders = new();
+ // Open .swb readers, one per map. Chunks are pulled from them on demand, so resident memory
+ // stays bounded by MaxResidentChunks no matter how large the file is.
+ private readonly Dictionary _lazyReaders = [];
///
- /// Walk every chunk in , populate the resident set, then
- /// save to . Returns the number of chunks written.
- /// Designed for offline / fixture use; blocks the calling thread for many seconds
- /// on a full Trammel walk.
+ /// Builds every chunk in the map and writes them to , returning the
+ /// number written. Blocks the caller for many seconds on a full-size map — run it offline or
+ /// during maintenance, not on a live shard at peak.
///
public int BakeMap(int mapId, string path)
{
@@ -195,9 +185,9 @@ public sealed class StepCache
return 0;
}
- // BakeMap is an explicit decision to populate every chunk; the promotion gate
- // would otherwise return Fallthrough_NotBuilt for every chunk (each touched once)
- // and the bake would write an empty file. Force eager build for the duration.
+ // A bake touches each chunk exactly once, so the promotion gate would defer every one of
+ // them and write an empty file. Baking is an explicit decision to populate everything, so
+ // build eagerly for the duration.
var prevThreshold = MissPromotionThreshold;
MissPromotionThreshold = 1;
try
@@ -216,8 +206,8 @@ public sealed class StepCache
{
for (var cx = 0; cx < chunkCols; cx++)
{
- // Any sourceZ works — the chunk is built on first access regardless of
- // whether the query returns Hit or Fallthrough_SourceZMismatch.
+ // The sourceZ is irrelevant here: the chunk gets built on first access whether
+ // the query ends up a Hit or a Fallthrough_SourceZMismatch.
TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
}
@@ -245,9 +235,9 @@ public sealed class StepCache
}
///
- /// Persist all resident chunks for to a .swb file. Returns
- /// the number of chunks written. The file embeds a TileData fingerprint so a stale
- /// file (built before a client patch) can be detected and rejected at open time.
+ /// Writes the map's resident chunks to a .swb file and returns the count. The file carries a
+ /// fingerprint of the tile and map data, so a bake made before a client patch is detected and
+ /// rejected when it is next opened.
///
public int SaveToFile(string path, int mapId)
{
@@ -285,10 +275,9 @@ public sealed class StepCache
}
///
- /// Open a .swb file as a lazy backing store for . Reads only
- /// header + chunk-offset index (~16 bytes per chunk); individual records are fetched
- /// on demand by . Returns false on missing file,
- /// magic / version mismatch, or TileData hash mismatch (stale bake).
+ /// Opens a .swb file as a backing store for the map, reading only the header and chunk index
+ /// up front; records are pulled as queries ask for them. Returns false if the file is missing,
+ /// unreadable, or a stale bake whose fingerprint no longer matches the live tile data.
///
public bool TryOpenLazyReader(string path, int mapId)
{
@@ -328,10 +317,7 @@ public sealed class StepCache
}
///
- /// Materializes every chunk in into the resident set.
- /// Called from when
- /// is set. Skips chunks whose live doesn't
- /// match the file's snapshot — those will rebake on first query.
+ /// Loads every chunk in the file into the resident set, for .
///
private void PreloadFromLazyReader(int mapId, StepCacheFile.LazyReader reader)
{
@@ -366,29 +352,22 @@ public sealed class StepCache
);
}
- ///
- /// Number of .swb readers currently open. Mostly for tests / telemetry.
- ///
+ /// Number of .swb readers currently open.
public int OpenLazyReaderCount => _lazyReaders.Count;
///
- /// True if a valid .swb reader is open for . A reader only opens via
- /// after validates the
- /// file's fingerprint against the live tile data, so "has reader" already means "present and
- /// up-to-date" — the boot prebake uses this to skip baking maps that don't need it, instead of
- /// recomputing the fingerprint a second time.
+ /// True when a .swb reader is open for the map. A reader only opens after its fingerprint
+ /// validates against the live tile data, so this already answers "is there an up-to-date bake
+ /// for this map?" — the boot prebake leans on that to skip maps rather than fingerprint them
+ /// a second time.
///
public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId);
- /// Test-only diagnostic: does the lazy reader for hold an offset for (chunkX, chunkY)?
+ /// Diagnostic: does the map's .swb hold a record for (chunkX, chunkY)?
internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) =>
_lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY);
- ///
- /// Closes all open lazy readers, releasing their underlying file streams. Called from
- /// so test cleanup can delete .swb files (they're held with
- /// FileShare.Read | FileShare.Delete, so this is mostly belt-and-suspenders).
- ///
+ /// Closes every open .swb reader, releasing the underlying file streams.
public void CloseLazyReaders()
{
foreach (var reader in _lazyReaders.Values)
@@ -399,18 +378,16 @@ public sealed class StepCache
}
///
- /// Probabilistic LRU sample size — picks SampleSize random resident chunks per
- /// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny
- /// fraction of the cost (no full sort). Redis uses the same approach (`maxmemory-samples`).
- /// 5 yields ~quality-of-true-LRU for cache eviction; higher values trade speed for accuracy.
+ /// How many random resident chunks each eviction samples before dropping the oldest of them.
+ /// Sampling approximates true LRU closely enough at a fraction of the cost, since it needs no
+ /// sort and no access-ordered structure. Raising it trades speed for accuracy.
///
private const int LruSampleSize = 5;
///
- /// If resident chunk count exceeds MaxResidentChunks, evict via probabilistic LRU
- /// until the count is at or below the cap. Per-eviction cost is O(LruSampleSize),
- /// independent of resident count — sustained cap pressure has no perpetual perf hit.
- /// Called from CacheEvictionTimer; also callable directly from tests.
+ /// Evicts chunks until the resident count is back within MaxResidentChunks. Each eviction costs
+ /// O() regardless of how many chunks are resident, so sustained cap
+ /// pressure doesn't degrade. Driven by .
///
public void EnforceLruCap()
{
@@ -426,8 +403,8 @@ public sealed class StepCache
long oldestTouched = long.MaxValue;
long oldestKey = 0;
- // Sample LruSampleSize random keys; track the oldest by LastTouchedTicks.
- // With replacement is fine — collisions are rare and don't break correctness.
+ // Sampling with replacement: a repeated key just wastes one sample, it can't pick a
+ // wrong victim.
var samples = Math.Min(LruSampleSize, _keysList.Count);
for (var s = 0; s < samples; s++)
{
@@ -482,11 +459,10 @@ public sealed class StepCache
}
///
- /// True if a multi (house / boat) covers (x, y) or any of its 8 neighbours. Multi-covered
- /// cells — plus the 1-cell halo, because a cell's mask encodes the edges TO its neighbours, so
- /// a neighbouring wall must block those edges — are served by the live movement path, not the
- /// static chunk cache. Cheap: an interior cell checks only its own sector (chunk == sector);
- /// only edge/corner cells additionally check the adjacent sector(s) the halo reaches.
+ /// True when a multi covers (x, y) or any of its 8 neighbours. The halo matters because a
+ /// cell's mask encodes the edges TO its neighbours, so a wall one cell over has to block those
+ /// edges. Since a chunk is a sector, an interior cell only inspects its own sector's HasMultis
+ /// flag; edge and corner cells additionally check whichever adjacent sectors the halo reaches.
///
private static bool MultiInfluence(Map map, int x, int y)
{
@@ -503,7 +479,7 @@ public sealed class StepCache
var south = (y & 15) == 15;
if (!(west || east || north || south))
{
- return false; // interior cell — its whole halo is inside the (multi-free) own sector
+ return false; // interior cell: its whole halo lies in this sector, which has no multis
}
return west && map.GetRealSector(sx - 1, sy).HasMultis
@@ -517,9 +493,9 @@ public sealed class StepCache
}
///
- /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind.
- /// Inspect to decide whether to use the result or fall
- /// back to the slow path.
+ /// The hot-path query: one lookup yields the cell's 8-direction mask, its 8 destination Zs,
+ /// and the hit kind. Check before trusting the payload — on any
+ /// fallthrough it is all zeroes and the caller must resolve the cell through MovementImpl.
///
public StepMask TryGetMask(Map map, int x, int y, sbyte sourceZ)
{
@@ -529,10 +505,9 @@ public sealed class StepCache
return Fallthrough(CacheHitKind.Fallthrough_OffMap);
}
- // Multis (houses, boats) are not baked into the static chunk cache (they're dynamic
- // content). If a multi covers this cell or its 1-cell halo, route to the live movement
- // path, which is fully multi-aware. Gated on Sector.HasMultis, so the multi-free majority
- // of the map pays a single (interior) sector lookup.
+ // Multis are dynamic, so they are never baked into a chunk. Cells they touch go to the
+ // multi-aware path instead. The check is gated on Sector.HasMultis, so the multi-free
+ // majority of the map pays one sector lookup for it.
if (MultiInfluence(map, x, y))
{
_fallthroughMulti++;
@@ -546,8 +521,8 @@ public sealed class StepCache
var hitKindResult = CacheHitKind.Hit;
if (!_chunks.TryGetValue(key, out var chunk))
{
- // Try lazy file first — file-loaded chunks bypass the miss tracker because
- // the .swb represents an explicit prior decision to keep this chunk warm.
+ // The .swb is consulted before the promotion gate: a baked chunk is already an explicit
+ // decision to keep this area warm, and loading it is far cheaper than building it.
chunk = TryLoadFromLazyReader(map, chunkX, chunkY);
if (chunk != null)
{
@@ -569,16 +544,16 @@ public sealed class StepCache
}
}
- // A resident chunk is static-only — it never goes stale from multis (multi-covered cells
- // fall through to the live path above).
+ // No staleness check: a resident chunk holds only static terrain, and every cell a multi
+ // could have changed already fell through above.
chunk.LastTouchedTicks = Core.TickCount;
var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4));
if (chunk.IsCellMultiZ(cellIndex))
{
- // Tier 4: try the per-cell strata. Each stratum is keyed by its bake-time
- // standing-Z; a query matches when |sourceZ - stratum.zCenter| <= StepHeight.
+ // Stacked surfaces: pick the stratum baked nearest the query Z. Multi-Z cells are
+ // served only from strata, never from the main mask.
if (TryStratumHit(chunk, cellIndex, sourceZ, hitKindResult, out var stratumResult))
{
RecordServed(hitKindResult);
@@ -589,15 +564,14 @@ public sealed class StepCache
return Fallthrough(CacheHitKind.Fallthrough_MultiZ);
}
- // Source-Z guard: the cache stores one answer per cell baked at SourceZ.
- // StepHeight tolerance accepts incremental Z jitter; loosening it breaks parity
- // because tile reachability shifts at step-height boundaries.
+ // Source-Z guard. A cell holds one answer, baked at one standing Z, so a query from too far
+ // above or below it would get an answer that doesn't apply. The StepHeight tolerance
+ // absorbs ordinary Z jitter and cannot be widened: reachability flips at exactly that
+ // boundary, so a looser guard would serve answers that disagree with MovementImpl.
if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight)
{
- // Swim-layer fallback for shore cells: if the chunk has the layer and this
- // cell's water-surface Z is within StepHeight of the query, serve from the
- // swim layer (computed at swim-perspective Z). Walker queries on shore cells
- // fall through this branch via their Z mismatch with SwimSourceZ.
+ // Unless this is a shore cell and the query is coming from the water, in which case the
+ // swim layer holds the answer baked from the water surface.
if (chunk.HasSwimLayer)
{
var swimSrc = chunk.SwimSourceZ[cellIndex];
@@ -650,37 +624,24 @@ public sealed class StepCache
}
///
- /// Returns a fresh StepChunk loaded from the lazy file reader, or null if there's no
- /// open reader for the map / no record at (chunkX, chunkY) / the loaded snapshot is
- /// stale relative to the live sector's . A null
- /// return means the caller should consult the miss tracker; a stale return means
- /// "rebuild, the .swb is out of date and a future SaveToFile will overwrite it."
+ /// Loads a chunk from the map's .swb, or null if no reader is open or the file has no record at
+ /// (chunkX, chunkY). No staleness check is needed here — the fingerprint was validated when the
+ /// file was opened, and the chunks are static-only.
///
- private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY)
- {
- if (!_lazyReaders.TryGetValue(map.MapID, out var reader))
- {
- return null;
- }
- // Static-only chunks are valid once the file fingerprint matched at open time; multi-covered
- // cells fall through before reaching here. Returns null when the file lacks this chunk.
- return reader.TryReadChunk(chunkX, chunkY);
- }
+ private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY) =>
+ _lazyReaders.TryGetValue(map.MapID, out var reader) ? reader.TryReadChunk(chunkX, chunkY) : null;
///
- /// Records a miss for and decides whether to build now
- /// or defer to slow path. Counts distinct Find generations, not raw calls — multiple
- /// TryGetMask calls within one Find (BeginFindGeneration scope) count as one touch.
- /// Returns true when DISTINCT-FIND misses within the window cross
- /// ; caller should run BuildChunk and serve.
- /// Returns false otherwise; caller should return Fallthrough_NotBuilt so the algorithm
- /// uses the slow path. Generation 0 ("no Find active") treats every call as distinct,
- /// preserving legacy semantics for callers that don't call BeginFindGeneration.
+ /// Records a miss and answers whether the chunk has now earned a build. True means build and
+ /// serve; false means return Fallthrough_NotBuilt and let the caller take the slow path.
+ ///
+ /// A miss only counts once per Find (see ). With no Find open
+ /// — a direct caller, or a bake — every call counts separately.
///
private bool ShouldPromoteAfterMiss(long chunkKey)
{
- // Environment.TickCount, not Core.TickCount: tests/bench fixtures may not advance
- // the game-loop tick. The promotion window is wall-clock anyway.
+ // Environment.TickCount rather than Core.TickCount: the window is wall-clock, and test and
+ // benchmark fixtures don't necessarily advance the game loop's tick.
var now = (uint)Environment.TickCount;
var gen = CurrentFindGeneration;
@@ -743,10 +704,10 @@ public sealed class StepCache
}
///
- /// Drop tracker entries older than the promotion window. Called when the tracker hits
- /// its capacity ceiling. If the prune doesn't reclaim anything (every entry is in
- /// window), the cap is enforced by clearing — the worst case is a few extra
- /// Fallthrough_NotBuilt returns until traffic re-establishes hot chunks.
+ /// Drops tracker entries that have aged out of the promotion window, once the tracker hits its
+ /// capacity ceiling. When nothing has aged out, the whole tracker is cleared to enforce the cap
+ /// — that costs a few extra Fallthrough_NotBuilt returns while traffic re-establishes the hot
+ /// chunks, which is cheaper than letting the tracker grow without bound.
///
private void PruneMissTracker(uint now)
{
@@ -780,17 +741,15 @@ public sealed class StepCache
var baseX = chunkX << 4;
var baseY = chunkY << 4;
- // Tier 4 strata accumulator. Both the offset table and the packed data buffer are
- // created lazily on the first multi-Z cell; single-Z chunks (the vast majority) never
- // touch the pool. strataData is a rented scratch buffer — the chunk gets an exact-size
- // copy at the end, so the pooled array never escapes this method.
+ // Strata accumulator, created on the first multi-Z cell so single-Z chunks pay nothing.
+ // strataData is rented scratch; the chunk receives an exact-size copy, so the pooled array
+ // never escapes this method.
ushort[] strataOffsetByCell = null;
byte[] strataData = null;
var strataLen = 0;
- // Reused per cell: the standable surface Zs (walkway / bridge / floor levels). 16 is
- // generous — clearance forces standable surfaces >= PersonHeight apart, so a 256-tall
- // Z range admits at most ~16 anyway.
+ // Standable surface Zs for the current cell. 16 slots is generous: clearance forces
+ // surfaces at least PersonHeight apart, so an sbyte Z range can't hold more than ~16.
Span surfaceZs = stackalloc sbyte[16];
for (var dy = 0; dy < ChunkSize; dy++)
@@ -803,16 +762,13 @@ public sealed class StepCache
map.GetAverageZ(x, y, out _, out var avgZ, out _);
- // Anchor the cell at the surface a creature actually STANDS on, not the land
- // average. For plain overworld that's the land; for static-over-land terrain
- // (sewer/dungeon walkways, bridges, stair treads, raised foundations, upper
- // building floors) it's the walkable static surface — which the old
- // ComputeStandingZ(avgZ) anchor missed, producing source-Z fallthroughs (or,
- // within the StepHeight tolerance band on stairs, a wrong vertical-neighbor
- // answer baked at the adjacent tread). ComputeStandableSurfaceZs returns the
- // standable surfaces ascending; the lowest is the primary anchor and A* tracks
- // newZ to match it. Cells with no standable walk surface (deep water, solid
- // rock) fall back to the land avg so the swim layer / wetMask still bake.
+ // Anchor the cell at the surface a creature stands on, not the land average. On
+ // open terrain those coincide, but on static-over-land geometry — walkways,
+ // bridges, stair treads, upper floors — the walkable surface is the static, and
+ // anchoring at the land below it would make every query fall through the source-Z
+ // guard. Surfaces come back ascending and the lowest is the anchor; A* tracks its
+ // per-cell Z to match. A cell with no standable surface at all (deep water, solid
+ // rock) falls back to the land average so its swim data still bakes.
var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaceZs);
var standingZ = surfaceCount > 0 ? surfaceZs[0] : (sbyte)Math.Clamp(avgZ, sbyte.MinValue, sbyte.MaxValue);
@@ -838,15 +794,12 @@ public sealed class StepCache
chunk.SwimZW[cell] = result.SwimZ_W;
chunk.SwimZNW[cell] = result.SwimZ_NW;
- // Shore-cell handling: if the cell has BOTH a walk surface (standing Z)
- // AND a water surface (Wet land tile or wet static) at a Z separated by
- // > StepHeight, populate the swim layer at swim-perspective Z. Only when
- // ComputeMaskAt produces a non-zero swim mask — bridges/docks/piers with
- // insufficient vertical clearance for a swim creature's body envelope
- // produce wetMask=0 (StaticsBlockAt rejects them), and we skip those cells
- // rather than baking a stratum that always answers "no movement." The
- // sentinel NoSwimLayerCell stays in SwimSourceZ for skipped cells; the
- // chunk only sets HasSwimLayer when at least one cell got a usable entry.
+ // Shore cell: a walkable surface and a water surface more than StepHeight apart.
+ // The main mask is baked at the walk surface, so a swimmer querying from the water
+ // would fail the source-Z guard; bake it a second answer from the water surface.
+ // An empty swim mask means the water is unreachable anyway — a dock or pier with
+ // too little clearance for a swimmer's body — so leave those cells at the
+ // NoSwimLayerCell sentinel rather than store an answer that always says "blocked".
var swimZRaw = StepProbe.ComputeSwimStandingZ(map, x, y);
if (swimZRaw != int.MinValue && Math.Abs(swimZRaw - standingZ) > StepHeight)
{
@@ -871,29 +824,24 @@ public sealed class StepCache
}
}
- // Stacked walkable surfaces at one cell (ground + 1st + 2nd building floors,
- // a bridge over a walkable path, etc.): bake a stratum per standable surface
- // so a query at any floor's Z hits. The primary (lowest) surface is also in
- // the main mask above, but multi-Z cells are served exclusively from strata,
- // so every standable surface — including the primary — must appear here.
- // Single-surface cells (the common case, incl. stair treads and sewer
- // walkways) skip this entirely and stay on the fast single-mask path.
+ // Stacked walkable surfaces — a bridge over a path, the floors of a building —
+ // need one stratum each so a query at any of their Zs finds an answer. Every
+ // surface goes in, including the lowest, because a multi-Z cell is served only
+ // from its strata and never from the main mask baked above.
if (surfaceCount >= 2)
{
if (strataOffsetByCell == null)
{
strataOffsetByCell = new ushort[StepChunk.CellsPerChunk];
strataOffsetByCell.AsSpan().Fill(StepChunk.NoStrata);
- // Offsets are ushort and NoStrata takes ushort.MaxValue, so a reachable
- // offset is at most NoStrata - 1 and the packed data can never exceed
- // NoStrata bytes. Renting that much up front means the guard below is
- // the only bound the writes need.
+ // NoStrata bounds the packed data to NoStrata bytes (see StepChunk), so
+ // renting that much up front leaves the record guard below as the only
+ // bound the writes need.
strataData = STArrayPool.Shared.Rent(StepChunk.NoStrata);
}
- // A record is one count byte plus surfaceCount strata. Skip the cell if it
- // doesn't fit — it keeps the land-anchored main mask and falls through
- // off-surface. Well above realistic per-chunk strata volume either way.
+ // One count byte plus a record per surface. A cell whose record won't fit stays
+ // single-Z: it keeps the main mask and falls through off its anchor surface.
var recordLength = 1 + surfaceCount * StepChunk.StratumByteLength;
if (strataLen + recordLength <= StepChunk.NoStrata)
{
@@ -920,10 +868,10 @@ public sealed class StepCache
}
///
- /// Tier 4 strata lookup. Walks the cell's stratum list, returns true with the first
- /// stratum whose zCenter is within StepHeight of .
- /// Layout matches : u8 count, then count × 19-byte
- /// stratum (sbyte zCenter, byte walkMask, byte wetMask, 8 sbyte walkZ, 8 sbyte swimZ).
+ /// Finds the cell's stratum matching — the first whose zCenter is
+ /// within StepHeight — and builds its mask. False when the cell has no strata or none of them
+ /// sit near enough, in which case the caller falls through. Reads the layout
+ /// writes.
///
private static bool TryStratumHit(
StepChunk chunk, int cellIndex, sbyte sourceZ, CacheHitKind hitKind, out StepMask result
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
index 8cbc5c51f..bfebae6b9 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
@@ -9,40 +9,36 @@ using Server.Compression;
namespace Server.Engines.Pathing.Cache;
///
-/// Binary serializer + lazy reader for the step cache. Persists chunk records to disk
-/// so a server warm-starts without paying chunk-build cost on the first pathfind through
-/// a region. Lazy: opening a file reads only the header + chunk-offset index (~few KB
-/// for tens of thousands of chunks), then individual chunks are seeked + deserialized
-/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
-/// of file size.
+/// Binary serializer and reader for the step cache, so a shard can warm-start instead of building
+/// chunks on the first pathfind through each region. Opening a file reads only the header and chunk
+/// index; a chunk record is seeked and inflated when the cache actually asks for it, which keeps
+/// resident memory bounded by MaxResidentChunks no matter how large the file is.
///
-/// File layout v8 (little-endian, BufferWriter / BufferReader convention):
+/// File layout (little-endian, BufferWriter / BufferReader convention):
///
/// Header (40 bytes):
/// u32 Magic = 0x42575300 ('SWB\0')
-/// u32 Version = current FormatVersion (9)
+/// u32 Version = FormatVersion
/// u32 MapId
-/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
-/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
-/// Rejects a load when EITHER tile flags shifted (client patch)
-/// OR the map data was rewritten (CentredSharp / UOFiddler edit).
-/// The .mul format has no built-in CRC; this is the only way
-/// to detect those mutations.
-/// u64 BakeTimestamp DateTime.UtcNow.Ticks at write time (informational).
+/// u64 Fingerprint XxHash3 over tiledata.mul and the map's own .mul / .uop files.
+/// Detects both a client patch that shifts tile flags and a map edit
+/// that rewrites the terrain; see ComputeFingerprint. The .mul format
+/// carries no CRC of its own, so hashing is the only way to catch either.
+/// u64 BakeTimestamp DateTime.UtcNow.Ticks at write time. Informational.
/// u32 ChunkCount
-/// u64 IndexOffset File position where the chunk index begins.
+/// u64 IndexOffset Where the index trailer begins.
///
/// Per chunk (ChunkCount times, variable size):
/// u32 UncompressedLen Size of the inflated record body below.
-/// byte[] Payload The record body (the v6 layout that follows), libdeflate-
-/// compressed. If the on-disk payload length (index recordLength −
-/// 4) equals UncompressedLen, the body was stored raw because
-/// compression did not shrink it (tiny Uniform records).
+/// byte[] Payload The record body, libdeflate-compressed — or stored raw when
+/// compression didn't shrink it, as happens with tiny Uniform
+/// records. The reader tells the two apart by comparing the payload
+/// length against UncompressedLen.
///
-/// Record body (after inflate — the v6 layout):
+/// Record body (after inflate):
/// u16 ChunkX
/// u16 ChunkY
-/// u32 BuiltMultisVersion (reserved since v9 — always 0; chunks are static-only)
+/// u32 BuiltMultisVersion Reserved, always 0 — chunks are static-only.
/// u8 Kind 0 = Full; 2 = Uniform
/// // Uniform (Kind == 2): ~28-byte record — all 256 cells share these single values:
/// byte walkMask, wetMask; sbyte sourceZ; sbyte walkZ_N..NW (8); sbyte swimZ_N..NW (8)
@@ -78,34 +74,29 @@ namespace Server.Engines.Pathing.Cache;
/// The file offset is not stored — reconstructed as a cumulative sum of recordLength
/// starting at HeaderSize (the first record sits immediately after the header).
///
-/// Per-chunk fixed portion (Kind + flags + ZArrayMask + WalkMask + WetMask + SourceZ):
-/// ~783 bytes; each present base Z array adds 256 bytes (0..16 present, so up to ~4 KB).
-/// A fully-flat Full chunk stores no residual blocks. Strata trailer: 516 + N × ~30 bytes
-/// for a chunk with N multi-Z cells averaging ~2 strata each. LRU bookkeeping
-/// (LastTouchedTicks) is intentionally not persisted.
+/// A chunk's fixed portion runs ~783 bytes, and each directional-Z array that survives prediction
+/// adds 256 more, so a Full record lands between ~783 bytes and ~4 KB. The strata trailer adds
+/// 516 bytes plus roughly 30 per multi-Z cell. LastTouchedTicks is deliberately not persisted —
+/// LRU state means nothing across a restart.
///
-/// Files with version < are silently rejected
-/// at open time (treated as missing) and overwritten on the next save.
+/// Files below are treated as missing and overwritten on the
+/// next save. The cache regenerates from the map data, so a format bump only costs a one-time
+/// re-bake.
///
internal static class StepCacheFile
{
public const uint Magic = 0x42575300; // 'SWB\0'
- // v9: chunks are STATIC-ONLY (land + statics.mul, no multis). v8 and earlier baked multis
- // (houses/boats) into chunks, which is unsafe to persist — multis are dynamic, and the
- // BuiltMultisVersion they were tagged with is a non-persisted session counter. Bumping the
- // version rejects those old files so they re-bake static-only. The BuiltMultisVersion record
- // field is retained as a reserved (always-0) u32 to avoid a layout change.
public const uint FormatVersion = 9;
///
- /// Lowest format version this binary can load. Files below it are treated as missing
- /// (silently rejected) and overwritten by the next SaveToFile / BakeMap. The cache is
- /// fully regenerable, so a format bump just forces a one-time re-bake of stale files.
+ /// Oldest format this binary will load. Anything older is treated as missing rather than
+ /// migrated: the cache is fully regenerable from the map data, so a re-bake is always
+ /// available and always correct.
///
public const uint MinSupportedVersion = 9;
- // Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved.
+ // Record discriminator. 1 is reserved.
private const byte KindFull = 0;
private const byte KindUniform = 2;
@@ -118,12 +109,12 @@ internal static class StepCacheFile
+ sizeof(uint) // ChunkCount
+ sizeof(ulong); // IndexOffset
- // Index entry (v8 compact): u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength.
- // The file offset is NOT stored — entries are in record write order, so the reader
- // reconstructs each offset by cumulative sum of record lengths starting at HeaderSize.
+ // One index entry: u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength. The file offset
+ // isn't stored — entries sit in record write order, so the reader rebuilds each offset as a
+ // running sum of the lengths before it, starting at HeaderSize.
private const int IndexEntryBytes = sizeof(uint) + sizeof(uint);
- /// Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).
+ /// A chunk record minus its optional strata and swim trailers.
private const int BytesPerChunkBase =
sizeof(ushort) + sizeof(ushort) + sizeof(uint)
+ sizeof(byte) + sizeof(byte) + sizeof(byte) // Kind + HasStrata + HasSwimLayer
@@ -135,17 +126,16 @@ internal static class StepCacheFile
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
///
- /// Byte offset of the IndexOffset u64 within the header
- /// (Magic+Version+MapId+Fingerprint+BakeTimestamp+ChunkCount = 32). Patched after chunks land.
+ /// Where the header's IndexOffset u64 sits. It's written as a placeholder and patched once the
+ /// chunks are down and the index position is known.
///
private const int IndexOffsetFieldPosition = 32;
public delegate bool ChunkEnumerator(out int chunkX, out int chunkY, out StepChunk chunk);
///
- /// Peek at a .swb file's Fingerprint field (header byte offset 12) without
- /// reading any chunk data. Returns false on missing file, bad magic, or wrong
- /// version. Cheap — reads 20 bytes total.
+ /// Reads just a .swb file's fingerprint — 20 bytes, no chunk data. False if the file is
+ /// missing, isn't a .swb, or is a version this binary can't load.
///
public static bool TryReadFingerprint(string path, out ulong fingerprint)
{
@@ -183,31 +173,28 @@ internal static class StepCacheFile
}
///
- /// Combined XxHash3 fingerprint over (1) the on-disk tiledata.mul file and (2) the
- /// per-map .mul / .uop file contents (via ).
- /// Bake files carry this hash so a load can refuse to populate the cache when EITHER the
- /// tile data shifted (client patch) OR the map data was rewritten (CentredSharp / UOFiddler
- /// edit). The .mul format has no built-in CRC; this is the only way to detect those mutations.
+ /// Hashes the inputs a bake depends on: tiledata.mul and the map's own .mul / .uop
+ /// files. A file carrying a stale hash is refused at open time, which is what catches a client
+ /// patch that shifts tile flags or a map editor that rewrites the terrain. Neither format has a
+ /// CRC of its own, so hashing is the only signal available.
///
- /// IMPORTANT: hash the FILES, never the in-memory /
+ /// This must hash the FILES, never the in-memory /
/// . The server patches those tables at runtime (ItemFixes,
- /// LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a fingerprint over
- /// the live tables varies with WHEN it is taken; the file hash is the only lifecycle-stable
- /// "did the client's tile data change?" signal. Server-side tile patches are applied identically
- /// every boot and intentionally do NOT invalidate the cache — change one and you must
- /// [PathCacheClear or bump the format.
+ /// LOSBlocker, PotionKeg, CTF), so a hash of the live tables changes depending on when it is
+ /// taken — useless as a fingerprint. Those server-side patches apply identically every boot and
+ /// deliberately do NOT invalidate the cache; if you change one, run [PathCacheClear or bump
+ /// yourself.
///
public static ulong ComputeFingerprint(int mapId)
{
var hasher = HashUtility.CreateXxHash3();
- // (1) tiledata.mul — hashed once, cached. The authoritative source for tile flags/heights.
Span tileDataBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(tileDataBytes, TileDataFileFingerprint());
hasher.Append(tileDataBytes);
- // (2) Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already
- // streamed them through XxHash3 once at construction; mix the result in.
+ // TileMatrix already streamed the map files through XxHash3 when it was built; reuse that
+ // rather than re-reading them.
var map = Map.Maps[mapId];
if (map != null && map != Map.Internal && map.Tiles != null)
{
@@ -223,10 +210,9 @@ internal static class StepCacheFile
private static bool _tileDataFileFingerprintComputed;
///
- /// XxHash3 over the raw tiledata.mul bytes, computed once and cached — the file never
- /// changes during a run. Mirrors for the map
- /// files. Returns 0 if the file can't be found (the server can't run without it anyway, so
- /// this only matters in stripped test hosts, where 0 is a fine deterministic constant).
+ /// XxHash3 of the raw tiledata.mul bytes, computed once — the file can't change while
+ /// the server runs. Returns 0 when the file is absent, which only happens in stripped test
+ /// hosts; a real server can't boot without it, and 0 is a fine deterministic stand-in.
///
private static ulong TileDataFileFingerprint()
{
@@ -257,10 +243,9 @@ internal static class StepCacheFile
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
- // Initial estimate: base record + a modest strata budget per chunk. Coastline
- // chunks add another ~2.5 KB (swim layer) but they're a small fraction of any
- // map; the writer grows on overflow so under-estimating just causes a few
- // realloc/copy cycles during the bake — not a correctness issue.
+ // A rough estimate: the base record plus a small strata budget per chunk. Coastline chunks
+ // run ~2.5 KB over it for their swim layer, but they're a small share of any map, and the
+ // writer grows on overflow — under-estimating costs a few reallocs during a bake, nothing more.
var capacity = HeaderSize + (BytesPerChunkBase + 256) * (int)chunkCount + IndexEntryBytes * (int)chunkCount;
var buffer = new byte[capacity];
var w = new BufferWriter(buffer, prefixStr: false);
@@ -273,8 +258,8 @@ internal static class StepCacheFile
w.Write(chunkCount);
w.Write(0UL); // IndexOffset placeholder, patched after chunks
- // Each record is built uncompressed into recordScratch, then libdeflate-compressed into
- // compScratch and framed as [u32 uncompressedLen][payload].
+ // Each record is built into recordScratch, compressed into compScratch, then framed as
+ // [u32 uncompressedLen][payload].
var packer = Deflate.Maximum;
var recordScratch = new byte[BytesPerChunkBase + 1024];
var compScratch = new byte[packer.MaxPackSize(recordScratch.Length)];
@@ -306,16 +291,14 @@ internal static class StepCacheFile
var indexOffset = (ulong)w.Position;
for (var i = 0u; i < chunkCount; i++)
{
- // v8 compact entry: u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength.
- // Offset is omitted; entries are in record write order so the reader derives it.
var key = indexEntries[i].key;
var packedKey = ((uint)(key >> 32) << 16) | (uint)(key & 0xFFFF);
w.Write(packedKey);
w.Write(indexEntries[i].length);
}
- // Patch IndexOffset on the writer's current backing buffer (BufferWriter may
- // have grown during chunk writes; the original `buffer` ref is stale after grow).
+ // Patch IndexOffset on the writer's CURRENT buffer: BufferWriter may have grown during the
+ // chunk writes, which leaves the original `buffer` reference pointing at a stale array.
var liveBuffer = w.Buffer;
BinaryPrimitives.WriteUInt64LittleEndian(liveBuffer.AsSpan(IndexOffsetFieldPosition, 8), indexOffset);
@@ -324,9 +307,9 @@ internal static class StepCacheFile
}
///
- /// Opens a .swb file and reads only its header + chunk-offset index. Returns null on
- /// missing file, magic / version mismatch, or Fingerprint mismatch (a stale bake
- /// against a freshly patched client). Callers own disposal of the returned reader.
+ /// Opens a .swb file, reading only its header and chunk index. Null if the file is missing,
+ /// isn't a loadable .swb, or is a stale bake whose fingerprint no longer matches the live tile
+ /// and map data. The caller owns the returned reader.
///
public static LazyReader OpenForLazy(string path)
{
@@ -361,8 +344,6 @@ internal static class StepCacheFile
var version = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[4..]);
if (version < MinSupportedVersion || version > FormatVersion)
{
- // Below the minimum supported version: treat as missing. Older files
- // get silently overwritten on the next SaveToFile / BakeMap.
stream.Dispose();
return null;
}
@@ -379,7 +360,7 @@ internal static class StepCacheFile
return null;
}
- // Read the chunk-offset index in one shot.
+ // Pull the whole index in one read.
var indexBytes = (int)chunkCount * IndexEntryBytes;
var indexBuf = new byte[indexBytes];
stream.Position = (long)indexOffset;
@@ -389,9 +370,8 @@ internal static class StepCacheFile
return null;
}
- // v8 compact index: { u32 packedKey, u32 length } per chunk, in record write order.
- // The file offset is not stored — reconstruct it by cumulative record length starting
- // at the first record (immediately after the header).
+ // Entries are in record write order and carry no offset, so rebuild each one as a
+ // running sum of the record lengths, starting just past the header.
var offsets = new Dictionary((int)chunkCount);
var runningOffset = (ulong)HeaderSize;
for (var i = 0; i < chunkCount; i++)
@@ -416,27 +396,27 @@ internal static class StepCacheFile
private static ulong PackChunkKey(int chunkX, int chunkY) => ((ulong)(uint)chunkX << 32) | (uint)chunkY;
///
- /// Predicted directional-Z for one cell/direction: the cell's own SourceZ when the
- /// direction is walkable/wet (mask bit set), else 0 — matching the baker, which leaves
- /// non-walkable directional slots at their zero-initialized default
- /// (StepProbe.ComputeMaskAt clears walkZs/swimZs and writes only on a successful step).
+ /// Guesses a cell's destination Z for one direction: on flat ground a step lands at the Z you
+ /// left from, so predict SourceZ where the direction is passable and 0 where it isn't. The
+ /// zero matches the baker, which only writes a slot on a successful step and leaves the rest
+ /// cleared. Most terrain is flat, so most predictions are exact and most residuals are 0 —
+ /// which is what makes the residual arrays compress away to nothing.
///
internal static sbyte Predict(byte dirMaskByte, int bit, sbyte sourceZ) =>
(dirMaskByte >> bit & 1) != 0 ? sourceZ : (sbyte)0;
///
- /// Residual of an absolute directional-Z against its prediction. Unchecked two's-complement
- /// so the transform is byte-exact for ALL sbyte inputs (no value-range constraint).
+ /// A destination Z's difference from its prediction. Wraps deliberately: two's-complement
+ /// round-trips exactly for every sbyte input, so no value range is off-limits.
///
internal static sbyte EncodeResidual(sbyte z, sbyte predict) => unchecked((sbyte)(z - predict));
- /// Inverse of : absolute directional-Z = predict + residual.
+ /// Inverse of .
internal static sbyte DecodeZ(sbyte predict, sbyte residual) => unchecked((sbyte)(predict + residual));
///
- /// The base directional-Z array for direction index d in canonical order: walk N..NW (0-7),
- /// then swim N..NW (8-15). Index d uses WalkMask (d < 8) or WetMask (d >= 8) with
- /// direction bit (d & 7).
+ /// The destination-Z array for direction index d, in the canonical order the format stores them:
+ /// walk N..NW as 0-7, then swim N..NW as 8-15.
///
private static sbyte[] GetBaseZArray(StepChunk c, int d) => d switch
{
@@ -448,10 +428,9 @@ internal static class StepCacheFile
};
///
- /// Builds the uncompressed v6 record for one chunk into , libdeflate-
- /// compresses it, and writes it framed as [u32 uncompressedLen][payload]. The payload is the
- /// compressed bytes, or — when compression does not shrink the record (tiny Uniform records) —
- /// the raw record itself; the reader distinguishes the two by payload length vs uncompressedLen.
+ /// Builds one chunk's record, compresses it, and frames it as [u32 uncompressedLen][payload].
+ /// When compression fails to shrink the record — as it does on the tiny Uniform ones — the raw
+ /// record is stored instead, and the reader tells the two apart by payload length.
///
private static void WriteChunk(
BufferWriter w, int chunkX, int chunkY, StepChunk chunk,
@@ -460,7 +439,7 @@ internal static class StepCacheFile
{
var rw = new BufferWriter(recordScratch, prefixStr: false);
BuildRecord(rw, chunkX, chunkY, chunk);
- recordScratch = rw.Buffer; // may have grown; keep the larger buffer for reuse
+ recordScratch = rw.Buffer; // may have grown; hold onto the larger buffer for the next chunk
var recordLen = (int)rw.Position;
var bound = packer.MaxPackSize(recordLen);
@@ -478,8 +457,8 @@ internal static class StepCacheFile
}
else
{
- // Incompressible (or expanded): store the record raw. The reader detects this when
- // the on-disk payload length equals the uncompressed length.
+ // Compression didn't help, so store the record raw. Payload length == uncompressedLen
+ // is how the reader recognizes that.
w.Write(recordScratch.AsSpan(0, recordLen));
}
}
@@ -490,8 +469,8 @@ internal static class StepCacheFile
w.Write((ushort)chunkY);
w.Write((uint)chunk.BuiltMultisVersion);
- // Kind: 0 = Full, 2 = Uniform. A uniform chunk (no strata, no swim layer, all 19 base
- // arrays constant) stores one cell's worth of data (~28-byte record total).
+ // A uniform chunk — every cell identical — collapses to one cell's worth of data, ~28 bytes.
+ // Open water and solid rock make up a lot of a map, so this is worth the branch.
if (chunk.IsUniform())
{
w.Write(KindUniform);
@@ -517,7 +496,7 @@ internal static class StepCacheFile
return;
}
- w.Write(KindFull); // Full
+ w.Write(KindFull);
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
var strataData = chunk.GetStrataDataForSerialization();
@@ -526,9 +505,9 @@ internal static class StepCacheFile
w.Write((byte)(hasStrata ? 1 : 0));
w.Write((byte)(hasSwimLayer ? 1 : 0));
- // Predictive-Z: each base directional Z array is stored as a masked residual against
- // SourceZ. Bit d of ZArrayMask is set only when array d differs from its prediction
- // somewhere; cleared arrays are omitted and rebuilt from mask+SourceZ at read.
+ // Each destination-Z array is stored as residuals against its prediction (see Predict). An
+ // array that matches its prediction everywhere — the common case on flat terrain — is
+ // omitted entirely, and its ZArrayMask bit stays clear so the reader synthesizes it.
ushort zArrayMask = 0;
for (var d = 0; d < 16; d++)
{
@@ -583,7 +562,6 @@ internal static class StepCacheFile
if (hasStrata)
{
- // 256 × u16 offsets, then u32 length-prefixed strata byte array.
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
w.Write(strataOffsetByCell[i]);
@@ -600,7 +578,7 @@ internal static class StepCacheFile
private static StepChunk ReadChunk(byte[] buffer)
{
var r = new BufferReader(buffer);
- // Skip ChunkX + ChunkY (already known via the index lookup).
+ // ChunkX + ChunkY — already known from the index lookup that got us here.
r.ReadUShort();
r.ReadUShort();
var multisVersion = (int)r.ReadUInt();
@@ -608,7 +586,7 @@ internal static class StepCacheFile
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
- if (kind == KindUniform) // Uniform — one cell's worth of the 19 base arrays, fill all 256 cells.
+ if (kind == KindUniform) // one cell's values, broadcast to all 256
{
Array.Fill(chunk.WalkMask, r.ReadByte());
Array.Fill(chunk.WetMask, r.ReadByte());
@@ -640,8 +618,8 @@ internal static class StepCacheFile
r.Read(chunk.WetMask);
ReadSBytes(r, chunk.SourceZ);
- // Predictive-Z reconstruction: present arrays carry residuals (z = predict + residual);
- // absent arrays are synthesized from mask+SourceZ (z = predict, residual implicitly 0).
+ // Inverse of the write path: a stored array carries residuals to add back to the
+ // prediction, an omitted one IS the prediction.
Span residual = stackalloc sbyte[StepChunk.CellsPerChunk];
for (var d = 0; d < 16; d++)
{
@@ -706,16 +684,15 @@ internal static class StepCacheFile
r.Read(MemoryMarshal.Cast(arr.AsSpan()));
///
- /// Open handle on a .swb file. Holds the FileStream + chunk-offset index. Chunks are
- /// fetched on demand via ; only the records actually queried
- /// are ever materialized. Dispose releases the underlying stream.
+ /// An open .swb file: the stream plus the chunk index. Only the records actually asked for are
+ /// ever read or inflated. Dispose releases the stream.
///
internal sealed class LazyReader : IDisposable
{
private FileStream _stream;
private readonly Dictionary _offsets;
- private byte[] _buffer; // raw on-disk record: [u32 uncompressedLen][payload]
- private byte[] _bodyBuffer; // decompressed v6 record, parsed by ReadChunk
+ private byte[] _buffer; // the raw framed record as it sits on disk
+ private byte[] _bodyBuffer; // that record, inflated, ready for ReadChunk
public uint MapId { get; }
public ulong Fingerprint { get; }
@@ -725,11 +702,7 @@ internal static class StepCacheFile
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
- ///
- /// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by
- /// when preload is enabled to materialize all chunks
- /// upfront instead of on first query.
- ///
+ /// Every (chunkX, chunkY) the file holds. Used to preload the whole file.
public IEnumerable<(int chunkX, int chunkY)> EnumerateChunkCoords()
{
foreach (var key in _offsets.Keys)
@@ -754,9 +727,8 @@ internal static class StepCacheFile
}
///
- /// Returns the chunk record at (, )
- /// from the file, or null if the file doesn't contain it. Single seek + bulk read,
- /// sized exactly to the chunk's recorded length (which varies with strata size).
+ /// Reads one chunk from the file, or null if the file has no record for it. One seek and
+ /// one read, sized to the record's indexed length.
///
public StepChunk TryReadChunk(int chunkX, int chunkY)
{
@@ -771,7 +743,6 @@ internal static class StepCacheFile
return null;
}
- // Grow the on-disk scratch buffer if this chunk's record is larger than what we have.
if (entry.length > _buffer.Length)
{
_buffer = new byte[entry.length];
@@ -784,8 +755,8 @@ internal static class StepCacheFile
return null;
}
- // Frame: [u32 uncompressedLen][payload]. payload is libdeflate-compressed, unless its
- // length equals uncompressedLen, in which case it was stored raw (incompressible).
+ // [u32 uncompressedLen][payload], where the payload is compressed unless its length
+ // already equals uncompressedLen — then it was stored raw.
var uncompressedLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(_buffer);
var payloadLen = (int)entry.length - sizeof(uint);
if (_bodyBuffer.Length < uncompressedLen)
@@ -799,7 +770,8 @@ internal static class StepCacheFile
}
else
{
- // Decompression is level-independent, so reuse the shared per-thread binding.
+ // Deflate.Standard, not .Maximum: the level only affects packing, and inflate has
+ // to accept whatever the writer produced regardless.
var result = Deflate.Standard.Unpack(
_bodyBuffer.AsSpan(0, uncompressedLen),
_buffer.AsSpan(sizeof(uint), payloadLen),
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs
index 3d26aee5d..80d42f8e0 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs
@@ -3,20 +3,19 @@ using System;
namespace Server.Engines.Pathing.Cache;
///
-/// Per-chunk storage backing StepCache. Holds raw walk + swim masks and destination Z
-/// values for each of 256 cells in a 16x16 chunk, plus build-time metadata (multis version, multi-Z strata)
-/// and LRU bookkeeping.
+/// Per-chunk storage backing : walk + swim masks and destination Zs for
+/// each of the 256 cells in a 16x16 chunk, plus the optional multi-Z strata and swim layers and
+/// the LRU timestamp.
///
internal sealed class StepChunk
{
public const int CellsPerChunk = 256; // 16 x 16
- /// Bit i of WalkMask[c] = "default walker can step from cell c to neighbor (Direction)i".
- /// Raw — no diagonal corner-cut applied here.
+ /// Bit i of WalkMask[c]: a default walker can step from cell c to neighbour (Direction)i.
+ /// Raw — no diagonal corner-cut applied, so callers must AND the partner bits themselves.
public readonly byte[] WalkMask = new byte[CellsPerChunk];
- /// Bit i of WetMask[c] = "swim-only mob can step from cell c to neighbor (Direction)i".
- /// Layered with WalkMask via canSwim/cantWalk capability flags.
+ /// Bit i of WetMask[c]: a swim-only mob can step from cell c to neighbour (Direction)i.
public readonly byte[] WetMask = new byte[CellsPerChunk];
public readonly sbyte[] SourceZ = new sbyte[CellsPerChunk];
@@ -40,14 +39,13 @@ internal sealed class StepChunk
public readonly sbyte[] SwimZNW = new sbyte[CellsPerChunk];
///
- /// Swim layer — populated only for chunks containing at least one shore cell (a cell
- /// with both a walkable land surface and a water surface separated by > StepHeight).
- /// On shore cells, queries from the swim source Z miss the primary source-Z guard;
- /// the swim layer carries the correct wetMask + per-direction destination Zs computed
- /// from the water surface's perspective. For non-shore cells in a chunk that has the
- /// layer, [cell] = sentinel.
- /// All swim-layer arrays are null on chunks with no shore cells (~90% of map chunks
- /// on Trammel) — zero memory cost on the common case.
+ /// Marks a cell with no swim-layer entry, in a chunk that has the layer.
+ ///
+ /// The swim layer exists only on chunks holding at least one shore cell — a cell with both a
+ /// walkable surface and a water surface more than StepHeight apart. A swim query there sits
+ /// too far from the primary SourceZ to pass the source-Z guard, so the layer carries a second
+ /// mask and destination-Z set computed from the water surface instead. Chunks with no shore
+ /// cells leave every swim-layer array null.
///
public const sbyte NoSwimLayerCell = sbyte.MinValue;
@@ -79,9 +77,8 @@ internal sealed class StepChunk
public sbyte[] SwimZNW_Layer => _swimZNW_extra;
///
- /// Lazily allocates the swim-layer arrays and seeds with
- /// the sentinel. Called at bake time the first time a
- /// shore cell is detected in this chunk.
+ /// Allocates the swim-layer arrays and seeds with
+ /// . Called on the first shore cell found in this chunk.
///
internal void AllocateSwimLayer()
{
@@ -105,28 +102,30 @@ internal sealed class StepChunk
}
}
- /// Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays.
+ ///
+ /// Marks a single-Z cell: no strata, read the main Walk/Wet arrays instead. Because this
+ /// takes ushort.MaxValue, a real strata offset is at most NoStrata - 1, which bounds
+ /// to NoStrata bytes.
+ ///
public const ushort NoStrata = ushort.MaxValue;
///
- /// Length-256 offset table: StrataOffsetByCell[cell] = byte offset into
- /// where this cell's strata begin, or
- /// for cells without multi-Z. Null when the chunk has zero multi-Z cells.
+ /// Length-256 table mapping a cell to the byte offset in where its
+ /// strata begin, or . Null when no cell in the chunk is multi-Z.
///
private ushort[] _strataOffsetByCell;
///
- /// Packed per-cell strata. For each cell with strata:
- /// u8 stratumCount, then stratumCount × Stratum (19 bytes each):
- /// sbyte zCenter, byte walkMask, byte wetMask,
- /// sbyte walkZ_N..NW (8), sbyte swimZ_N..NW (8)
+ /// Packed strata for the multi-Z cells. Per cell: u8 stratumCount, then stratumCount records
+ /// of bytes — sbyte zCenter, byte walkMask, byte wetMask,
+ /// sbyte walkZ_N..NW (8), sbyte swimZ_N..NW (8).
///
private byte[] _strataData;
- /// Snapshot of Sector.MultisVersion at the time this chunk was built.
+ /// Reserved. Chunks are static-only, so this is always 0.
public int BuiltMultisVersion;
- /// Updated on every cache hit/miss. Used by LRU fallback eviction.
+ /// Refreshed on every query that reaches this chunk. Drives LRU eviction.
public long LastTouchedTicks;
/// Size in bytes of one Stratum record in StrataData.
@@ -140,10 +139,8 @@ internal sealed class StepChunk
_strataData == null ? ReadOnlySpan.Empty : _strataData.AsSpan();
///
- /// Single-shot setter for the chunk's strata. Pass null/null to clear (chunk becomes
- /// "no multi-Z"). Otherwise must be length 256 with
- /// for cells without strata, and the
- /// packed strata records.
+ /// Sets the chunk's strata in one shot. must be length 256,
+ /// carrying for single-Z cells. Pass null/null to clear.
///
internal void SetStrata(ushort[] offsetByCell, byte[] data)
{
@@ -151,18 +148,17 @@ internal sealed class StepChunk
_strataData = data;
}
- /// Serialization hook: returns the raw offset array (or null if no strata).
+ /// Serialization hook: the raw offset array, or null if the chunk has no strata.
internal ushort[] GetStrataOffsetByCellForSerialization() => _strataOffsetByCell;
- /// Serialization hook: returns the raw data array (or null if no strata).
+ /// Serialization hook: the raw data array, or null if the chunk has no strata.
internal byte[] GetStrataDataForSerialization() => _strataData;
///
- /// True when every cell shares one value across WalkMask, WetMask, SourceZ, and all 16
- /// directional-Z arrays, and the chunk has neither multi-Z strata nor a swim layer. Such a
- /// chunk serializes to a ~28-byte uniform record (StepCacheFile v5) instead of the full
- /// record. Chunks with a swim layer (shore cells) are never uniform — their per-cell swim
- /// data must be preserved via the Full record.
+ /// True when all 256 cells share one value across WalkMask, WetMask, SourceZ and every
+ /// directional-Z array, with no strata and no swim layer — open water or solid rock, mostly.
+ /// collapses such a chunk to a ~28-byte record. A swim layer
+ /// disqualifies a chunk outright: its per-cell shore data would not survive the collapse.
///
internal bool IsUniform() => _strataOffsetByCell == null
&& !HasSwimLayer
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs
index 832f3d853..6817f9ae0 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepMask.cs
@@ -1,10 +1,10 @@
namespace Server.Engines.Pathing.Cache;
///
-/// Per-cell, per-direction static walkability data baked by
-/// and stored by . WalkMask + WalkZ_* applies under default-walker
-/// rules (cantWalk=false, canSwim=false). WetMask + SwimZ_* applies under swim-only rules
-/// (cantWalk=true, canSwim=true). Algorithms layer the right rules per mobile.
+/// Per-cell, per-direction walkability baked by and stored by
+/// . Two rule sets travel together: WalkMask + WalkZ_* for a default
+/// walker (cantWalk=false, canSwim=false), WetMask + SwimZ_* for a swim-only mob
+/// (cantWalk=true, canSwim=true). Callers overlay whichever applies to the mobile.
///
public readonly struct StepMask(
byte walkMask,
@@ -49,8 +49,8 @@ public readonly struct StepMask(
public readonly CacheHitKind HitKind = hitKind;
///
- /// True when the cache produced a usable answer (Hit / Miss_NotBuilt / Miss_DirtyRebuild).
- /// False on Fallthrough_*, in which case the caller must use the slow path for this cell.
+ /// True when the cache produced a usable answer. False on any Fallthrough_*, where the
+ /// payload is all zeroes and the caller must resolve this cell via the slow path.
///
public bool IsHit => HitKind <= CacheHitKind.Miss_DirtyRebuild;
diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
index e45fc602c..54a880b51 100644
--- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
+++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs
@@ -4,39 +4,37 @@ using CalcMoves = Server.Movement.Movement;
namespace Server.Engines.Pathing.Cache;
///
-/// Computes static-only walkability for a single cell — the per-cell, per-direction
-/// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT
-/// multis). Mirrors .Check minus the item and mobile collision
-/// phases. Multis (houses, boats) are intentionally excluded: they're dynamic content, so
-/// cells they cover route to the live movement path via 's
-/// multi-halo fallthrough rather than being baked into the static chunk cache.
+/// Computes the 8-direction "can step" mask and destination Zs for a single cell from land and
+/// statics alone. Mirrors .Check minus the item and mobile collision
+/// phases, which belong to the caller's dynamic-obstacle pass.
+///
+/// Multis (houses, boats) are excluded from the static bake because they are dynamic content;
+/// cells they cover route to the live movement path via 's multi halo.
+/// is the opt-in exception for those cells.
+///
+/// Each call bakes both rule sets: walker (canSwim=false, cantWalk=false) and swim-only
+/// (canSwim=true, cantWalk=true). Diagonal corner-cut is not applied — callers hold the partner
+/// bits in the same mask byte and combine them at query time.
///
-///
-/// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only
-/// (canSwim=true, cantWalk=true). Item / mobile collision phases are omitted (they're
-/// the dynamic-obstacle pass's job). Diagonal corner-cut is NOT applied here; callers
-/// must AND the partner-cell results at query time.
-///
public static class StepProbe
{
private const int PersonHeight = 16;
private const int StepHeight = 2;
///
- /// Writes the distinct surface Zs at (x, y) that a default walker (PersonHeight envelope)
- /// can actually STAND on — each candidate surface (walkable land center + every walkable
- /// static top) that has PersonHeight of vertical clearance free of impassable statics —
- /// into , ascending, and returns the count.
+ /// Writes the surface Zs at (x, y) a default walker can actually stand on into
+ /// , ascending, and returns the count. A candidate surface — the
+ /// walkable land centre, or any walkable static's top — qualifies only if a PersonHeight
+ /// envelope above it is clear of impassable statics.
///
- /// Clearance-aware: it drops surfaces a creature cannot occupy (land under a sewer walkway,
- /// ground under a low bridge), so the result is exactly the set of standing Zs the slow path
- /// can resolve to. Two standable surfaces are inherently >= PersonHeight apart (an upper
- /// surface within PersonHeight of a lower one removes the lower one's clearance), so a
- /// single ascending pass with an exact-duplicate skip is sufficient.
+ /// The clearance test is what makes this the exact set of standing Zs the slow path can
+ /// resolve to: it drops surfaces a creature cannot occupy, like the land beneath a sewer
+ /// walkway or a low bridge. That in turn means two surviving surfaces are always at least
+ /// PersonHeight apart (an upper surface any closer would have taken the lower one's
+ /// clearance away), so one ascending pass with a duplicate skip suffices.
///
- /// Used by the baker to capture walkable static-over-land surfaces (sewer/dungeon
- /// walkways, bridges, raised foundations, upper building floors) that the land-anchored
- /// main mask would otherwise miss.
+ /// The baker anchors each cell here so static-over-land geometry — walkways, bridges, raised
+ /// foundations, upper floors — bakes at the Z a creature stands on rather than the land average.
///
public static int ComputeStandableSurfaceZs(Map map, int x, int y, Span zs)
{
@@ -106,21 +104,17 @@ public static class StepProbe
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.
+ /// Multi-aware counterpart to , for cells a multi covers or
+ /// neighbours: folds house/boat component tiles into the same surface/step logic. Builds the
+ /// whole 8-direction mask in one pass, where the slow path would run CheckMovement eight times.
///
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).
+ /// Shared 8-direction mask builder behind and
+ /// . is the only difference:
+ /// it swaps the tile source to GetStaticAndMultiTiles so house and boat components participate.
///
private static StepMask ComputeMaskCore(Map map, int x, int y, sbyte sourceZ, bool includeMultis)
{
@@ -140,8 +134,8 @@ public static class StepProbe
byte wetMask = 0;
Span walkZs = stackalloc sbyte[8];
Span swimZs = stackalloc sbyte[8];
- // stackalloc is NOT zero-initialized — unwritten slots hold whatever was on the
- // stack. Clear before use; the loop only writes slots where the step succeeds.
+ // stackalloc is not zero-initialized, and the loop below writes a slot only where the
+ // step succeeds, so blocked directions would otherwise carry stack garbage.
walkZs.Clear();
swimZs.Clear();
@@ -178,10 +172,10 @@ public static class StepProbe
}
///
- /// Returns the slow path's standing-Z for a default walker at (x, y). Mirrors
- /// MovementImpl.Check's surface-selection — paver Z+1 for paver-over-ground,
- /// landCenter for bare land. Used by to bake SourceZ so
- /// A*'s tracked-per-cell Z matches the cache's bake-time assumption.
+ /// The standing-Z a default walker at (x, y) resolves to under the slow path's
+ /// surface-selection rules: paver Z+1 over paver-on-ground, land centre on bare land.
+ /// The baker anchors cells with instead, which is
+ /// clearance-aware; this remains the direct MovementImpl equivalent for parity checks.
///
public static int ComputeStandingZ(Map map, int x, int y, int locZ)
{
diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
index 14a41b6c0..f14886564 100644
--- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
+++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs
@@ -8,23 +8,23 @@ namespace Server.Engines.Pathing;
///
/// Admin commands for inspecting and operating the pathfinding step cache.
-/// [PathCacheStats — current resident-chunk count + hit/miss/eviction telemetry.
-/// [PathCacheClear — drop all cached chunks, close lazy readers, zero counters.
-/// [PathBake — walk a whole map building the full static cache, then save it.
-/// [PathCacheSave — persist resident chunks per map to Data/Pathfinding/<mapId>.swb.
-/// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup.
-/// [PathRecord — toggle JSONL telemetry capture for replay / benchmark corpora.
+/// [PathCacheStats — resident-chunk count and hit/miss/eviction telemetry.
+/// [PathCacheClear — drop all cached chunks, close the .swb readers, zero the counters.
+/// [PathBake — build a map's full static cache and save it.
+/// [PathCacheSave — persist the resident chunks to Data/Pathfinding/<mapId>.swb.
+/// [PathCacheLoad — open those files as backing stores. Also runs at startup.
+/// [PathRecord — toggle capture of pathfind telemetry.
///
-/// The step cache works WITHOUT any .swb file — chunks build on demand as creatures path.
-/// A baked .swb is an optional optimization that removes first-pathfind-after-boot latency
-/// for shard owners who want it; is how you produce one.
+/// None of this is required: the cache builds chunks on demand as creatures path, with or without
+/// a .swb on disk. Baking one is purely an optimization that trades disk and a few minutes of bake
+/// time for the removal of first-pathfind-after-boot latency.
///
public static class PathCacheCommands
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathCacheCommands));
- // modernuo.json flag: when true, Initialize() bakes any missing/stale .swb at startup.
- // The first-boot ConfigurePrompts() prompt writes it.
+ // When set, Initialize() bakes any missing or stale .swb at startup. ConfigurePrompts() asks
+ // for it on first boot.
private const string PrebakeSetting = "pathfinding.prebakeMaps";
private static string PathFor(int mapId) =>
@@ -32,9 +32,8 @@ public static class PathCacheCommands
public static void Configure()
{
- // Resident-chunk cap is shard-tunable. Default 8192 ≈ 40 MB; small shards may
- // want lower, large shards (or full-map bakes) may want higher. Setting is
- // written back to server.cfg on first boot for discoverability.
+ // Resident-chunk cap, shard-tunable — the default works out to roughly 40 MB. Written back
+ // to server.cfg on first boot so it's discoverable.
StepCache.Instance.MaxResidentChunks = ServerConfiguration.GetOrUpdateSetting(
"pathfinding.maxResidentChunks",
8192
@@ -52,13 +51,13 @@ public static class PathCacheCommands
}
///
- /// First-boot prompt, auto-invoked by AssemblyHandler.Invoke("ConfigurePrompts") in
- /// the startup sequence — after assemblies load (so content can prompt) but before Serilog
- /// starts, so the console prompt isn't interleaved with async log output. Offers to pre-bake
- /// the pathfinding .swb cache for the selected maps; the answer persists in
- /// modernuo.json (), so it's asked exactly once. Skipped when the
- /// setting already exists or when input is redirected (headless/CI) — operators can set the
- /// flag directly. The bake itself happens later in .
+ /// Asks, once, whether to pre-bake the .swb cache; does the work later.
+ /// The answer persists, so the question is never repeated, and it's skipped entirely when input
+ /// is redirected — a headless or CI boot sets directly instead.
+ ///
+ /// Runs in the ConfigurePrompts phase because that's the one window where content can prompt:
+ /// assemblies are loaded, but Serilog hasn't started, so console output won't interleave with
+ /// async log writes.
///
public static void ConfigurePrompts()
{
@@ -81,16 +80,15 @@ public static class PathCacheCommands
}
///
- /// Auto-invoked by AssemblyHandler.Invoke("Initialize") after the tile matrix and
- /// world are loaded. When is set, bakes any map whose
- /// .swb is missing or stale, so the first pathfind on each region is already warm. A
- /// fresh cache makes this a no-op, so only first boot — or a client/map update that changes
- /// the fingerprint — pays the cost.
+ /// Bakes any map whose .swb is missing or stale, when is
+ /// set. Runs in the Initialize phase, once the tile matrix and world are loaded. An up-to-date
+ /// cache makes it a no-op, so the cost lands only on a first boot or after a client or map
+ /// update moves the fingerprint.
///
- /// Validity is decided by : runs
- /// in the earlier Configure phase, opening (and fingerprint-
- /// validating) a reader for every up-to-date .swb. So a map with an open reader is
- /// already good and we skip it — no need to recompute the fingerprint a second time here.
+ /// A map is judged up-to-date by whether it has an open reader.
+ /// already ran in the earlier Configure phase and only opens a reader for a .swb whose
+ /// fingerprint validates, so an open reader is proof of a good bake — no need to fingerprint
+ /// the map a second time here.
///
public static void Initialize()
{
@@ -110,7 +108,7 @@ public static class PathCacheCommands
if (StepCache.Instance.HasLazyReader(map.MapID))
{
- continue; // AutoLoadAtStartup already opened a fingerprint-valid .swb for this map
+ continue; // already has a fingerprint-valid .swb open
}
var path = PathFor(map.MapID);
@@ -127,15 +125,14 @@ public static class PathCacheCommands
if (baked > 0)
{
logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked);
- AutoLoadAtStartup(); // (re)open the freshly written files as lazy backing stores
+ AutoLoadAtStartup(); // reopen what we just wrote
}
}
///
- /// Open Data/Pathfinding/<mapId>.swb as a lazy backing store for every map.
- /// Reads only the header + chunk-offset index up front (~16 bytes per chunk);
- /// individual chunk records are fetched on demand when the cache asks for them.
- /// RAM stays bounded by MaxResidentChunks regardless of file size.
+ /// Opens Data/Pathfinding/<mapId>.swb as a backing store for every map. Only the header and
+ /// index are read up front; chunk records are fetched as the cache asks for them, so resident
+ /// memory stays bounded by the LRU cap however large the files are.
///
private static void AutoLoadAtStartup()
{
@@ -196,9 +193,9 @@ public static class PathCacheCommands
continue;
}
- // BakeMap walks the whole map (building every chunk) and writes the .swb. The
- // chunks are left resident afterward; drop them so peak memory is bounded to one
- // map at a time and the post-command footprint returns to the LRU cap.
+ // BakeMap leaves every chunk it built resident. Drop them between maps so peak memory
+ // is one map's worth rather than all of them, and the footprint afterwards is back
+ // under the LRU cap.
var written = StepCache.Instance.BakeMap(map.MapID, PathFor(map.MapID));
StepCache.Instance.ClearResidentChunks();
@@ -218,8 +215,7 @@ public static class PathCacheCommands
return;
}
- // Reopen the freshly written files as lazy backing stores so they're usable now
- // without a restart (resident memory stays bounded by the LRU cap).
+ // Reopen what we just wrote, so the bake is usable immediately without a restart.
AutoLoadAtStartup();
from.SendMessage($"PathBake: {totalChunks} chunks across {totalMaps} map(s) in {sw.Elapsed.TotalSeconds:F1}s; lazy readers reopened.");
}
diff --git a/Projects/UOContent/Engines/Pathing/PathDiag.cs b/Projects/UOContent/Engines/Pathing/PathDiag.cs
index d115c3c6c..38bd36bc4 100644
--- a/Projects/UOContent/Engines/Pathing/PathDiag.cs
+++ b/Projects/UOContent/Engines/Pathing/PathDiag.cs
@@ -8,26 +8,19 @@ using Server.Targeting;
namespace Server.Engines.Pathing;
///
-/// Developer diagnostic for the bitmap A* step cache. Stand where a creature would start,
-/// run [PathDiag, and target the goal. The detailed report is appended to
-/// Logs/pathdiag.log; a short summary is sent to the invoking client. For the route
-/// it records:
-/// 1. the raw tile makeup of the start and goal cells (land + statics) and the
-/// clearance-aware standable surfaces the baker anchors to — the ground truth for
-/// "why does the cache (not) serve this cell";
-/// 2. one warm -served Find with the per-pathfind cache
-/// hit/fallthrough breakdown and fallthrough fraction — a high fallthrough fraction
-/// means the cache isn't helping the route (it pays the lookup then uses the slow path);
-/// 3. warm timing over many iterations.
+/// Diagnoses why the step cache does or doesn't serve a given route. Stand where the creature
+/// would start, run [PathDiag, target the goal; the full report lands in
+/// Logs/pathdiag.log and a summary goes to the client. It reports the tile makeup of the
+/// start and goal cells alongside the standable surfaces the baker anchors to, the cache
+/// hit/fallthrough breakdown for one warm Find, and warm timings.
///
-/// Primarily useful when bringing up custom maps / facets: it shows whether static-over-land
-/// geometry (dungeon walkways, bridges, stairs, raised foundations, stacked floors) is being
-/// baked at the right Z.
+/// The fallthrough fraction is the number to read: a high one means the cache is paying for a
+/// lookup on every cell and then taking the slow path anyway. That usually points at
+/// static-over-land geometry — dungeon walkways, bridges, stairs, stacked floors — baking at the
+/// wrong Z, which is why this is most useful when bringing up a custom map or facet.
///
-/// Output goes to a log file rather than the console because the live server uses Serilog and
-/// raw Console writes interleave badly with it. The promotion gate is forced to eager
-/// (threshold 1) for the duration so the cache builds on first touch and the numbers reflect
-/// its best case; the previous threshold is restored afterward.
+/// The promotion gate is forced eager for the duration, so the numbers reflect the cache's best
+/// case rather than an artifact of chunks not having been built yet.
///
public static class PathDiag
{
@@ -67,7 +60,7 @@ public static class PathDiag
var cache = StepCache.Instance;
var previousThreshold = cache.MissPromotionThreshold;
- cache.MissPromotionThreshold = 1; // eager build — measure the cache's best case
+ cache.MissPromotionThreshold = 1; // build eagerly, so we measure the cache's best case
StreamWriter log = null;
try
@@ -99,9 +92,8 @@ public static class PathDiag
}
///
- /// Writes the raw tile makeup of one cell plus the surfaces the baker anchors to. A large
- /// gap between the query Z and the standable surfaces is the signature of a route the
- /// cache can't serve (the creature stands on a static surface far from the land average).
+ /// Dumps one cell's tiles and the surfaces the baker anchors to. A wide gap between the query Z
+ /// and every standable surface is the signature of a cell the cache can't serve.
///
private static void DumpCell(TextWriter log, Map map, int x, int y, int queryZ, string label)
{
@@ -133,9 +125,8 @@ public static class PathDiag
}
///
- /// Runs one warm Find and records the StepCache counter delta for it — the per-pathfind
- /// cache hit/fallthrough mix and the fallthrough fraction. Returns a summary for the
- /// caller to relay to the player.
+ /// Runs one Find against a warm cache and reports the counter delta it produced — the
+ /// hit/fallthrough mix for that single pathfind.
///
private static (string result, double fallthroughPct, long total) RunInstrumentedFind(
TextWriter log, Mobile from, Map map, Point3D start, Point3D goal
@@ -143,7 +134,8 @@ public static class PathDiag
{
var cache = StepCache.Instance;
- // Warm every chunk the route touches before measuring.
+ // Build every chunk the route touches first, so the measured Find below reports steady-state
+ // behaviour rather than first-touch misses.
for (var i = 0; i < 3; i++)
{
BitmapAStarAlgorithm.Instance.Find(from, map, start, goal);
diff --git a/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs
index f21637dc4..c3490d235 100644
--- a/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs
+++ b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs
@@ -7,25 +7,14 @@ using Server.Text;
namespace Server.Engines.Pathing;
///
-/// Admin-toggled telemetry: appends a JSONL line per pathfind request to a file.
-/// One record per BitmapAStarAlgorithm.Find call, capturing the inputs (start, goal,
-/// map, capability flags) needed to replay the scenario in benchmarks. Output format
-/// matches the corpus the BDN harness consumes.
+/// Appends one JSONL record per pathfind, capturing the inputs — start, goal, map, capability
+/// flags — needed to replay it later in a benchmark. Toggled at runtime with [PathRecord;
+/// only seeds the initial state from server.cfg.
///
-/// Hot-toggleable at runtime via the [PathRecord admin command — no restart needed.
-/// only seeds the initial state from server.cfg
-/// (pathfinding.recorder.enable, default false).
-///
-/// Holds a single StreamWriter open while recording; its internal buffer absorbs
-/// per-record writes without per-call File.Open / File.Append. Each record is built
-/// in a stack-allocated ValueStringBuilder (zero per-int allocation for the field
-/// formatting), then handed to the writer as a ReadOnlySpan<char>.
-///
-/// Workload note: intended for short bursts of capture (turn on, walk a region
-/// or trigger a scenario, turn off). On a busy server with hundreds of pathfinds
-/// per second, sustained recording can saturate the StreamWriter's 4 KB buffer and
-/// block the game thread on disk writes. A backpressure-aware async sink is a
-/// future enhancement if 24/7 capture becomes a use case.
+/// Meant for short bursts: turn it on, walk the region or trigger the scenario, turn it off. The
+/// writes go through a StreamWriter's buffer on the game thread, so a busy shard doing hundreds of
+/// pathfinds a second can saturate that buffer and stall the loop on disk I/O. Sustained capture
+/// would need an async sink with backpressure.
///
public static class PathfindRecorder
{
@@ -52,9 +41,8 @@ public static class PathfindRecorder
}
///
- /// Toggle recording. When enabling, opens an append-mode StreamWriter; when
- /// disabling, flushes + disposes it. Idempotent — calling twice with the same
- /// state is a no-op.
+ /// Toggles recording, opening the file on enable and flushing and closing it on disable.
+ /// Idempotent.
///
public static void SetEnabled(bool enabled)
{
@@ -98,9 +86,8 @@ public static class PathfindRecorder
}
///
- /// Force a flush of the writer's internal buffer to disk. Safe to call when
- /// disabled (no-op). Useful after a burst of recording when an admin wants to
- /// inspect the file without waiting for buffer fill or disable.
+ /// Pushes the writer's buffer to disk, so a capture can be inspected without disabling first.
+ /// No-op when disabled.
///
public static void Flush()
{
@@ -115,9 +102,8 @@ public static class PathfindRecorder
}
///
- /// Capture one Find call. Hot path: cheap when disabled (single bool check).
- /// When enabled, formats one JSONL line and writes it through the StreamWriter's
- /// internal buffer — flush is amortized across many calls.
+ /// Records one Find. Sits on the pathfinding hot path, so it costs a single bool check when
+ /// disabled.
///
public static void RecordIfEnabled(Mobile m, Map map, Point3D start, Point3D goal)
{
@@ -140,9 +126,8 @@ public static class PathfindRecorder
try
{
- // One interpolation handles every numeric field with no per-int ToString
- // allocation; bool fields use explicit literal spans because JSON wants
- // lowercase "true"/"false" and bool.ToString() yields "True"/"False".
+ // One interpolation covers every numeric field without a per-field ToString. The bools
+ // are appended as literals because JSON wants lowercase and bool.ToString() capitalizes.
using var vsb = ValueStringBuilder.Create(192);
vsb.Append(
$"{{\"Name\":\"recorded\",\"MapId\":{map.MapID},\"StartX\":{start.X},\"StartY\":{start.Y},\"StartZ\":{start.Z},\"GoalX\":{goal.X},\"GoalY\":{goal.Y},\"GoalZ\":{goal.Z},\"CanSwim\":"