The pathing and step-cache comments had accumulated as development notes rather
than documentation: internal phase jargon ("Tier 4", "the Phase-2 synthesizer"),
change narration aimed at a reviewer ("which the old ComputeStandingZ anchor
missed", "legacy behavior"), benchmark anecdotes ("benchmarked as near-optimal",
"a ~20 ns lookup"), and multi-paragraph blocks restating what the code says.
Rewritten to keep the rationale a reader cannot derive from the code - why the
source-Z guard cannot be loosened, why multis fall through, why the promotion gate
counts Finds instead of calls, why the fingerprint hashes files rather than the
live tile tables - and to drop the history that got us there.
Also corrects comments that had gone stale:
- CacheEvictionTimer and CacheStats documented a class named
StaticWalkabilityCache, which no longer exists; it is StepCache.
- StepCacheFile's header said "File layout v8" while FormatVersion is 9, and the
body called the current record layout "the v6 layout" throughout. The layout
descriptions are now unversioned, since they describe whatever FormatVersion
currently is.
- StepProbe.ComputeStandingZ claimed StepCache uses it to bake SourceZ. It hasn't
since the baker moved to the clearance-aware ComputeStandableSurfaceZs; only a
parity test calls it now.
Two small code changes came along with the comment work, both behavior-preserving:
_lazyReaders now uses a collection expression like its neighbours, and
TryLoadFromLazyReader collapses to an expression body once its inline comment moved
to the doc comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
15 KiB
C#
435 lines
15 KiB
C#
using System;
|
|
using CalcMoves = Server.Movement.Movement;
|
|
|
|
namespace Server.Engines.Pathing.Cache;
|
|
|
|
/// <summary>
|
|
/// Computes the 8-direction "can step" mask and destination Zs for a single cell from land and
|
|
/// statics alone. Mirrors <see cref="MovementImpl"/>.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 <see cref="StepCache"/>'s multi halo.
|
|
/// <see cref="ComputeMultiMaskAt"/> 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.
|
|
/// </summary>
|
|
public static class StepProbe
|
|
{
|
|
private const int PersonHeight = 16;
|
|
private const int StepHeight = 2;
|
|
|
|
/// <summary>
|
|
/// Writes the surface Zs at (x, y) a default walker can actually stand on into
|
|
/// <paramref name="zs"/>, 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.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
public static int ComputeStandableSurfaceZs(Map map, int x, int y, Span<sbyte> zs)
|
|
{
|
|
if (map == null || map == Map.Internal)
|
|
{
|
|
return 0;
|
|
}
|
|
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
Span<int> cand = stackalloc int[16];
|
|
var count = 0;
|
|
|
|
var landTile = map.Tiles.GetLandTile(x, y);
|
|
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
|
if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0)
|
|
{
|
|
map.GetAverageZ(x, y, out _, out var landCenter, out _);
|
|
cand[count++] = landCenter;
|
|
}
|
|
|
|
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
|
|
{
|
|
if (count >= cand.Length)
|
|
{
|
|
break;
|
|
}
|
|
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
|
if (!data.Surface || data.Impassable)
|
|
{
|
|
continue;
|
|
}
|
|
cand[count++] = tile.Z + data.CalcHeight;
|
|
}
|
|
|
|
if (count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
cand[..count].Sort();
|
|
|
|
var n = 0;
|
|
for (var i = 0; i < count && n < zs.Length; i++)
|
|
{
|
|
var cz = (sbyte)Math.Clamp(cand[i], sbyte.MinValue + 1, sbyte.MaxValue);
|
|
if (n > 0 && zs[n - 1] == cz)
|
|
{
|
|
continue;
|
|
}
|
|
// Standable iff the creature's PersonHeight body envelope above this surface is
|
|
// free of impassable statics. The surface itself never blocks (its top == cz,
|
|
// which is the envelope floor, not inside it).
|
|
if (StaticsBlockAt(map, x, y, cz, cz + PersonHeight))
|
|
{
|
|
continue;
|
|
}
|
|
zs[n++] = cz;
|
|
}
|
|
|
|
return n;
|
|
}
|
|
|
|
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) =>
|
|
ComputeMaskCore(map, x, y, sourceZ, includeMultis: false);
|
|
|
|
/// <summary>
|
|
/// Multi-aware counterpart to <see cref="ComputeMaskAt"/>, 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.
|
|
/// </summary>
|
|
public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) =>
|
|
ComputeMaskCore(map, x, y, sourceZ, includeMultis: true);
|
|
|
|
/// <summary>
|
|
/// Shared 8-direction mask builder behind <see cref="ComputeMaskAt"/> and
|
|
/// <see cref="ComputeMultiMaskAt"/>. <paramref name="includeMultis"/> is the only difference:
|
|
/// it swaps the tile source to GetStaticAndMultiTiles so house and boat components participate.
|
|
/// </summary>
|
|
private static StepMask ComputeMaskCore(Map map, int x, int y, sbyte sourceZ, bool includeMultis)
|
|
{
|
|
if (map == null || map == Map.Internal)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
var srcTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(x, y) : map.Tiles.GetStaticTiles(x, y);
|
|
|
|
GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: false, cantWalk: false,
|
|
out var walkStartZ, out var walkStartTop, out _);
|
|
GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: true, cantWalk: true,
|
|
out var swimStartZ, out var swimStartTop, out _);
|
|
|
|
byte walkMask = 0;
|
|
byte wetMask = 0;
|
|
Span<sbyte> walkZs = stackalloc sbyte[8];
|
|
Span<sbyte> swimZs = stackalloc sbyte[8];
|
|
// 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();
|
|
|
|
for (var d = 0; d < 8; d++)
|
|
{
|
|
var dx = x;
|
|
var dy = y;
|
|
CalcMoves.Offset((Direction)d, ref dx, ref dy);
|
|
|
|
var dTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(dx, dy) : map.Tiles.GetStaticTiles(dx, dy);
|
|
|
|
if (CheckStaticStep(map, dx, dy, dTiles, walkStartZ, walkStartTop,
|
|
canSwim: false, cantWalk: false, out var walkZ))
|
|
{
|
|
walkMask |= (byte)(1 << d);
|
|
walkZs[d] = (sbyte)walkZ;
|
|
}
|
|
|
|
if (CheckStaticStep(map, dx, dy, dTiles, swimStartZ, swimStartTop,
|
|
canSwim: true, cantWalk: true, out var swimZ))
|
|
{
|
|
wetMask |= (byte)(1 << d);
|
|
swimZs[d] = (sbyte)swimZ;
|
|
}
|
|
}
|
|
|
|
return new StepMask(
|
|
walkMask, wetMask,
|
|
walkZs[0], walkZs[1], walkZs[2], walkZs[3],
|
|
walkZs[4], walkZs[5], walkZs[6], walkZs[7],
|
|
swimZs[0], swimZs[1], swimZs[2], swimZs[3],
|
|
swimZs[4], swimZs[5], swimZs[6], swimZs[7]
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="ComputeStandableSurfaceZs"/> instead, which is
|
|
/// clearance-aware; this remains the direct MovementImpl equivalent for parity checks.
|
|
/// </summary>
|
|
public static int ComputeStandingZ(Map map, int x, int y, int locZ)
|
|
{
|
|
GetStaticStartZ(map, x, y, locZ, map.Tiles.GetStaticTiles(x, y), canSwim: false, cantWalk: false, out _, out _, out var zCenter);
|
|
|
|
return zCenter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the water-surface standing Z at (x, y) — the Z a swim-only mob would stand
|
|
/// at on this cell — or <see cref="int.MinValue"/> if no water surface exists. Used
|
|
/// by <see cref="StepCache"/> to detect shore cells (cells with both walk and swim
|
|
/// surfaces separated by > StepHeight) and bake their swim layer at swim-perspective Z.
|
|
/// </summary>
|
|
public static int ComputeSwimStandingZ(Map map, int x, int y)
|
|
{
|
|
if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
|
{
|
|
return int.MinValue;
|
|
}
|
|
|
|
// Land tile flagged Wet — its center Z is the swim surface.
|
|
var landTile = map.Tiles.GetLandTile(x, y);
|
|
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
|
if (!landTile.Ignored && (landFlags & TileFlag.Wet) != 0)
|
|
{
|
|
map.GetAverageZ(x, y, out _, out var landCenter, out _);
|
|
return landCenter;
|
|
}
|
|
|
|
// Otherwise scan statics for a wet surface.
|
|
foreach (var tile in map.Tiles.GetStaticTiles(x, y))
|
|
{
|
|
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
|
if (data.Wet)
|
|
{
|
|
return tile.Z + data.CalcHeight;
|
|
}
|
|
}
|
|
|
|
return int.MinValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
|
|
/// </summary>
|
|
private static void GetStaticStartZ(
|
|
Map map, int x, int y, int locZ, Map.StaticTileEnumerable tiles,
|
|
bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter
|
|
)
|
|
{
|
|
var landTile = map.Tiles.GetLandTile(x, y);
|
|
var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
|
var impassable = (flags & TileFlag.Impassable) != 0;
|
|
|
|
// Mirrors MovementImpl: impassable + swim on water is OK; otherwise block on
|
|
// cantWalk or impassable.
|
|
var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0);
|
|
|
|
map.GetAverageZ(x, y, out var landZ, out var landCenter, out var landTop);
|
|
|
|
var considerLand = !landTile.Ignored;
|
|
|
|
zCenter = zLow = zTop = 0;
|
|
var isSet = false;
|
|
|
|
if (considerLand && !landBlocks && locZ >= landCenter)
|
|
{
|
|
zLow = landZ;
|
|
zCenter = landCenter;
|
|
zTop = landTop;
|
|
isSet = true;
|
|
}
|
|
|
|
foreach (var tile in tiles)
|
|
{
|
|
var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
|
var calcTop = tile.Z + id.CalcHeight;
|
|
|
|
if (isSet && calcTop < zCenter || locZ < calcTop || !id.Surface && !(canSwim && id.Wet))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
zLow = tile.Z;
|
|
zCenter = calcTop;
|
|
|
|
var top = tile.Z + id.Height;
|
|
|
|
if (!isSet || top > zTop)
|
|
{
|
|
zTop = top;
|
|
}
|
|
|
|
isSet = true;
|
|
}
|
|
|
|
if (!isSet)
|
|
{
|
|
zLow = zTop = locZ;
|
|
}
|
|
else if (locZ > zTop)
|
|
{
|
|
zTop = locZ;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors MovementImpl.Check for static tiles only, parameterized by canSwim / cantWalk.
|
|
/// Items and mobile collision phases are omitted.
|
|
/// </summary>
|
|
private static bool CheckStaticStep(
|
|
Map map, int x, int y, Map.StaticTileEnumerable tiles, int startZ, int startTop,
|
|
bool canSwim, bool cantWalk, out int newZ
|
|
)
|
|
{
|
|
newZ = 0;
|
|
|
|
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var landTile = map.Tiles.GetLandTile(x, y);
|
|
var flags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
|
var impassable = (flags & TileFlag.Impassable) != 0;
|
|
|
|
var landBlocks = (cantWalk || impassable) && !(impassable && canSwim && (flags & TileFlag.Wet) != 0);
|
|
|
|
var considerLand = !landTile.Ignored;
|
|
|
|
map.GetAverageZ(x, y, out var landZ, out var landCenter, out _);
|
|
|
|
var moveIsOk = false;
|
|
|
|
var stepTop = startTop + StepHeight;
|
|
var checkTop = startZ + PersonHeight;
|
|
|
|
int testTop;
|
|
|
|
foreach (var tile in tiles)
|
|
{
|
|
var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
|
var notWater = !itemData.Wet;
|
|
|
|
// Mirrors MovementImpl: skip if not a passable surface AND not swimmable water,
|
|
// OR if the mobile can't walk and this isn't water.
|
|
if ((!itemData.Surface || itemData.Impassable) && (!canSwim || notWater)
|
|
|| cantWalk && notWater)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var itemZ = tile.Z;
|
|
var itemTop = itemZ;
|
|
var ourZ = itemZ + itemData.CalcHeight;
|
|
testTop = checkTop;
|
|
|
|
if (moveIsOk)
|
|
{
|
|
var cmp = Math.Abs(ourZ - startZ) - Math.Abs(newZ - startZ);
|
|
|
|
if (cmp > 0 || cmp == 0 && ourZ > newZ)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (ourZ + PersonHeight > testTop)
|
|
{
|
|
testTop = ourZ + PersonHeight;
|
|
}
|
|
|
|
if (!itemData.Bridge)
|
|
{
|
|
itemTop += itemData.Height;
|
|
}
|
|
|
|
if (stepTop < itemTop)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var landCheck = itemZ + Math.Min(itemData.Height, StepHeight);
|
|
|
|
if (considerLand && landCheck < landCenter && landCenter > ourZ && testTop > landZ)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (StaticsBlockAt(map, x, y, ourZ, testTop))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
newZ = ourZ;
|
|
moveIsOk = true;
|
|
}
|
|
|
|
if (!considerLand || landBlocks || stepTop < landZ)
|
|
{
|
|
return moveIsOk;
|
|
}
|
|
|
|
testTop = checkTop;
|
|
|
|
if (landCenter + PersonHeight > testTop)
|
|
{
|
|
testTop = landCenter + PersonHeight;
|
|
}
|
|
|
|
var shouldCheck = true;
|
|
|
|
if (moveIsOk)
|
|
{
|
|
var cmp = Math.Abs(landCenter - startZ) - Math.Abs(newZ - startZ);
|
|
|
|
if (cmp > 0 || cmp == 0 && landCenter > newZ)
|
|
{
|
|
shouldCheck = false;
|
|
}
|
|
}
|
|
|
|
if (shouldCheck && !StaticsBlockAt(map, x, y, landCenter, testTop))
|
|
{
|
|
newZ = landCenter;
|
|
moveIsOk = true;
|
|
}
|
|
|
|
return moveIsOk;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors the static-tile portion of IsOk: returns true if any static tile at (x,y)
|
|
/// has ImpassableSurface and overlaps the vertical range (ourZ, testTop).
|
|
/// </summary>
|
|
private static bool StaticsBlockAt(Map map, int x, int y, int ourZ, int testTop)
|
|
{
|
|
foreach (var check in map.Tiles.GetStaticAndMultiTiles(x, y))
|
|
{
|
|
var itemData = TileData.ItemTable[check.ID & TileData.MaxItemValue];
|
|
|
|
if (itemData.ImpassableSurface)
|
|
{
|
|
var checkZ = check.Z;
|
|
var checkTop = checkZ + itemData.CalcHeight;
|
|
|
|
if (checkTop > ourZ && testTop > checkZ)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|