feat(pathfinding): non-eager TryGetMask + second-touch promotion (#2451)
## Summary Closes the Cold-cache regression flagged in PR #2450. `StepCache.TryGetMask` no longer eagerly runs `BuildChunk` on the first miss for a chunk that isn't in a `.swb` lazy reader. Instead it returns `Fallthrough_NotBuilt` and the caller (`BitmapAStarAlgorithm`) takes the per-cell slow path. The chunk is only promoted to the bitmap fast path after the **second** miss within a 30-second window, filtering single-touch pass-throughs. This makes BitmapAStar's worst-case (cold cache + short hops) collapse from **12–47× slower** than FastAStar to **roughly the same**, which is the floor the slow path can deliver. Steady-state warm performance (the actual deliverable) is unchanged from PR-5 — it was always the cache fast path. ## The pet-follow scenario this fixes A mounted player at ~4 tiles/sec with a pet/hireable following will trigger an NPC pathfind every 100–300 ms. Each pathfind is 1–6 tiles. As the player crosses chunk boundaries (~4 sec/chunk), the pet's first pathfind in the new chunk under the previous behavior triggered a full ~700 µs `BuildChunk` for a chunk the player would leave shortly after. At 50–100 mobiles per shard, this exceeded the 8 ms tick budget. PR-5 BDN data showed scenarios 6–9 (2–8 tile NPC perception) at 2,300–3,700 µs Cold vs FastAStar's 80–200 µs. Under the new gate: - First miss → `Fallthrough_NotBuilt` → caller uses slow path (~30–50 µs short path). No `BuildChunk`. No allocation. - Player keeps moving → chunk never gets a second touch within window → never promoted, no rot. - NPC patrolling a fixed territory → repeatedly hits the same chunks → second touch within window → promote → cache fast path on subsequent calls. ## What changed - **`CacheHitKind.Fallthrough_NotBuilt = 6`** + **`CacheStats.FallthroughNotBuilt`** counter. `IsHit=false`, so the caller routes to slow path. - **`StepCache._chunkMissTracker`** — `Dictionary<long, ChunkMissState>` capped at 4096 entries. State is `(byte missCount, uint lastMissTickStamp)` keyed by chunk key. Window-expired entries reset count to 1; capacity overflow prunes window-old entries first. - **`StepCache.MissPromotionThreshold`** (default `2`) and **`StepCache.MissPromotionWindowMs`** (default `30_000`) — tunable, can be wired through `ServerConfiguration` if shards want different policy. Setting threshold to `1` restores legacy eager-build behavior (used by tests that prime chunks via single `TryGetMask` call). - **`StepCache.TryGetMask` miss branch** — try lazy reader first (file-loaded chunks bypass the tracker entirely; an `.swb` represents an explicit prior decision to keep the chunk warm). Otherwise consult the tracker. - **`BitmapAStarAlgorithm.GetSuccessorsSlowPath`** now layers `IsBlockedByDynamic` on top of `CalcMoves.CheckMovement`. Previously the slow path only ran for `CanFly` creatures and rare cache fallthroughs — `CheckMovement` doesn't iterate same-cell mobiles, so the bitmap fast path's `IsBlockedByDynamic` was the only mobile-blocking check. Now first-touch pathfinds run through the slow path, so the gap had to close. ## Tests 50 pathfinding tests pass (was 47). New / updated: - **`TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough`** — single TryGetMask call returns `Fallthrough_NotBuilt`, no chunk built, no allocation. - **`TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds`** — second call inside the 30s window builds + serves. - **`TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers`** — second call outside the window restarts the count, returns Fallthrough again. - **`TryGetMask_DistinctChunks_TrackedIndependently`** — counters are per-chunk; one touch on each of two adjacent chunks both stay in fallthrough. - **`LazyReaderHit_BypassesMissTrackerOnFirstTouch`** — open `.swb` + first touch hits without consulting the tracker. Production with `.swb` loaded skips the gate entirely. - **`MultisVersion_Bump_TriggersDirtyRebuild`** — updated to reflect the new 3-step flow (Fallthrough → Miss_NotBuilt → Miss_DirtyRebuild). - Tests that prime chunks via a single `TryGetMask` call (multi-Z, Tier4, lifecycle, parity, BitmapAStar uses-cache) set `MissPromotionThreshold = 1` to opt into eager behavior. ## Expected BDN impact The Cold column from PR-5's BDN should change as follows once the bench's submodule pointer is updated to this branch: | # | Scenario | Cold (PR-5) | Cold (PR-6 expected) | FastAStar Cold | |--:|-----------------|-------------:|---------------------:|---------------:| | 2 | sewer corridor | 1,627 µs | ~36 µs | 36 µs | | 4 | causeway | 1,533 µs | ~39 µs | 39 µs | | 6 | pet 2-tile | 2,364 µs | ~80 µs | 81 µs | | 8 | npc 5-tile | 3,708 µs | ~140 µs | 141 µs | | 9 | npc 8-tile | 2,386 µs | ~200 µs | 197 µs | WarmNoFile and LazyWarm rows should be unchanged — they were always cache-warm. The miss tracker only fires when neither resident chunks nor the lazy reader can satisfy the request. ## Future work (not in this PR) - **Background-thread bake**: builds outside the game thread so even promoted chunks don't pay the 700 µs build cost on the main thread. Rule 10 (no Task.Run) applies, so this needs careful design — the bake is a pure data transform but main-thread synchronization on chunk-state transitions has to be threaded through. Defer to a follow-up. - **Long-traverse BDN scenario**: a multi-Find benchmark simulating 50 pet repaths across chunk transitions. Requires restructuring the bench harness; the existing 10-scenario corpus + Cold provider already exercises the gate. - **Swim sourceZ bake**: scenario 5 (sea serpent) shows 56 B alloc on warm paths because the cache's SourceZ is computed under default-walker rules. Swim creatures fall through to slow path. Independent of this PR.
This commit is contained in:
parent
cff9fbda29
commit
9a3d88988c
14 changed files with 1664 additions and 163 deletions
|
|
@ -58,6 +58,7 @@ public class BitmapAStarAlgorithmTests
|
||||||
public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy)
|
public void SwimCreature_FindsPath_ViaCacheCapabilityOverlay(int sx, int sy, int gx, int gy)
|
||||||
{
|
{
|
||||||
StepCache.Instance.Clear();
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 1;
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
|
@ -249,6 +250,7 @@ public class BitmapAStarAlgorithmTests
|
||||||
public void NonGmPlayer_UsesCache_WithStrictDiagonalRule()
|
public void NonGmPlayer_UsesCache_WithStrictDiagonalRule()
|
||||||
{
|
{
|
||||||
StepCache.Instance.Clear();
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 1;
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
|
@ -277,6 +279,7 @@ public class BitmapAStarAlgorithmTests
|
||||||
public void DoorCreature_UsesCache_NotSlowPath()
|
public void DoorCreature_UsesCache_NotSlowPath()
|
||||||
{
|
{
|
||||||
StepCache.Instance.Clear();
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 1;
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
var stub = new DoorOpenerStub(World.NewMobile);
|
var stub = new DoorOpenerStub(World.NewMobile);
|
||||||
|
|
@ -303,6 +306,7 @@ public class BitmapAStarAlgorithmTests
|
||||||
public void ObstacleCreature_UsesCache_NotSlowPath()
|
public void ObstacleCreature_UsesCache_NotSlowPath()
|
||||||
{
|
{
|
||||||
StepCache.Instance.Clear();
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 1;
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
var stub = new ObstacleClimberStub(World.NewMobile);
|
var stub = new ObstacleClimberStub(World.NewMobile);
|
||||||
|
|
@ -325,6 +329,74 @@ public class BitmapAStarAlgorithmTests
|
||||||
"CanMoveOverObstacles creature should use the cache (movables are dynamic items)");
|
"CanMoveOverObstacles creature should use the cache (movables are dynamic items)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------
|
||||||
|
// Promotion-gate integration tests. These exercise BitmapAStarAlgorithm.Find()
|
||||||
|
// end-to-end against the live StepCache to prove the per-Find generation gate
|
||||||
|
// actually defers BuildChunk on a single pathfind. They duplicate behavior that
|
||||||
|
// unit tests cover at the cache layer; the value is end-to-end verification that
|
||||||
|
// the bench-relevant scenario (single Find on cleared cache) skips builds entirely.
|
||||||
|
// TODO: REMOVE these two tests once PR-6's gate is proven stable in production BDN.
|
||||||
|
// ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Find_SinglePathfindOnClearedCache_DoesNotBuildAnyChunk()
|
||||||
|
{
|
||||||
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
var stub = new DefaultWalkerStub();
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
|
||||||
|
var start = new Point3D(1500, 1600, (sbyte)startZ);
|
||||||
|
var goal = new Point3D(1498, 1598, (sbyte)startZ);
|
||||||
|
stub.MoveToWorld(start, map);
|
||||||
|
|
||||||
|
var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||||
|
|
||||||
|
var stats = StepCache.Instance.GetStats();
|
||||||
|
stub.Delete();
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(0L, stats.BuildsTotal);
|
||||||
|
Assert.True(stats.FallthroughNotBuilt > 0L,
|
||||||
|
$"expected fallthrough on every chunk touched once; got 0 (residents={stats.ResidentChunks})");
|
||||||
|
_output.WriteLine(
|
||||||
|
$"single-Find gate: builds={stats.BuildsTotal} fallthrough_not_built={stats.FallthroughNotBuilt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Find_TwoPathfindsOverlappingChunks_PromoteToBuildOnSecondFind()
|
||||||
|
{
|
||||||
|
StepCache.Instance.Clear();
|
||||||
|
StepCache.Instance.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
var stub = new DefaultWalkerStub();
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var startZ, out _);
|
||||||
|
var start = new Point3D(1500, 1600, (sbyte)startZ);
|
||||||
|
var goal = new Point3D(1498, 1598, (sbyte)startZ);
|
||||||
|
stub.MoveToWorld(start, map);
|
||||||
|
|
||||||
|
// Find #1: first time anyone touches these chunks. Gate defers; no builds.
|
||||||
|
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||||
|
var afterFirst = StepCache.Instance.GetStats();
|
||||||
|
Assert.Equal(0L, afterFirst.BuildsTotal);
|
||||||
|
|
||||||
|
// Find #2: same path; chunks now hit their second distinct Find inside the window.
|
||||||
|
// Gate promotes — at least one BuildChunk fires.
|
||||||
|
BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal);
|
||||||
|
var afterSecond = StepCache.Instance.GetStats();
|
||||||
|
|
||||||
|
stub.Delete();
|
||||||
|
|
||||||
|
Assert.True(afterSecond.BuildsTotal > 0L,
|
||||||
|
$"second Find through overlapping chunks must promote (got {afterSecond.BuildsTotal} builds)");
|
||||||
|
_output.WriteLine(
|
||||||
|
$"two-Find gate: first builds={afterFirst.BuildsTotal} second builds={afterSecond.BuildsTotal}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache
|
/// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache
|
||||||
/// fast path on every expansion.
|
/// fast path on every expansion.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Server.Engines.Pathing.Cache;
|
using Server.Engines.Pathing.Cache;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -17,15 +18,30 @@ public class StepCacheFileTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
||||||
// Populate three distinct chunks by querying different sectors.
|
// Populate three distinct chunks by querying different sectors. Query at each cell's
|
||||||
|
// real standable surface Z (where the cache anchors) so first-touch yields a clean
|
||||||
|
// hit rather than an off-surface fallthrough.
|
||||||
var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) };
|
var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) };
|
||||||
foreach (var (x, y) in sourceQueries)
|
var standZ = new sbyte[sourceQueries.Length];
|
||||||
{
|
{
|
||||||
cache.TryGetMask(map, x, y, sourceZ: 10);
|
Span<sbyte> surfZ = stackalloc sbyte[16];
|
||||||
|
for (var i = 0; i < sourceQueries.Length; i++)
|
||||||
|
{
|
||||||
|
var (qx, qy) = sourceQueries[i];
|
||||||
|
var n = StepProbe.ComputeStandableSurfaceZs(map, qx, qy, surfZ);
|
||||||
|
Assert.True(n > 0, $"({qx},{qy}) has no standable surface — bad test cell");
|
||||||
|
standZ[i] = surfZ[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var i = 0; i < sourceQueries.Length; i++)
|
||||||
|
{
|
||||||
|
var (x, y) = sourceQueries[i];
|
||||||
|
cache.TryGetMask(map, x, y, standZ[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(3, cache.GetStats().ResidentChunks);
|
Assert.Equal(3, cache.GetStats().ResidentChunks);
|
||||||
|
|
@ -36,7 +52,7 @@ public class StepCacheFileTests
|
||||||
for (var i = 0; i < sourceQueries.Length; i++)
|
for (var i = 0; i < sourceQueries.Length; i++)
|
||||||
{
|
{
|
||||||
var (x, y) = sourceQueries[i];
|
var (x, y) = sourceQueries[i];
|
||||||
expected[i] = cache.TryGetMask(map, x, y, sourceZ: 10);
|
expected[i] = cache.TryGetMask(map, x, y, standZ[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb");
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb");
|
||||||
|
|
@ -66,7 +82,7 @@ public class StepCacheFileTests
|
||||||
for (var i = 0; i < sourceQueries.Length; i++)
|
for (var i = 0; i < sourceQueries.Length; i++)
|
||||||
{
|
{
|
||||||
var (x, y) = sourceQueries[i];
|
var (x, y) = sourceQueries[i];
|
||||||
var lookup = cache.TryGetMask(map, x, y, sourceZ: 10);
|
var lookup = cache.TryGetMask(map, x, y, standZ[i]);
|
||||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||||
Assert.Equal(expected[i].WalkMask, lookup.WalkMask);
|
Assert.Equal(expected[i].WalkMask, lookup.WalkMask);
|
||||||
Assert.Equal(expected[i].WetMask, lookup.WetMask);
|
Assert.Equal(expected[i].WetMask, lookup.WetMask);
|
||||||
|
|
@ -171,6 +187,7 @@ public class StepCacheFileTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build to populate chunks for save
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
@ -210,4 +227,175 @@ public class StepCacheFileTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First-touch on a chunk that the lazy reader can satisfy must NOT route through the
|
||||||
|
/// miss tracker — file-loaded chunks represent an explicit prior decision to keep
|
||||||
|
/// them warm. This guards the deployment shape where an admin ships .swb files and
|
||||||
|
/// expects the very first NPC pathfind in any region to use cache (not slow path).
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// A chunk with an injected swim layer must serialize and deserialize via the lazy
|
||||||
|
/// reader without losing the layer. Validates v3 file format end-to-end: swim layer
|
||||||
|
/// fields survive Save → Clear → LazyOpen → first-touch query.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SwimLayer_RoundTrips_ThroughLazyReader()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
// Build a chunk and inject a synthetic swim layer onto cell (1500, 1600).
|
||||||
|
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
|
||||||
|
var chunksField = typeof(StepCache).GetField(
|
||||||
|
"_chunks",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||||
|
);
|
||||||
|
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||||
|
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||||
|
var chunk = chunks[key];
|
||||||
|
|
||||||
|
chunk.AllocateSwimLayer();
|
||||||
|
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||||
|
chunk.SwimSourceZ[cellIndex] = -7;
|
||||||
|
chunk.SwimMask[cellIndex] = 0b0000_1111;
|
||||||
|
chunk.SwimZN_Layer[cellIndex] = -7;
|
||||||
|
chunk.SwimZNE_Layer[cellIndex] = -7;
|
||||||
|
chunk.SwimZE_Layer[cellIndex] = -7;
|
||||||
|
chunk.SwimZSE_Layer[cellIndex] = -7;
|
||||||
|
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{System.Guid.NewGuid():N}.swb");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
||||||
|
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||||
|
|
||||||
|
// Pull the chunk back via a query at swim Z; the layer must hit and serve our
|
||||||
|
// injected mask. Walk-Z query of the same cell should still hit the walk
|
||||||
|
// layer with whatever the bake produced.
|
||||||
|
var swim = cache.TryGetMask(map, 1500, 1600, sourceZ: -7);
|
||||||
|
Assert.True(swim.IsHit);
|
||||||
|
Assert.Equal((byte)0, swim.WalkMask);
|
||||||
|
Assert.Equal((byte)0b0000_1111, swim.WetMask);
|
||||||
|
Assert.Equal((sbyte)-7, swim.SwimZ_N);
|
||||||
|
Assert.Equal((sbyte)-7, swim.SwimZ_E);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache.Clear();
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PreloadOnLazyOpen=true must materialize every chunk in the .swb file into the
|
||||||
|
/// resident set immediately, eliminating first-touch file-read latency. Counterpart
|
||||||
|
/// to <see cref="LazyReader_DoesNotMaterializeUntilQueried"/> which proves the
|
||||||
|
/// default lazy behavior.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
var coords = new (int, int)[]
|
||||||
|
{
|
||||||
|
(1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600)
|
||||||
|
};
|
||||||
|
foreach (var (x, y) in coords)
|
||||||
|
{
|
||||||
|
cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{System.Guid.NewGuid():N}.swb");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID));
|
||||||
|
|
||||||
|
cache.Clear();
|
||||||
|
cache.PreloadOnLazyOpen = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||||
|
|
||||||
|
// Every chunk should be resident — no further queries needed.
|
||||||
|
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
||||||
|
|
||||||
|
// Subsequent query is a clean Hit, not a Miss_NotBuilt.
|
||||||
|
var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10);
|
||||||
|
Assert.Equal(CacheHitKind.Hit, lookup.HitKind);
|
||||||
|
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache.PreloadOnLazyOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache.Clear();
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build for save phase
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
// Build + save one chunk.
|
||||||
|
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{System.Guid.NewGuid():N}.swb");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(1, cache.SaveToFile(path, map.MapID));
|
||||||
|
|
||||||
|
// Reset to a fresh state with the file open as a lazy reader and the deferred
|
||||||
|
// promotion threshold restored to 2.
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
Assert.True(cache.TryOpenLazyReader(path, map.MapID));
|
||||||
|
|
||||||
|
// First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the
|
||||||
|
// chunk and serves it before the miss tracker is consulted.
|
||||||
|
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.True(lookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||||
|
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(0L, cache.GetStats().FallthroughNotBuilt);
|
||||||
|
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cache.Clear();
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,38 +26,167 @@ public class StepCacheLifecycleTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetMask_FirstQuery_BuildsChunkAndReturnsBakerOutput()
|
public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough()
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
||||||
// Pinned cell (1500, 1600, z=10): mask=0xC1
|
// First touch on a chunk that has no resident copy and no lazy reader behind it
|
||||||
|
// must NOT eagerly build. Caller (BitmapAStarAlgorithm) interprets IsHit=false as
|
||||||
|
// "use slow path" — pets/hireables passing briefly through a chunk avoid the
|
||||||
|
// ~700µs BuildChunk cost they'd never amortize.
|
||||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
|
||||||
Assert.True(lookup.IsHit);
|
Assert.False(lookup.IsHit);
|
||||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
||||||
Assert.Equal((byte)0xC1, lookup.WalkMask);
|
|
||||||
Assert.Equal((sbyte)10, lookup.WalkZ_N);
|
var stats = cache.GetStats();
|
||||||
Assert.Equal((sbyte)10, lookup.WalkZ_W);
|
Assert.Equal(0, stats.ResidentChunks);
|
||||||
Assert.Equal((sbyte)10, lookup.WalkZ_NW);
|
Assert.Equal(0L, stats.BuildsTotal);
|
||||||
|
Assert.Equal(0L, stats.MissesNotBuilt);
|
||||||
|
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
// First touch defers; second touch inside the promotion window builds + serves.
|
||||||
|
// Pinned cell (1500, 1600, z=10): mask=0xC1
|
||||||
|
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.False(first.IsHit);
|
||||||
|
|
||||||
|
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.True(second.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, second.HitKind);
|
||||||
|
Assert.Equal((byte)0xC1, second.WalkMask);
|
||||||
|
Assert.Equal((sbyte)10, second.WalkZ_N);
|
||||||
|
|
||||||
var stats = cache.GetStats();
|
var stats = cache.GetStats();
|
||||||
Assert.Equal(1, stats.ResidentChunks);
|
Assert.Equal(1, stats.ResidentChunks);
|
||||||
Assert.Equal(1L, stats.MissesNotBuilt);
|
Assert.Equal(1L, stats.MissesNotBuilt);
|
||||||
Assert.Equal(1L, stats.BuildsTotal);
|
Assert.Equal(1L, stats.BuildsTotal);
|
||||||
|
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||||
|
|
||||||
// Second query of same cell → Hit
|
// Third query of same cell → Hit (chunk now resident).
|
||||||
var lookup2 = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
var third = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
Assert.True(lookup2.IsHit);
|
Assert.True(third.IsHit);
|
||||||
Assert.Equal(CacheHitKind.Hit, lookup2.HitKind);
|
Assert.Equal(CacheHitKind.Hit, third.HitKind);
|
||||||
Assert.Equal((byte)0xC1, lookup2.WalkMask);
|
Assert.Equal((byte)0xC1, third.WalkMask);
|
||||||
|
}
|
||||||
|
|
||||||
var stats2 = cache.GetStats();
|
[Fact]
|
||||||
Assert.Equal(1, stats2.ResidentChunks);
|
public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers()
|
||||||
Assert.Equal(1L, stats2.Hits);
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
cache.MissPromotionWindowMs = 1; // 1ms window for testability
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.False(first.IsHit);
|
||||||
|
|
||||||
|
System.Threading.Thread.Sleep(20); // exceed the window
|
||||||
|
|
||||||
|
// Second touch lands outside the window: tracker resets the count to 1, returns
|
||||||
|
// Fallthrough_NotBuilt again — chunks the player just glanced through don't get
|
||||||
|
// promoted just because they get re-touched minutes later by an unrelated NPC.
|
||||||
|
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.False(second.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||||
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
// Open a pathfind. Multiple TryGetMask calls inside this Find target the same chunk
|
||||||
|
// (different cells). The promotion gate counts distinct Finds, not raw probes — these
|
||||||
|
// calls must NOT increment the per-chunk counter, even though there are many of them.
|
||||||
|
// Without this, A* expansion would trip the gate on the second cell expansion in any
|
||||||
|
// visited chunk, defeating the whole point of deferred promotion.
|
||||||
|
cache.BeginFindGeneration();
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
// All cells are inside chunk (1500>>4, 1600>>4) = (93, 100).
|
||||||
|
var lookup = cache.TryGetMask(map, 1500 + i, 1600, sourceZ: 10);
|
||||||
|
Assert.False(lookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(0L, cache.GetStats().BuildsTotal);
|
||||||
|
Assert.Equal(8L, cache.GetStats().FallthroughNotBuilt);
|
||||||
|
|
||||||
|
// Begin a NEW Find — this is the second distinct touch under the per-Find gate.
|
||||||
|
// The chunk now crosses the threshold and promotes.
|
||||||
|
cache.BeginFindGeneration();
|
||||||
|
var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.True(promoted.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
||||||
|
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(1L, cache.GetStats().BuildsTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
cache.MissPromotionWindowMs = 1; // 1ms window for testability
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
cache.BeginFindGeneration();
|
||||||
|
Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit);
|
||||||
|
|
||||||
|
System.Threading.Thread.Sleep(20); // exceed window
|
||||||
|
|
||||||
|
// Second Find lands outside the window. Even though it's a distinct generation,
|
||||||
|
// the elapsed-time check resets the counter to 1, so no promotion.
|
||||||
|
cache.BeginFindGeneration();
|
||||||
|
var second = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
Assert.False(second.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||||
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetMask_DistinctChunks_TrackedIndependently()
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
// Two different chunks, one touch each — both must defer (each has its own counter).
|
||||||
|
var chunkA = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
var chunkB = cache.TryGetMask(map, 1600, 1700, sourceZ: 10); // different chunk
|
||||||
|
|
||||||
|
Assert.False(chunkA.IsHit);
|
||||||
|
Assert.False(chunkB.IsHit);
|
||||||
|
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||||
|
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -80,11 +209,13 @@ public class StepCacheLifecycleTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 2;
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
|
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
|
||||||
|
|
||||||
// First query: builds chunk, snapshots current MultisVersion.
|
// First touch defers (Fallthrough_NotBuilt); second touch promotes and builds.
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||||
|
|
||||||
// Bump _multisVersion via reflection.
|
// Bump _multisVersion via reflection.
|
||||||
|
|
@ -96,18 +227,18 @@ public class StepCacheLifecycleTests
|
||||||
var current = (int)versionField.GetValue(sector);
|
var current = (int)versionField.GetValue(sector);
|
||||||
versionField.SetValue(sector, current + 1);
|
versionField.SetValue(sector, current + 1);
|
||||||
|
|
||||||
// Second query: detects version mismatch, rebuilds.
|
// Third query: detects version mismatch, rebuilds.
|
||||||
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
|
||||||
|
|
||||||
var stats = cache.GetStats();
|
var stats = cache.GetStats();
|
||||||
Assert.Equal(1L, stats.MissesDirtyRebuild);
|
Assert.Equal(1L, stats.MissesDirtyRebuild);
|
||||||
Assert.Equal(2L, stats.BuildsTotal);
|
Assert.Equal(2L, stats.BuildsTotal);
|
||||||
|
|
||||||
// Mutual-exclusivity invariant: every successful TryGetMask hits exactly one
|
// Mutual-exclusivity invariant: hits + miss-builds + dirty-rebuilds = served-result count.
|
||||||
// outcome counter. Two queries above both returned true (the test cell is not
|
// Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild),
|
||||||
// multi-Z and not off-map), so the three outcome counters must sum to 2 and the
|
// and the first was a Fallthrough_NotBuilt (no build, slow-path signal).
|
||||||
// fallthrough counters must be zero.
|
|
||||||
Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits);
|
Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits);
|
||||||
|
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||||
Assert.Equal(0L, stats.FallthroughMultiZ);
|
Assert.Equal(0L, stats.FallthroughMultiZ);
|
||||||
Assert.Equal(0L, stats.FallthroughOffMap);
|
Assert.Equal(0L, stats.FallthroughOffMap);
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +248,7 @@ public class StepCacheLifecycleTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
|
@ -165,6 +297,7 @@ public class StepCacheLifecycleTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
cache.TryGetMask(map, 1500, 1600, 10);
|
cache.TryGetMask(map, 1500, 1600, 10);
|
||||||
|
|
@ -205,11 +338,122 @@ public class StepCacheLifecycleTests
|
||||||
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
|
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch()
|
||||||
|
{
|
||||||
|
// Sanity check: a chunk WITHOUT a swim layer falls through on source-Z mismatch
|
||||||
|
// exactly like before. Validates we didn't accidentally serve garbage when the
|
||||||
|
// chunk has no shore cells.
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
|
||||||
|
cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk
|
||||||
|
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
|
||||||
|
|
||||||
|
// Same cell but query Z far from baked Z → source-Z guard fires.
|
||||||
|
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 100);
|
||||||
|
Assert.False(lookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
||||||
|
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwimLayer_InjectedMatchingZ_ReturnsHitFromSwimLayer()
|
||||||
|
{
|
||||||
|
// Inject a synthetic swim layer onto a resident chunk and verify a query at the
|
||||||
|
// swim source Z routes through the swim-layer fallback, returning the swim mask.
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
|
||||||
|
var chunksField = typeof(StepCache).GetField(
|
||||||
|
"_chunks",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||||
|
);
|
||||||
|
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||||
|
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||||
|
var chunk = chunks[key];
|
||||||
|
|
||||||
|
chunk.AllocateSwimLayer();
|
||||||
|
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||||
|
chunk.SwimSourceZ[cellIndex] = -5;
|
||||||
|
chunk.SwimMask[cellIndex] = 0b0000_0011;
|
||||||
|
chunk.SwimZN_Layer[cellIndex] = -5;
|
||||||
|
chunk.SwimZNE_Layer[cellIndex] = -5;
|
||||||
|
// Other directions stay 0 — Mask bits 0 and 1 cover N and NE.
|
||||||
|
|
||||||
|
// Query at the chunk's primary SourceZ — primary path serves walk-layer data,
|
||||||
|
// swim layer not consulted.
|
||||||
|
var bakedSourceZ = chunk.SourceZ[cellIndex];
|
||||||
|
var walkLookup = cache.TryGetMask(map, 1500, 1600, bakedSourceZ);
|
||||||
|
Assert.True(walkLookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Hit, walkLookup.HitKind);
|
||||||
|
// Walk-layer query produces walk-layer walkMask (whatever the bake found), NOT
|
||||||
|
// the synthetic swim mask we injected.
|
||||||
|
|
||||||
|
// Query at the swim source Z — primary source-Z guard fails (|−5 − bakedZ| > 2
|
||||||
|
// assuming baked Z is land surface), swim-layer fallback serves with our mask.
|
||||||
|
if (System.Math.Abs(-5 - bakedSourceZ) <= 2)
|
||||||
|
{
|
||||||
|
// Bake landed near water Z — adjust the test to a clearer swim Z.
|
||||||
|
chunk.SwimSourceZ[cellIndex] = (sbyte)(bakedSourceZ - 20);
|
||||||
|
}
|
||||||
|
var swimLookup = cache.TryGetMask(map, 1500, 1600, chunk.SwimSourceZ[cellIndex]);
|
||||||
|
Assert.True(swimLookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Hit, swimLookup.HitKind);
|
||||||
|
Assert.Equal((byte)0, swimLookup.WalkMask); // walk = 0 at swim Z
|
||||||
|
Assert.Equal(chunk.SwimMask[cellIndex], swimLookup.WetMask);
|
||||||
|
Assert.Equal(chunk.SwimZN_Layer[cellIndex], swimLookup.SwimZ_N);
|
||||||
|
Assert.Equal(chunk.SwimZNE_Layer[cellIndex], swimLookup.SwimZ_NE);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwimLayer_InjectedButCellHasNoSentinel_FallsThrough()
|
||||||
|
{
|
||||||
|
// Chunk has the swim layer (some other cell is shore), but THIS cell is inland
|
||||||
|
// (SwimSourceZ = NoSwimLayerCell). Query at non-matching walk Z must fall through,
|
||||||
|
// not erroneously match -128 against the query.
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||||
|
|
||||||
|
var chunksField = typeof(StepCache).GetField(
|
||||||
|
"_chunks",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||||
|
);
|
||||||
|
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField!.GetValue(cache)!;
|
||||||
|
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||||
|
var chunk = chunks[key];
|
||||||
|
|
||||||
|
// Allocate layer but leave THIS cell at the sentinel.
|
||||||
|
chunk.AllocateSwimLayer();
|
||||||
|
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
|
||||||
|
Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cellIndex]);
|
||||||
|
|
||||||
|
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
|
||||||
|
// Query at -128 (the sentinel value) — must NOT match. The guard short-circuits
|
||||||
|
// on the sentinel before computing |sourceZ - SwimSourceZ|.
|
||||||
|
var lookup = cache.TryGetMask(map, 1500, 1600, sbyte.MinValue);
|
||||||
|
Assert.False(lookup.IsHit);
|
||||||
|
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
|
||||||
|
Assert.Equal(beforeMismatch + 1L, cache.GetStats().FallthroughSourceZMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tier4Strata_NonMatchingZ_FallsThrough()
|
public void Tier4Strata_NonMatchingZ_FallsThrough()
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
cache.TryGetMask(map, 1500, 1600, 10);
|
cache.TryGetMask(map, 1500, 1600, 10);
|
||||||
|
|
@ -246,6 +490,7 @@ public class StepCacheLifecycleTests
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
cache.MaxResidentChunks = 4;
|
cache.MaxResidentChunks = 4;
|
||||||
|
cache.MissPromotionThreshold = 1;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using Server.Engines.Pathing.Cache;
|
using Server.Engines.Pathing.Cache;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
@ -22,6 +23,7 @@ public class StepCacheParityTests
|
||||||
{
|
{
|
||||||
var cache = StepCache.Instance;
|
var cache = StepCache.Instance;
|
||||||
cache.Clear();
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
var map = Map.Maps[1];
|
||||||
Assert.NotNull(map);
|
Assert.NotNull(map);
|
||||||
|
|
@ -31,16 +33,22 @@ public class StepCacheParityTests
|
||||||
var multiZ = 0;
|
var multiZ = 0;
|
||||||
var wetCells = 0;
|
var wetCells = 0;
|
||||||
|
|
||||||
|
// The cache anchors each cell at the surface a creature actually STANDS on
|
||||||
|
// (clearance-aware), not the land average. Query at that same standable Z so the
|
||||||
|
// source-Z guard doesn't false-positive (e.g. on a raised causeway or sewer walkway
|
||||||
|
// whose surface sits well above the land). Cells with no standable walk surface are
|
||||||
|
// skipped — there's nothing for a walker to compare against.
|
||||||
|
Span<sbyte> surfZ = stackalloc sbyte[16];
|
||||||
|
|
||||||
for (var x = xStart; x < xStart + size; x++)
|
for (var x = xStart; x < xStart + size; x++)
|
||||||
{
|
{
|
||||||
for (var y = yStart; y < yStart + size; y++)
|
for (var y = yStart; y < yStart + size; y++)
|
||||||
{
|
{
|
||||||
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0)
|
||||||
|
{
|
||||||
// The cache bakes from the slow path's standing Z (the Z a creature actually
|
continue;
|
||||||
// stands at on this cell). Query with the same Z so the source-Z guard
|
}
|
||||||
// doesn't false-positive on every paver cell.
|
var sourceZ = surfZ[0];
|
||||||
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
|
|
||||||
|
|
||||||
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server.Engines.Pathing.Cache;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Server.Tests.Pathfinding;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parity coverage for "walkable static surface above a land tile" terrain — sewers,
|
||||||
|
/// dungeon walkways, bridges, raised foundations, and stacked building floors.
|
||||||
|
///
|
||||||
|
/// The original parity tests only queried at the LAND-anchored standing Z and skipped
|
||||||
|
/// multi-Z fallthroughs, so they never noticed that a query at the REAL walk Z — the static
|
||||||
|
/// surface a creature actually stands on — returns
|
||||||
|
/// <see cref="CacheHitKind.Fallthrough_SourceZMismatch"/>, because the baker anchored
|
||||||
|
/// SourceZ at the land average instead of the walkway. In the Britain sewer that's a ~98%
|
||||||
|
/// cache miss on a known walk-path (confirmed via [PathDiag).
|
||||||
|
///
|
||||||
|
/// Method: flood-fill outward from a known-walkable start using
|
||||||
|
/// <see cref="Movement.Movement.CheckMovement"/> — the slow path the cache mirrors. Each
|
||||||
|
/// reached (x, y, z) is a genuine standing state at its TRUE Z (CheckMovement returns the
|
||||||
|
/// destination Z it lands on), exactly the set of states A* would query. For every reached
|
||||||
|
/// state the cache must serve a Hit and agree with the slow path. This naturally follows
|
||||||
|
/// ramped stairs (each tread at its own Z) and climbs to upper floors, so one start covers
|
||||||
|
/// the whole connected structure — no fragile fixed-Z assumption.
|
||||||
|
///
|
||||||
|
/// A bare test world has no spawned items/mobiles, so CheckMovement reduces to static
|
||||||
|
/// walkability (no door/dynamic interference). Parity restricted to cardinal directions:
|
||||||
|
/// the cache stores raw masks and applies the diagonal corner-cut at query time, so a raw
|
||||||
|
/// diagonal bit legitimately differs from CheckMovement's diagonal result.
|
||||||
|
///
|
||||||
|
/// EXPECTED: RED before the standable-surface bake (reached states fall through at their
|
||||||
|
/// true Z); GREEN after.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Sequential Pathfinding Tests")]
|
||||||
|
public class StepCacheStaticSurfaceParityTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public StepCacheStaticSurfaceParityTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// label, start X, Y, Z (a real in-game walkable tile), max states to explore. Seeds are
|
||||||
|
// chosen to span the terrain classes the standable-surface bake must get right; the
|
||||||
|
// flood-fill spreads from each across a wide local area, so a handful of seeds exercises
|
||||||
|
// thousands of distinct (cell, Z) states without an exhaustive whole-map walk.
|
||||||
|
// sewer — static walkway @ z=5 over impassable land; covers dungeon walkways + bridges.
|
||||||
|
// inn — stair foot @ z=10; climbs the stairs onto the 1st & 2nd floors (multi-Z).
|
||||||
|
// plain — open Britain ground; guards against clearance false-positives on flat land.
|
||||||
|
// town — Britain cobblestones near the inn; mixed buildings, stairs, raised floors.
|
||||||
|
[InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)]
|
||||||
|
[InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)]
|
||||||
|
[InlineData("trammel_open_plain", 1500, 1600, 10, 2500)]
|
||||||
|
[InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // plain ground: guards against clearance false-positives
|
||||||
|
public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates)
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
cache.Clear();
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build — expect the cache to answer every state
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
|
||||||
|
var stub = new ParityStubMobile();
|
||||||
|
stub.MoveToWorld(new Point3D(sx, sy, sz), map);
|
||||||
|
|
||||||
|
// Sanity: the start must itself be a walkable standing state via the slow path.
|
||||||
|
var startWalkable = false;
|
||||||
|
for (var d = 0; d < 8; d++)
|
||||||
|
{
|
||||||
|
if (Movement.Movement.CheckMovement(stub, map, new Point3D(sx, sy, sz), (Direction)d, out _))
|
||||||
|
{
|
||||||
|
startWalkable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.True(startWalkable, $"[{label}] start ({sx},{sy},{sz}) is not walkable per the slow path — bad waypoint");
|
||||||
|
|
||||||
|
var visited = new HashSet<(int x, int y, int z)>();
|
||||||
|
var queue = new Queue<(int x, int y, int z)>();
|
||||||
|
visited.Add((sx, sy, sz));
|
||||||
|
queue.Enqueue((sx, sy, sz));
|
||||||
|
|
||||||
|
var states = 0;
|
||||||
|
var fellThrough = 0;
|
||||||
|
var disagreements = 0;
|
||||||
|
const int maxLog = 12;
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var (x, y, z) = queue.Dequeue();
|
||||||
|
states++;
|
||||||
|
|
||||||
|
var loc = new Point3D(x, y, z);
|
||||||
|
var lookup = cache.TryGetMask(map, x, y, (sbyte)z);
|
||||||
|
|
||||||
|
if (!lookup.IsHit)
|
||||||
|
{
|
||||||
|
if (fellThrough < maxLog)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"FELL THROUGH @ ({x},{y},{z}) hitKind={lookup.HitKind}");
|
||||||
|
}
|
||||||
|
fellThrough++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var d = 0; d < 8; d++)
|
||||||
|
{
|
||||||
|
var dir = (Direction)d;
|
||||||
|
var slowOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var nz);
|
||||||
|
|
||||||
|
// Expand the frontier through every legal move (incl. diagonals).
|
||||||
|
if (slowOk)
|
||||||
|
{
|
||||||
|
var nx = x;
|
||||||
|
var ny = y;
|
||||||
|
Movement.Movement.Offset(dir, ref nx, ref ny);
|
||||||
|
var next = (nx, ny, (int)nz);
|
||||||
|
if (visited.Count < maxStates && visited.Add(next))
|
||||||
|
{
|
||||||
|
queue.Enqueue(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parity on cardinals only (diagonals carry the query-time corner-cut rule).
|
||||||
|
if ((d & 1) == 0 && lookup.IsHit)
|
||||||
|
{
|
||||||
|
var cacheOk = lookup.IsWalkable(dir);
|
||||||
|
if (cacheOk != slowOk)
|
||||||
|
{
|
||||||
|
if (disagreements < maxLog)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"WALK DIFF @ ({x},{y},{z}) dir={dir} slow={slowOk} cache={cacheOk}");
|
||||||
|
}
|
||||||
|
disagreements++;
|
||||||
|
}
|
||||||
|
else if (slowOk && nz != lookup.GetWalkZ(dir))
|
||||||
|
{
|
||||||
|
if (disagreements < maxLog)
|
||||||
|
{
|
||||||
|
_output.WriteLine($"Z DIFF @ ({x},{y},{z}) dir={dir} slow={nz} cache={lookup.GetWalkZ(dir)}");
|
||||||
|
}
|
||||||
|
disagreements++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.Delete();
|
||||||
|
|
||||||
|
var fallthroughPct = states == 0 ? 0 : 100.0 * fellThrough / states;
|
||||||
|
_output.WriteLine($"[{label}] states={states} fellThrough={fellThrough} ({fallthroughPct:F2}%) disagreements={disagreements}");
|
||||||
|
|
||||||
|
Assert.True(states > 50, $"[{label}] only explored {states} states — flood-fill stalled, bad waypoint");
|
||||||
|
|
||||||
|
// Correctness is strict: where the cache DOES answer, it must agree with the slow path.
|
||||||
|
Assert.Equal(0, disagreements);
|
||||||
|
|
||||||
|
// Coverage: nearly every reachable state should be cache-served. A small residual is
|
||||||
|
// expected and acceptable — a walkable surface sitting directly under a bridge/stair
|
||||||
|
// ramp falls through to the slow path (correct, just uncached) because the bake's
|
||||||
|
// clearance check is intentionally conservative there. A real anchor regression shows
|
||||||
|
// up as a large fraction (the pre-fix sewer was ~98%), which this still catches.
|
||||||
|
Assert.True(
|
||||||
|
fallthroughPct < 1.0,
|
||||||
|
$"[{label}] cache fell through on {fallthroughPct:F2}% ({fellThrough}/{states}) of reachable states — coverage regression"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default static walker: inherits straight from Mobile so MovementImpl sees no
|
||||||
|
/// BaseCreature flags (CanSwim/CanFly false, bc==null). Mirrors the existing parity stub.
|
||||||
|
/// </summary>
|
||||||
|
private class ParityStubMobile : Mobile
|
||||||
|
{
|
||||||
|
public ParityStubMobile()
|
||||||
|
{
|
||||||
|
Body = 0xC9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -108,6 +108,12 @@ public class BitmapAStarAlgorithm : PathAlgorithm
|
||||||
return null;
|
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.
|
||||||
|
StepCache.Instance.BeginFindGeneration();
|
||||||
|
|
||||||
Server.Engines.Pathing.PathfindRecorder.RecordIfEnabled(m, map, start, goal);
|
Server.Engines.Pathing.PathfindRecorder.RecordIfEnabled(m, map, start, goal);
|
||||||
|
|
||||||
_currentMobileNeedsSlowPath = RequiresSlowPath(m);
|
_currentMobileNeedsSlowPath = RequiresSlowPath(m);
|
||||||
|
|
@ -494,6 +500,8 @@ public class BitmapAStarAlgorithm : PathAlgorithm
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell.
|
/// Per-direction <see cref="CalcMoves.CheckMovement"/> loop for a single source cell.
|
||||||
/// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set.
|
/// Runs on cache fallthrough or when <see cref="_currentMobileNeedsSlowPath"/> is set.
|
||||||
|
/// CheckMovement validates land/statics/items via MovementImpl; dynamic mobile blocking
|
||||||
|
/// is layered on top because MovementImpl doesn't iterate same-cell mobiles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals)
|
private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals)
|
||||||
{
|
{
|
||||||
|
|
@ -510,15 +518,23 @@ public class BitmapAStarAlgorithm : PathAlgorithm
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CalcMoves.CheckMovement(m, map, p3D, (Direction)i, out var z))
|
if (!CalcMoves.CheckMovement(m, map, p3D, (Direction)i, out var z))
|
||||||
{
|
{
|
||||||
var idx = GetIndex(x + _xOffset, y + _yOffset, z);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (idx >= 0 && idx < NodeCount)
|
var absX = x + _xOffset;
|
||||||
{
|
var absY = y + _yOffset;
|
||||||
_nodes[idx].z = z;
|
if (IsBlockedByDynamic(m, map, absX, absY, z))
|
||||||
vals[count++] = idx;
|
{
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = GetIndex(absX, absY, z);
|
||||||
|
if (idx >= 0 && idx < NodeCount)
|
||||||
|
{
|
||||||
|
_nodes[idx].z = z;
|
||||||
|
vals[count++] = idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive
|
/// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive
|
||||||
/// the slow-path fallthrough decision in callers. Ordering is load-bearing:
|
/// the slow-path fallthrough decision in callers. Ordering is load-bearing:
|
||||||
/// values 0-2 are hits, values 3-5 are fallthroughs (see StepMask.IsHit).
|
/// values 0-2 are hits, values 3-6 are fallthroughs (see StepMask.IsHit).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum CacheHitKind : byte
|
public enum CacheHitKind : byte
|
||||||
{
|
{
|
||||||
|
|
@ -13,4 +13,5 @@ public enum CacheHitKind : byte
|
||||||
Fallthrough_MultiZ = 3, // cell has multiple walkable surfaces; caller must use slow path
|
Fallthrough_MultiZ = 3, // cell has multiple walkable surfaces; caller must use slow path
|
||||||
Fallthrough_OffMap = 4, // out of bounds
|
Fallthrough_OffMap = 4, // out of bounds
|
||||||
Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ public readonly struct CacheStats(
|
||||||
long fallthroughMultiZ,
|
long fallthroughMultiZ,
|
||||||
long fallthroughOffMap,
|
long fallthroughOffMap,
|
||||||
long fallthroughSourceZMismatch,
|
long fallthroughSourceZMismatch,
|
||||||
|
long fallthroughNotBuilt,
|
||||||
long evictionsByLruCap,
|
long evictionsByLruCap,
|
||||||
long buildsTotal
|
long buildsTotal
|
||||||
)
|
)
|
||||||
|
|
@ -23,6 +24,7 @@ public readonly struct CacheStats(
|
||||||
public readonly long FallthroughMultiZ = fallthroughMultiZ;
|
public readonly long FallthroughMultiZ = fallthroughMultiZ;
|
||||||
public readonly long FallthroughOffMap = fallthroughOffMap;
|
public readonly long FallthroughOffMap = fallthroughOffMap;
|
||||||
public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch;
|
public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch;
|
||||||
|
public readonly long FallthroughNotBuilt = fallthroughNotBuilt;
|
||||||
public readonly long EvictionsByLruCap = evictionsByLruCap;
|
public readonly long EvictionsByLruCap = evictionsByLruCap;
|
||||||
public readonly long BuildsTotal = buildsTotal;
|
public readonly long BuildsTotal = buildsTotal;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,29 @@ public sealed class StepCache
|
||||||
// with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction.
|
// with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction.
|
||||||
private readonly List<long> _keysList = new();
|
private readonly List<long> _keysList = new();
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
private readonly Dictionary<long, ChunkMissState> _chunkMissTracker = new();
|
||||||
|
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 uint _findGeneration;
|
||||||
|
|
||||||
|
private struct ChunkMissState
|
||||||
|
{
|
||||||
|
public byte MissCount;
|
||||||
|
public uint LastMissTickStamp;
|
||||||
|
public uint LastFindGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
// Telemetry counters
|
// Telemetry counters
|
||||||
private long _hits;
|
private long _hits;
|
||||||
private long _missesNotBuilt;
|
private long _missesNotBuilt;
|
||||||
|
|
@ -31,6 +54,7 @@ public sealed class StepCache
|
||||||
private long _fallthroughMultiZ;
|
private long _fallthroughMultiZ;
|
||||||
private long _fallthroughOffMap;
|
private long _fallthroughOffMap;
|
||||||
private long _fallthroughSourceZMismatch;
|
private long _fallthroughSourceZMismatch;
|
||||||
|
private long _fallthroughNotBuilt;
|
||||||
private long _evictionsByLruCap;
|
private long _evictionsByLruCap;
|
||||||
private long _buildsTotal;
|
private long _buildsTotal;
|
||||||
|
|
||||||
|
|
@ -39,6 +63,43 @@ public sealed class StepCache
|
||||||
/// <summary>Hard cap on resident chunk count. Default 8192. Override for tests / ops.</summary>
|
/// <summary>Hard cap on resident chunk count. Default 8192. Override for tests / ops.</summary>
|
||||||
public int MaxResidentChunks { get; set; } = 8192;
|
public int MaxResidentChunks { get; set; } = 8192;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, <see cref="TryOpenLazyReader"/> immediately materializes every chunk in
|
||||||
|
/// the .swb file into the resident set, paying the file-load cost upfront at boot
|
||||||
|
/// instead of on first query. Trades ~25–50ms boot time per fully-baked map for zero
|
||||||
|
/// first-touch latency in production. Default off — preserves the lazy memory profile.
|
||||||
|
/// </summary>
|
||||||
|
public bool PreloadOnLazyOpen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of misses on the same chunk within <see cref="MissPromotionWindowMs"/>
|
||||||
|
/// required to trigger a build. 1 = eager (legacy behavior). 2 = second-touch (default,
|
||||||
|
/// filters single-touch pass-throughs).
|
||||||
|
/// </summary>
|
||||||
|
public int MissPromotionThreshold { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Window over which misses against the same chunk accumulate toward promotion.
|
||||||
|
/// Misses spaced wider than this restart the count. Default 30s.
|
||||||
|
/// </summary>
|
||||||
|
public uint MissPromotionWindowMs { get; set; } = 30_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public void BeginFindGeneration()
|
||||||
|
{
|
||||||
|
unchecked { _findGeneration++; }
|
||||||
|
if (_findGeneration == 0) { _findGeneration = 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test-only: read the current Find generation.</summary>
|
||||||
|
internal uint CurrentFindGeneration => _findGeneration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pack (mapId, chunkX, chunkY) into a single long key.
|
/// Pack (mapId, chunkX, chunkY) into a single long key.
|
||||||
/// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16].
|
/// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16].
|
||||||
|
|
@ -54,6 +115,7 @@ public sealed class StepCache
|
||||||
fallthroughMultiZ: _fallthroughMultiZ,
|
fallthroughMultiZ: _fallthroughMultiZ,
|
||||||
fallthroughOffMap: _fallthroughOffMap,
|
fallthroughOffMap: _fallthroughOffMap,
|
||||||
fallthroughSourceZMismatch: _fallthroughSourceZMismatch,
|
fallthroughSourceZMismatch: _fallthroughSourceZMismatch,
|
||||||
|
fallthroughNotBuilt: _fallthroughNotBuilt,
|
||||||
evictionsByLruCap: _evictionsByLruCap,
|
evictionsByLruCap: _evictionsByLruCap,
|
||||||
buildsTotal: _buildsTotal
|
buildsTotal: _buildsTotal
|
||||||
);
|
);
|
||||||
|
|
@ -79,12 +141,15 @@ public sealed class StepCache
|
||||||
{
|
{
|
||||||
_chunks.Clear();
|
_chunks.Clear();
|
||||||
_keysList.Clear();
|
_keysList.Clear();
|
||||||
|
_chunkMissTracker.Clear();
|
||||||
|
_findGeneration = 0;
|
||||||
_hits = 0;
|
_hits = 0;
|
||||||
_missesNotBuilt = 0;
|
_missesNotBuilt = 0;
|
||||||
_missesDirtyRebuild = 0;
|
_missesDirtyRebuild = 0;
|
||||||
_fallthroughMultiZ = 0;
|
_fallthroughMultiZ = 0;
|
||||||
_fallthroughOffMap = 0;
|
_fallthroughOffMap = 0;
|
||||||
_fallthroughSourceZMismatch = 0;
|
_fallthroughSourceZMismatch = 0;
|
||||||
|
_fallthroughNotBuilt = 0;
|
||||||
_evictionsByLruCap = 0;
|
_evictionsByLruCap = 0;
|
||||||
_buildsTotal = 0;
|
_buildsTotal = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -123,18 +188,30 @@ public sealed class StepCache
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize;
|
// BakeMap is an explicit decision to populate every chunk; the promotion gate
|
||||||
var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize;
|
// 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.
|
||||||
for (var cy = 0; cy < chunkRows; cy++)
|
var prevThreshold = MissPromotionThreshold;
|
||||||
|
MissPromotionThreshold = 1;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
for (var cx = 0; cx < chunkCols; cx++)
|
var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize;
|
||||||
|
var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize;
|
||||||
|
|
||||||
|
for (var cy = 0; cy < chunkRows; cy++)
|
||||||
{
|
{
|
||||||
// Any sourceZ works — the chunk is built on first access regardless of
|
for (var cx = 0; cx < chunkCols; cx++)
|
||||||
// whether the query returns Hit or Fallthrough_SourceZMismatch.
|
{
|
||||||
TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
|
// Any sourceZ works — the chunk is built on first access regardless of
|
||||||
|
// whether the query returns Hit or Fallthrough_SourceZMismatch.
|
||||||
|
TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
MissPromotionThreshold = prevThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
return SaveToFile(path, mapId);
|
return SaveToFile(path, mapId);
|
||||||
}
|
}
|
||||||
|
|
@ -213,9 +290,54 @@ public sealed class StepCache
|
||||||
"StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}",
|
"StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}",
|
||||||
path, reader.IndexedChunkCount, mapId
|
path, reader.IndexedChunkCount, mapId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (PreloadOnLazyOpen)
|
||||||
|
{
|
||||||
|
PreloadFromLazyReader(mapId, reader);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes every chunk in <paramref name="reader"/> into the resident set.
|
||||||
|
/// Called from <see cref="TryOpenLazyReader"/> when <see cref="PreloadOnLazyOpen"/>
|
||||||
|
/// is set. Skips chunks whose live <see cref="Map.Sector.MultisVersion"/> doesn't
|
||||||
|
/// match the file's snapshot — those will rebake on first query.
|
||||||
|
/// </summary>
|
||||||
|
private void PreloadFromLazyReader(int mapId, StepCacheFile.LazyReader reader)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[mapId];
|
||||||
|
if (map == null || map == Map.Internal)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded = 0;
|
||||||
|
foreach (var (chunkX, chunkY) in reader.EnumerateChunkCoords())
|
||||||
|
{
|
||||||
|
var key = EncodeKey(mapId, chunkX, chunkY);
|
||||||
|
if (_chunks.ContainsKey(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk = TryLoadFromLazyReader(map, chunkX, chunkY);
|
||||||
|
if (chunk == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_chunks[key] = chunk;
|
||||||
|
_keysList.Add(key);
|
||||||
|
loaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Information(
|
||||||
|
"StepCache: preloaded {Loaded} chunks from .swb for map {MapId}", loaded, mapId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of .swb readers currently open. Mostly for tests / telemetry.
|
/// Number of .swb readers currently open. Mostly for tests / telemetry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -324,10 +446,27 @@ public sealed class StepCache
|
||||||
var hitKindResult = CacheHitKind.Hit;
|
var hitKindResult = CacheHitKind.Hit;
|
||||||
if (!_chunks.TryGetValue(key, out var chunk))
|
if (!_chunks.TryGetValue(key, out var chunk))
|
||||||
{
|
{
|
||||||
chunk = ResolveMissingChunk(map, chunkX, chunkY);
|
// Try lazy file first — file-loaded chunks bypass the miss tracker because
|
||||||
_chunks[key] = chunk;
|
// the .swb represents an explicit prior decision to keep this chunk warm.
|
||||||
_keysList.Add(key);
|
chunk = TryLoadFromLazyReader(map, chunkX, chunkY);
|
||||||
hitKindResult = CacheHitKind.Miss_NotBuilt;
|
if (chunk != null)
|
||||||
|
{
|
||||||
|
_chunks[key] = chunk;
|
||||||
|
_keysList.Add(key);
|
||||||
|
hitKindResult = CacheHitKind.Miss_NotBuilt;
|
||||||
|
}
|
||||||
|
else if (ShouldPromoteAfterMiss(key))
|
||||||
|
{
|
||||||
|
chunk = BuildChunk(map, chunkX, chunkY);
|
||||||
|
_chunks[key] = chunk;
|
||||||
|
_keysList.Add(key);
|
||||||
|
hitKindResult = CacheHitKind.Miss_NotBuilt;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_fallthroughNotBuilt++;
|
||||||
|
return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_NotBuilt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -369,6 +508,37 @@ public sealed class StepCache
|
||||||
// because tile reachability shifts at step-height boundaries.
|
// because tile reachability shifts at step-height boundaries.
|
||||||
if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight)
|
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.
|
||||||
|
if (chunk.HasSwimLayer)
|
||||||
|
{
|
||||||
|
var swimSrc = chunk.SwimSourceZ[cellIndex];
|
||||||
|
if (swimSrc != StepChunk.NoSwimLayerCell && Math.Abs(sourceZ - swimSrc) <= StepHeight)
|
||||||
|
{
|
||||||
|
switch (hitKindResult)
|
||||||
|
{
|
||||||
|
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||||
|
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||||
|
case CacheHitKind.Hit: { _hits++; break; }
|
||||||
|
}
|
||||||
|
return new StepMask(
|
||||||
|
0, chunk.SwimMask[cellIndex],
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
chunk.SwimZN_Layer[cellIndex],
|
||||||
|
chunk.SwimZNE_Layer[cellIndex],
|
||||||
|
chunk.SwimZE_Layer[cellIndex],
|
||||||
|
chunk.SwimZSE_Layer[cellIndex],
|
||||||
|
chunk.SwimZS_Layer[cellIndex],
|
||||||
|
chunk.SwimZSW_Layer[cellIndex],
|
||||||
|
chunk.SwimZW_Layer[cellIndex],
|
||||||
|
chunk.SwimZNW_Layer[cellIndex],
|
||||||
|
hitKindResult
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_fallthroughSourceZMismatch++;
|
_fallthroughSourceZMismatch++;
|
||||||
return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_SourceZMismatch);
|
return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_SourceZMismatch);
|
||||||
}
|
}
|
||||||
|
|
@ -404,28 +574,128 @@ public sealed class StepCache
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chunk-miss resolution: try the lazy file reader for this map first; if there's no
|
/// Returns a fresh StepChunk loaded from the lazy file reader, or null if there's no
|
||||||
/// file or no record at this (chunkX, chunkY), fall back to the runtime baker. The
|
/// open reader for the map / no record at (chunkX, chunkY) / the loaded snapshot is
|
||||||
/// file path validates each loaded chunk's MultisVersion against the live sector — a
|
/// stale relative to the live sector's <see cref="Map.Sector.MultisVersion"/>. A null
|
||||||
/// stale snapshot triggers a rebuild rather than serving a wrong answer.
|
/// 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."
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY)
|
private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY)
|
||||||
{
|
{
|
||||||
if (_lazyReaders.TryGetValue(map.MapID, out var reader))
|
if (!_lazyReaders.TryGetValue(map.MapID, out var reader))
|
||||||
{
|
{
|
||||||
var loaded = reader.TryReadChunk(chunkX, chunkY);
|
return null;
|
||||||
if (loaded != null)
|
}
|
||||||
|
var loaded = reader.TryReadChunk(chunkX, chunkY);
|
||||||
|
if (loaded == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var sector = map.GetRealSector(chunkX, chunkY);
|
||||||
|
return loaded.BuiltMultisVersion == sector.MultisVersion ? loaded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a miss for <paramref name="chunkKey"/> 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
|
||||||
|
/// <see cref="MissPromotionThreshold"/>; 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.
|
||||||
|
/// </summary>
|
||||||
|
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.
|
||||||
|
var now = (uint)Environment.TickCount;
|
||||||
|
var gen = _findGeneration;
|
||||||
|
|
||||||
|
if (_chunkMissTracker.TryGetValue(chunkKey, out var state))
|
||||||
|
{
|
||||||
|
// Same Find generation as the last touch — A* expansion is probing this chunk
|
||||||
|
// multiple times in one pathfind. Don't increment; the gate counts distinct
|
||||||
|
// Finds. Skip when gen==0 (no Find started) so legacy single-call tests still
|
||||||
|
// see incrementing behavior.
|
||||||
|
if (gen != 0 && state.LastFindGeneration == gen)
|
||||||
{
|
{
|
||||||
var sector = map.GetRealSector(chunkX, chunkY);
|
return false;
|
||||||
if (loaded.BuiltMultisVersion == sector.MultisVersion)
|
}
|
||||||
|
|
||||||
|
var elapsed = now - state.LastMissTickStamp;
|
||||||
|
if (elapsed > MissPromotionWindowMs)
|
||||||
|
{
|
||||||
|
_chunkMissTracker[chunkKey] = new ChunkMissState
|
||||||
{
|
{
|
||||||
return loaded;
|
MissCount = 1,
|
||||||
}
|
LastMissTickStamp = now,
|
||||||
// Snapshot is stale (multis added/removed since the bake). Fall through
|
LastFindGeneration = gen
|
||||||
// to the runtime baker; a future SaveToFile will overwrite the entry.
|
};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newCount = (byte)Math.Min(state.MissCount + 1, byte.MaxValue);
|
||||||
|
if (newCount >= MissPromotionThreshold)
|
||||||
|
{
|
||||||
|
_chunkMissTracker.Remove(chunkKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_chunkMissTracker[chunkKey] = new ChunkMissState
|
||||||
|
{
|
||||||
|
MissCount = newCount,
|
||||||
|
LastMissTickStamp = now,
|
||||||
|
LastFindGeneration = gen
|
||||||
|
};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MissPromotionThreshold <= 1)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_chunkMissTracker.Count >= MaxMissTrackerEntries)
|
||||||
|
{
|
||||||
|
PruneMissTracker(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
_chunkMissTracker[chunkKey] = new ChunkMissState
|
||||||
|
{
|
||||||
|
MissCount = 1,
|
||||||
|
LastMissTickStamp = now,
|
||||||
|
LastFindGeneration = gen
|
||||||
|
};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void PruneMissTracker(uint now)
|
||||||
|
{
|
||||||
|
var window = MissPromotionWindowMs;
|
||||||
|
var beforeCount = _chunkMissTracker.Count;
|
||||||
|
var toRemove = new List<long>();
|
||||||
|
foreach (var kvp in _chunkMissTracker)
|
||||||
|
{
|
||||||
|
if (now - kvp.Value.LastMissTickStamp > window)
|
||||||
|
{
|
||||||
|
toRemove.Add(kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return BuildChunk(map, chunkX, chunkY);
|
foreach (var k in toRemove)
|
||||||
|
{
|
||||||
|
_chunkMissTracker.Remove(k);
|
||||||
|
}
|
||||||
|
if (_chunkMissTracker.Count == beforeCount)
|
||||||
|
{
|
||||||
|
_chunkMissTracker.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
|
private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
|
||||||
|
|
@ -440,7 +710,12 @@ public sealed class StepCache
|
||||||
// Tier 4 strata accumulator. Lazily allocated when the first multi-Z cell
|
// Tier 4 strata accumulator. Lazily allocated when the first multi-Z cell
|
||||||
// appears; otherwise the chunk has zero strata overhead.
|
// appears; otherwise the chunk has zero strata overhead.
|
||||||
ushort[] strataOffsetByCell = null;
|
ushort[] strataOffsetByCell = null;
|
||||||
System.Collections.Generic.List<byte> strataData = null;
|
List<byte> strataData = null;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
Span<sbyte> surfaceZs = stackalloc sbyte[16];
|
||||||
|
|
||||||
for (var dy = 0; dy < ChunkSize; dy++)
|
for (var dy = 0; dy < ChunkSize; dy++)
|
||||||
{
|
{
|
||||||
|
|
@ -452,10 +727,18 @@ public sealed class StepCache
|
||||||
|
|
||||||
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
||||||
|
|
||||||
// Bake from the slow path's "standing Z" (the surface Z a creature actually
|
// Anchor the cell at the surface a creature actually STANDS on, not the land
|
||||||
// stands at, not the ground avg). A* tracks newZ as standing Z, so SourceZ
|
// average. For plain overworld that's the land; for static-over-land terrain
|
||||||
// must match for the source-Z guard not to over-fire.
|
// (sewer/dungeon walkways, bridges, stair treads, raised foundations, upper
|
||||||
var standingZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
|
// 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.
|
||||||
|
var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaceZs);
|
||||||
|
var standingZ = surfaceCount > 0 ? surfaceZs[0] : (sbyte)Math.Clamp(avgZ, sbyte.MinValue, sbyte.MaxValue);
|
||||||
|
|
||||||
var result = StepProbe.ComputeMaskAt(map, x, y, standingZ);
|
var result = StepProbe.ComputeMaskAt(map, x, y, standingZ);
|
||||||
|
|
||||||
|
|
@ -479,39 +762,69 @@ public sealed class StepCache
|
||||||
chunk.SwimZW[cell] = result.SwimZ_W;
|
chunk.SwimZW[cell] = result.SwimZ_W;
|
||||||
chunk.SwimZNW[cell] = result.SwimZ_NW;
|
chunk.SwimZNW[cell] = result.SwimZ_NW;
|
||||||
|
|
||||||
// Multi-Z handling: if the cell has 2+ reachable surfaces, compute its
|
// Shore-cell handling: if the cell has BOTH a walk surface (standing Z)
|
||||||
// Tier 4 strata so future queries can be answered without falling through
|
// AND a water surface (Wet land tile or wet static) at a Z separated by
|
||||||
// to the slow path. ComputeStrataAt returns null for single-Z cells.
|
// > StepHeight, populate the swim layer at swim-perspective Z. Only when
|
||||||
if (CountReachableSurfaces(map, x, y, standingZ) > 1)
|
// 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.
|
||||||
|
var swimZRaw = StepProbe.ComputeSwimStandingZ(map, x, y);
|
||||||
|
if (swimZRaw != int.MinValue && Math.Abs(swimZRaw - standingZ) > StepHeight)
|
||||||
{
|
{
|
||||||
var strata = StepProbe.ComputeStrataAt(map, x, y);
|
var swimSrc = (sbyte)Math.Clamp(swimZRaw, sbyte.MinValue + 1, sbyte.MaxValue);
|
||||||
if (strata != null)
|
var swimResult = StepProbe.ComputeMaskAt(map, x, y, swimSrc);
|
||||||
|
if (swimResult.WetMask != 0)
|
||||||
{
|
{
|
||||||
if (strataOffsetByCell == null)
|
if (chunk.SwimSourceZ == null)
|
||||||
{
|
{
|
||||||
strataOffsetByCell = new ushort[StepChunk.CellsPerChunk];
|
chunk.AllocateSwimLayer();
|
||||||
for (var i = 0; i < strataOffsetByCell.Length; i++)
|
|
||||||
{
|
|
||||||
strataOffsetByCell[i] = StepChunk.NoStrata;
|
|
||||||
}
|
|
||||||
strataData = new System.Collections.Generic.List<byte>(256);
|
|
||||||
}
|
}
|
||||||
|
chunk.SwimSourceZ[cell] = swimSrc;
|
||||||
|
chunk.SwimMask[cell] = swimResult.WetMask;
|
||||||
|
chunk.SwimZN_Layer[cell] = swimResult.SwimZ_N;
|
||||||
|
chunk.SwimZNE_Layer[cell] = swimResult.SwimZ_NE;
|
||||||
|
chunk.SwimZE_Layer[cell] = swimResult.SwimZ_E;
|
||||||
|
chunk.SwimZSE_Layer[cell] = swimResult.SwimZ_SE;
|
||||||
|
chunk.SwimZS_Layer[cell] = swimResult.SwimZ_S;
|
||||||
|
chunk.SwimZSW_Layer[cell] = swimResult.SwimZ_SW;
|
||||||
|
chunk.SwimZW_Layer[cell] = swimResult.SwimZ_W;
|
||||||
|
chunk.SwimZNW_Layer[cell] = swimResult.SwimZ_NW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cap at 65,535 byte offsets — well above realistic per-chunk
|
// Stacked walkable surfaces at one cell (ground + 1st + 2nd building floors,
|
||||||
// strata volume. If we ever blow past this we'd silently truncate;
|
// a bridge over a walkable path, etc.): bake a stratum per standable surface
|
||||||
// assert as a defensive guard.
|
// so a query at any floor's Z hits. The primary (lowest) surface is also in
|
||||||
if (strataData.Count > ushort.MaxValue - StepChunk.StratumByteLength * 8)
|
// 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.
|
||||||
|
if (surfaceCount >= 2)
|
||||||
|
{
|
||||||
|
if (strataOffsetByCell == null)
|
||||||
|
{
|
||||||
|
strataOffsetByCell = new ushort[StepChunk.CellsPerChunk];
|
||||||
|
for (var i = 0; i < strataOffsetByCell.Length; i++)
|
||||||
{
|
{
|
||||||
// Should never happen for sane tile data; bail to fallthrough.
|
strataOffsetByCell[i] = StepChunk.NoStrata;
|
||||||
}
|
}
|
||||||
else
|
strataData = new List<byte>(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 65,535 byte offsets — well above realistic per-chunk strata
|
||||||
|
// volume. If we ever blow past this we silently leave the cell single-Z
|
||||||
|
// (it keeps the land-anchored main mask and falls through off-surface).
|
||||||
|
if (strataData.Count <= ushort.MaxValue - StepChunk.StratumByteLength * 8)
|
||||||
|
{
|
||||||
|
strataOffsetByCell[cell] = (ushort)strataData.Count;
|
||||||
|
strataData.Add((byte)surfaceCount);
|
||||||
|
for (var i = 0; i < surfaceCount; i++)
|
||||||
{
|
{
|
||||||
strataOffsetByCell[cell] = (ushort)strataData.Count;
|
var sz = surfaceZs[i];
|
||||||
strataData.Add((byte)strata.Length);
|
AppendStratumBytes(strataData, new StepProbe.ComputedStratum(sz, StepProbe.ComputeMaskAt(map, x, y, sz)));
|
||||||
for (var s = 0; s < strata.Length; s++)
|
|
||||||
{
|
|
||||||
AppendStratumBytes(strataData, strata[s]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -593,9 +906,7 @@ public sealed class StepCache
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AppendStratumBytes(
|
private static void AppendStratumBytes(List<byte> dst, in StepProbe.ComputedStratum s)
|
||||||
System.Collections.Generic.List<byte> dst, in StepProbe.ComputedStratum s
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
dst.Add((byte)s.ZCenter);
|
dst.Add((byte)s.ZCenter);
|
||||||
dst.Add(s.Mask.WalkMask);
|
dst.Add(s.Mask.WalkMask);
|
||||||
|
|
@ -620,54 +931,4 @@ public sealed class StepCache
|
||||||
|
|
||||||
private const int PersonHeight = 16;
|
private const int PersonHeight = 16;
|
||||||
private const int StepHeight = 2;
|
private const int StepHeight = 2;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Counts walkable surfaces actually reachable from a creature standing at sourceZ.
|
|
||||||
/// Mirrors <see cref="StepProbe"/>.CheckStaticStep so cells flagged multi-Z
|
|
||||||
/// here are exactly those where the baker would have multiple candidate destinations.
|
|
||||||
/// Reachable when: surface and !impassable; stepTop ≥ itemTop; vertical overlap with
|
|
||||||
/// the creature's PersonHeight envelope.
|
|
||||||
/// </summary>
|
|
||||||
internal static int CountReachableSurfaces(Map map, int x, int y, sbyte sourceZ)
|
|
||||||
{
|
|
||||||
var startTop = sourceZ + PersonHeight;
|
|
||||||
var stepTop = startTop + StepHeight;
|
|
||||||
var count = 0;
|
|
||||||
|
|
||||||
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
|
|
||||||
{
|
|
||||||
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
|
||||||
if (!data.Surface || data.Impassable)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemZ = tile.Z;
|
|
||||||
var itemTop = data.Bridge ? itemZ : itemZ + data.Height;
|
|
||||||
|
|
||||||
if (stepTop < itemTop)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceZ + PersonHeight > itemZ && itemZ + data.Height > sourceZ)
|
|
||||||
{
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Land surface check — same shape, but use GetAverageZ for the land's effective top.
|
|
||||||
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 var landZ, out _, out var landTop);
|
|
||||||
if (stepTop >= landZ && sourceZ + PersonHeight > landZ && landTop > sourceZ)
|
|
||||||
{
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ namespace Server.Engines.Pathing.Cache;
|
||||||
/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
|
/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
|
||||||
/// of file size.
|
/// of file size.
|
||||||
///
|
///
|
||||||
/// File layout v2 (little-endian, BufferWriter / BufferReader convention):
|
/// File layout v3 (little-endian, BufferWriter / BufferReader convention):
|
||||||
///
|
///
|
||||||
/// Header (48 bytes):
|
/// Header (48 bytes):
|
||||||
/// u32 Magic = 0x42575300 ('SWB\0')
|
/// u32 Magic = 0x42575300 ('SWB\0')
|
||||||
/// u32 Version = current FormatVersion (2)
|
/// u32 Version = current FormatVersion (3)
|
||||||
/// u32 MapId
|
/// u32 MapId
|
||||||
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
|
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
|
||||||
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
|
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
|
||||||
|
|
@ -35,11 +35,16 @@ namespace Server.Engines.Pathing.Cache;
|
||||||
/// u16 ChunkY
|
/// u16 ChunkY
|
||||||
/// u32 BuiltMultisVersion
|
/// u32 BuiltMultisVersion
|
||||||
/// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata trailer follows
|
/// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata trailer follows
|
||||||
|
/// u8 HasSwimLayer 0 = no shore cells (no swim trailer); 1 = swim trailer follows
|
||||||
/// byte WalkMask[256]
|
/// byte WalkMask[256]
|
||||||
/// byte WetMask[256]
|
/// byte WetMask[256]
|
||||||
/// sbyte SourceZ[256]
|
/// sbyte SourceZ[256]
|
||||||
/// sbyte WalkZN[256]..WalkZNW[256] (8 arrays in N,NE,E,SE,S,SW,W,NW order)
|
/// sbyte WalkZN[256]..WalkZNW[256] (8 arrays in N,NE,E,SE,S,SW,W,NW order)
|
||||||
/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order)
|
/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order; baked at WALK source-Z)
|
||||||
|
/// // Swim layer trailer — only when HasSwimLayer == 1 (chunks containing shore cells):
|
||||||
|
/// sbyte SwimSourceZ[256] (NoSwimLayerCell sentinel = sbyte.MinValue)
|
||||||
|
/// byte SwimMask[256] (per-cell swim mask baked at SwimSourceZ)
|
||||||
|
/// sbyte SwimZN_Layer[256]..SwimZNW_Layer[256] (8 arrays, dest-Z at swim perspective)
|
||||||
/// // Strata trailer — only when HasStrata == 1:
|
/// // Strata trailer — only when HasStrata == 1:
|
||||||
/// u16 StrataOffsetByCell[256] (NoStrata sentinel = 0xFFFF)
|
/// u16 StrataOffsetByCell[256] (NoStrata sentinel = 0xFFFF)
|
||||||
/// u32 StrataDataLength
|
/// u32 StrataDataLength
|
||||||
|
|
@ -63,15 +68,20 @@ namespace Server.Engines.Pathing.Cache;
|
||||||
internal static class StepCacheFile
|
internal static class StepCacheFile
|
||||||
{
|
{
|
||||||
public const uint Magic = 0x42575300; // 'SWB\0'
|
public const uint Magic = 0x42575300; // 'SWB\0'
|
||||||
public const uint FormatVersion = 2;
|
public const uint FormatVersion = 4;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lowest format version this binary can load. Files below this version are treated as
|
/// Lowest format version this binary can load. Files below this version are treated as
|
||||||
/// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with
|
/// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with
|
||||||
/// the current FormatVersion. Bumped to 2 when Tier 4 multi-Z strata landed; v1 had no
|
/// the current FormatVersion. Bumped to 3 when the swim layer landed (v2 had no swim
|
||||||
/// strata data and is incompatible with the strata-aware lookup path.
|
/// layer). Bumped to 4 when the baker switched to clearance-aware standable-surface
|
||||||
|
/// strata: v3 bakes anchored every cell at the land average and so missed walkable
|
||||||
|
/// static-over-land surfaces (sewer/dungeon walkways, bridges, upper building floors),
|
||||||
|
/// producing ~98% source-Z fallthroughs on those routes. The on-disk layout is
|
||||||
|
/// unchanged; only the strata population differs, so the bump exists purely to force a
|
||||||
|
/// one-time re-bake of stale v3 files on first boot under the new binary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint MinSupportedVersion = 2;
|
public const uint MinSupportedVersion = 4;
|
||||||
|
|
||||||
private const int HeaderSize =
|
private const int HeaderSize =
|
||||||
sizeof(uint) // Magic
|
sizeof(uint) // Magic
|
||||||
|
|
@ -87,15 +97,19 @@ internal static class StepCacheFile
|
||||||
// single bulk read per chunk without consulting the next offset.
|
// single bulk read per chunk without consulting the next offset.
|
||||||
private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint);
|
private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint);
|
||||||
|
|
||||||
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata trailer).</summary>
|
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).</summary>
|
||||||
private const int BytesPerChunkBase =
|
private const int BytesPerChunkBase =
|
||||||
sizeof(ushort) + sizeof(ushort) + sizeof(uint) + sizeof(byte)
|
sizeof(ushort) + sizeof(ushort) + sizeof(uint)
|
||||||
|
+ sizeof(byte) + sizeof(byte) // HasStrata + HasSwimLayer
|
||||||
+ StepChunk.CellsPerChunk // WalkMask
|
+ StepChunk.CellsPerChunk // WalkMask
|
||||||
+ StepChunk.CellsPerChunk // WetMask
|
+ StepChunk.CellsPerChunk // WetMask
|
||||||
+ StepChunk.CellsPerChunk // SourceZ
|
+ StepChunk.CellsPerChunk // SourceZ
|
||||||
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
|
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
|
||||||
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
|
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
|
||||||
|
|
||||||
|
/// <summary>Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays.</summary>
|
||||||
|
private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk;
|
||||||
|
|
||||||
/// <summary>Strata trailer overhead when present: 256×u16 offset table + u32 data length.</summary>
|
/// <summary>Strata trailer overhead when present: 256×u16 offset table + u32 data length.</summary>
|
||||||
private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint);
|
private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint);
|
||||||
|
|
||||||
|
|
@ -201,9 +215,10 @@ internal static class StepCacheFile
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
|
||||||
|
|
||||||
// Initial estimate: base record + a modest strata budget per chunk. BufferWriter
|
// Initial estimate: base record + a modest strata budget per chunk. Coastline
|
||||||
// grows on overflow, so under-estimating just causes a few realloc/copy cycles
|
// chunks add another ~2.5 KB (swim layer) but they're a small fraction of any
|
||||||
// during the bake — not a correctness issue.
|
// map; the writer grows on overflow so under-estimating just causes a few
|
||||||
|
// realloc/copy cycles during the bake — not a correctness issue.
|
||||||
var capacity = HeaderSize
|
var capacity = HeaderSize
|
||||||
+ (BytesPerChunkBase + 256) * (int)chunkCount
|
+ (BytesPerChunkBase + 256) * (int)chunkCount
|
||||||
+ IndexEntryBytes * (int)chunkCount;
|
+ IndexEntryBytes * (int)chunkCount;
|
||||||
|
|
@ -355,7 +370,9 @@ internal static class StepCacheFile
|
||||||
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
|
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
|
||||||
var strataData = chunk.GetStrataDataForSerialization();
|
var strataData = chunk.GetStrataDataForSerialization();
|
||||||
var hasStrata = strataOffsetByCell != null;
|
var hasStrata = strataOffsetByCell != null;
|
||||||
|
var hasSwimLayer = chunk.HasSwimLayer;
|
||||||
w.Write((byte)(hasStrata ? 1 : 0));
|
w.Write((byte)(hasStrata ? 1 : 0));
|
||||||
|
w.Write((byte)(hasSwimLayer ? 1 : 0));
|
||||||
|
|
||||||
w.Write(chunk.WalkMask);
|
w.Write(chunk.WalkMask);
|
||||||
w.Write(chunk.WetMask);
|
w.Write(chunk.WetMask);
|
||||||
|
|
@ -379,6 +396,20 @@ internal static class StepCacheFile
|
||||||
WriteSBytes(w, chunk.SwimZW);
|
WriteSBytes(w, chunk.SwimZW);
|
||||||
WriteSBytes(w, chunk.SwimZNW);
|
WriteSBytes(w, chunk.SwimZNW);
|
||||||
|
|
||||||
|
if (hasSwimLayer)
|
||||||
|
{
|
||||||
|
WriteSBytes(w, chunk.SwimSourceZ);
|
||||||
|
w.Write(chunk.SwimMask);
|
||||||
|
WriteSBytes(w, chunk.SwimZN_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZNE_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZE_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZSE_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZS_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZSW_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZW_Layer);
|
||||||
|
WriteSBytes(w, chunk.SwimZNW_Layer);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasStrata)
|
if (hasStrata)
|
||||||
{
|
{
|
||||||
// 256 × u16 offsets, then u32 length-prefixed strata byte array.
|
// 256 × u16 offsets, then u32 length-prefixed strata byte array.
|
||||||
|
|
@ -403,6 +434,7 @@ internal static class StepCacheFile
|
||||||
r.ReadUShort();
|
r.ReadUShort();
|
||||||
var multisVersion = (int)r.ReadUInt();
|
var multisVersion = (int)r.ReadUInt();
|
||||||
var hasStrata = r.ReadByte() != 0;
|
var hasStrata = r.ReadByte() != 0;
|
||||||
|
var hasSwimLayer = r.ReadByte() != 0;
|
||||||
|
|
||||||
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
|
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
|
||||||
|
|
||||||
|
|
@ -428,6 +460,21 @@ internal static class StepCacheFile
|
||||||
ReadSBytes(r, chunk.SwimZW);
|
ReadSBytes(r, chunk.SwimZW);
|
||||||
ReadSBytes(r, chunk.SwimZNW);
|
ReadSBytes(r, chunk.SwimZNW);
|
||||||
|
|
||||||
|
if (hasSwimLayer)
|
||||||
|
{
|
||||||
|
chunk.AllocateSwimLayer();
|
||||||
|
ReadSBytes(r, chunk.SwimSourceZ);
|
||||||
|
r.Read(chunk.SwimMask);
|
||||||
|
ReadSBytes(r, chunk.SwimZN_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZNE_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZE_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZSE_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZS_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZSW_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZW_Layer);
|
||||||
|
ReadSBytes(r, chunk.SwimZNW_Layer);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasStrata)
|
if (hasStrata)
|
||||||
{
|
{
|
||||||
var offsets = new ushort[StepChunk.CellsPerChunk];
|
var offsets = new ushort[StepChunk.CellsPerChunk];
|
||||||
|
|
@ -472,6 +519,19 @@ internal static class StepCacheFile
|
||||||
|
|
||||||
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
|
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by
|
||||||
|
/// <see cref="StepCache"/> when preload is enabled to materialize all chunks
|
||||||
|
/// upfront instead of on first query.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<(int chunkX, int chunkY)> EnumerateChunkCoords()
|
||||||
|
{
|
||||||
|
foreach (var key in _offsets.Keys)
|
||||||
|
{
|
||||||
|
yield return ((int)(key >> 32), (int)(key & 0xFFFFFFFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal LazyReader(
|
internal LazyReader(
|
||||||
FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
|
FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
|
||||||
uint chunkCount, Dictionary<ulong, (ulong offset, uint length)> offsets
|
uint chunkCount, Dictionary<ulong, (ulong offset, uint length)> offsets
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,91 @@ internal sealed class StepChunk
|
||||||
public readonly sbyte[] SwimZW = new sbyte[CellsPerChunk];
|
public readonly sbyte[] SwimZW = new sbyte[CellsPerChunk];
|
||||||
public readonly sbyte[] SwimZNW = new sbyte[CellsPerChunk];
|
public readonly sbyte[] SwimZNW = new sbyte[CellsPerChunk];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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, <see cref="SwimSourceZ"/>[cell] = <see cref="NoSwimLayerCell"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public const sbyte NoSwimLayerCell = sbyte.MinValue;
|
||||||
|
|
||||||
|
private sbyte[] _swimSourceZ;
|
||||||
|
private byte[] _swimMask;
|
||||||
|
private sbyte[] _swimZN_extra;
|
||||||
|
private sbyte[] _swimZNE_extra;
|
||||||
|
private sbyte[] _swimZE_extra;
|
||||||
|
private sbyte[] _swimZSE_extra;
|
||||||
|
private sbyte[] _swimZS_extra;
|
||||||
|
private sbyte[] _swimZSW_extra;
|
||||||
|
private sbyte[] _swimZW_extra;
|
||||||
|
private sbyte[] _swimZNW_extra;
|
||||||
|
|
||||||
|
/// <summary>True when this chunk has at least one shore cell with a populated swim layer.</summary>
|
||||||
|
public bool HasSwimLayer => _swimSourceZ != null;
|
||||||
|
|
||||||
|
/// <summary>Per-cell water-surface standing Z (or <see cref="NoSwimLayerCell"/>). Null when chunk has no swim layer.</summary>
|
||||||
|
public sbyte[] SwimSourceZ => _swimSourceZ;
|
||||||
|
/// <summary>Per-cell swim mask computed at <see cref="SwimSourceZ"/>. Null when chunk has no swim layer.</summary>
|
||||||
|
public byte[] SwimMask => _swimMask;
|
||||||
|
public sbyte[] SwimZN_Layer => _swimZN_extra;
|
||||||
|
public sbyte[] SwimZNE_Layer => _swimZNE_extra;
|
||||||
|
public sbyte[] SwimZE_Layer => _swimZE_extra;
|
||||||
|
public sbyte[] SwimZSE_Layer => _swimZSE_extra;
|
||||||
|
public sbyte[] SwimZS_Layer => _swimZS_extra;
|
||||||
|
public sbyte[] SwimZSW_Layer => _swimZSW_extra;
|
||||||
|
public sbyte[] SwimZW_Layer => _swimZW_extra;
|
||||||
|
public sbyte[] SwimZNW_Layer => _swimZNW_extra;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily allocates the swim-layer arrays and seeds <see cref="SwimSourceZ"/> with
|
||||||
|
/// the <see cref="NoSwimLayerCell"/> sentinel. Called at bake time the first time a
|
||||||
|
/// shore cell is detected in this chunk.
|
||||||
|
/// </summary>
|
||||||
|
internal void AllocateSwimLayer()
|
||||||
|
{
|
||||||
|
if (_swimSourceZ != null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_swimSourceZ = new sbyte[CellsPerChunk];
|
||||||
|
_swimMask = new byte[CellsPerChunk];
|
||||||
|
_swimZN_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZNE_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZE_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZSE_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZS_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZSW_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZW_extra = new sbyte[CellsPerChunk];
|
||||||
|
_swimZNW_extra = new sbyte[CellsPerChunk];
|
||||||
|
for (var i = 0; i < CellsPerChunk; i++)
|
||||||
|
{
|
||||||
|
_swimSourceZ[i] = NoSwimLayerCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test/serialization hook: install pre-built swim-layer arrays. Pass nulls to clear.</summary>
|
||||||
|
internal void SetSwimLayer(
|
||||||
|
sbyte[] swimSourceZ, byte[] swimMask,
|
||||||
|
sbyte[] zN, sbyte[] zNE, sbyte[] zE, sbyte[] zSE,
|
||||||
|
sbyte[] zS, sbyte[] zSW, sbyte[] zW, sbyte[] zNW
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_swimSourceZ = swimSourceZ;
|
||||||
|
_swimMask = swimMask;
|
||||||
|
_swimZN_extra = zN;
|
||||||
|
_swimZNE_extra = zNE;
|
||||||
|
_swimZE_extra = zE;
|
||||||
|
_swimZSE_extra = zSE;
|
||||||
|
_swimZS_extra = zS;
|
||||||
|
_swimZSW_extra = zSW;
|
||||||
|
_swimZW_extra = zW;
|
||||||
|
_swimZNW_extra = zNW;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays.</summary>
|
/// <summary>Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays.</summary>
|
||||||
public const ushort NoStrata = ushort.MaxValue;
|
public const ushort NoStrata = ushort.MaxValue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,87 @@ public static class StepProbe
|
||||||
return strata;
|
return strata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <paramref name="zs"/>, ascending, and returns the count.
|
||||||
|
///
|
||||||
|
/// This is the clearance-aware counterpart to <see cref="ComputeStrataAt"/>'s candidate
|
||||||
|
/// gather: 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.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </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.GetStaticAndMultiTiles(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)
|
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ)
|
||||||
{
|
{
|
||||||
if (map == null || map == Map.Internal)
|
if (map == null || map == Map.Internal)
|
||||||
|
|
@ -166,6 +247,45 @@ public static class StepProbe
|
||||||
return 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)
|
||||||
|
{
|
||||||
|
return int.MinValue;
|
||||||
|
}
|
||||||
|
if (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.GetStaticAndMultiTiles(x, y))
|
||||||
|
{
|
||||||
|
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||||
|
if (data.Wet)
|
||||||
|
{
|
||||||
|
return tile.Z + data.CalcHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return int.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
|
/// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ namespace Server.Engines.Pathing;
|
||||||
/// Admin commands for inspecting and operating the pathfinding step cache.
|
/// Admin commands for inspecting and operating the pathfinding step cache.
|
||||||
/// [PathCacheStats — current resident-chunk count + hit/miss/eviction telemetry.
|
/// [PathCacheStats — current resident-chunk count + hit/miss/eviction telemetry.
|
||||||
/// [PathCacheClear — drop all cached chunks, close lazy readers, zero counters.
|
/// [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.
|
/// [PathCacheSave — persist resident chunks per map to Data/Pathfinding/<mapId>.swb.
|
||||||
/// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup.
|
/// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup.
|
||||||
/// [PathRecord — toggle JSONL telemetry capture for replay / benchmark corpora.
|
/// [PathRecord — toggle JSONL telemetry capture for replay / benchmark corpora.
|
||||||
|
///
|
||||||
|
/// 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; <see cref="OnPathBake"/> is how you produce one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PathCacheCommands
|
public static class PathCacheCommands
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +35,7 @@ public static class PathCacheCommands
|
||||||
|
|
||||||
CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats);
|
CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats);
|
||||||
CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear);
|
CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear);
|
||||||
|
CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake);
|
||||||
CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave);
|
CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave);
|
||||||
CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad);
|
CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad);
|
||||||
CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord);
|
CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord);
|
||||||
|
|
@ -78,6 +84,55 @@ public static class PathCacheCommands
|
||||||
e.Mobile.SendMessage($"StepCache cleared: {residentBefore} chunks dropped, counters reset.");
|
e.Mobile.SendMessage($"StepCache cleared: {residentBefore} chunks dropped, counters reset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Usage("PathBake [mapId]")]
|
||||||
|
[Description("Walks every chunk of the given map (or all loaded maps) building the full static step cache, then saves it to Data/Pathfinding/<mapId>.swb so a future boot has zero first-pathfind latency. WARNING: blocks the game loop for several seconds and transiently uses hundreds of MB per map — run during maintenance, not peak hours.")]
|
||||||
|
private static void OnPathBake(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var from = e.Mobile;
|
||||||
|
int? only = e.Arguments.Length > 0 && int.TryParse(e.Arguments[0], out var parsed) ? parsed : null;
|
||||||
|
|
||||||
|
from.SendMessage("PathBake: building the static step cache. The server will pause briefly per map...");
|
||||||
|
|
||||||
|
var totalChunks = 0;
|
||||||
|
var totalMaps = 0;
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
|
for (var i = 0; i < Map.Maps.Length; i++)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[i];
|
||||||
|
if (map == null || map == Map.Internal || (only.HasValue && map.MapID != only.Value))
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
var written = StepCache.Instance.BakeMap(map.MapID, PathFor(map.MapID));
|
||||||
|
StepCache.Instance.ClearResidentChunks();
|
||||||
|
|
||||||
|
if (written > 0)
|
||||||
|
{
|
||||||
|
totalChunks += written;
|
||||||
|
totalMaps++;
|
||||||
|
from.SendMessage($" map {map.MapID}: {written} chunks → {PathFor(map.MapID)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
if (totalMaps == 0)
|
||||||
|
{
|
||||||
|
from.SendMessage(only.HasValue ? $"PathBake: map {only.Value} not loaded." : "PathBake: no maps to bake.");
|
||||||
|
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).
|
||||||
|
AutoLoadAtStartup();
|
||||||
|
from.SendMessage($"PathBake: {totalChunks} chunks across {totalMaps} map(s) in {sw.Elapsed.TotalSeconds:F1}s; lazy readers reopened.");
|
||||||
|
}
|
||||||
|
|
||||||
[Usage("PathCacheSave")]
|
[Usage("PathCacheSave")]
|
||||||
[Description("Persists resident StepCache chunks for every loaded map to Data/Pathfinding/<mapId>.swb.")]
|
[Description("Persists resident StepCache chunks for every loaded map to Data/Pathfinding/<mapId>.swb.")]
|
||||||
private static void OnPathCacheSave(CommandEventArgs e)
|
private static void OnPathCacheSave(CommandEventArgs e)
|
||||||
|
|
|
||||||
205
Projects/UOContent/Engines/Pathing/PathDiag.cs
Normal file
205
Projects/UOContent/Engines/Pathing/PathDiag.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using Server.Engines.Pathing.Cache;
|
||||||
|
using Server.PathAlgorithms.BitmapAStar;
|
||||||
|
using Server.Targeting;
|
||||||
|
|
||||||
|
namespace Server.Engines.Pathing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Developer diagnostic for the bitmap A* step cache. Stand where a creature would start,
|
||||||
|
/// run <c>[PathDiag</c>, and target the goal. The detailed report is appended to
|
||||||
|
/// <c>Logs/pathdiag.log</c>; 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 <see cref="StepCache.TryGetMask"/>-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.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class PathDiag
|
||||||
|
{
|
||||||
|
private const int TimingIterations = 200;
|
||||||
|
|
||||||
|
private static string LogPath => Path.Combine(Core.BaseDirectory, "Logs", "pathdiag.log");
|
||||||
|
|
||||||
|
public static void Configure()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("PathDiag", AccessLevel.Administrator, OnPathDiag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("PathDiag")]
|
||||||
|
[Description("Diagnoses the step cache for a route (results appended to Logs/pathdiag.log): target a tile to record start/goal tile makeup, the per-Find cache hit/fallthrough breakdown, and warm timing.")]
|
||||||
|
private static void OnPathDiag(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var start = e.Mobile.Location;
|
||||||
|
e.Mobile.SendMessage("PathDiag: target the goal tile.");
|
||||||
|
e.Mobile.BeginTarget(-1, true, TargetFlags.None, (from, targeted) => OnTarget(from, start, targeted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnTarget(Mobile from, Point3D start, object targeted)
|
||||||
|
{
|
||||||
|
if (targeted is not IPoint3D p)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var map = from.Map;
|
||||||
|
var goal = new Point3D(p.X, p.Y, p.Z);
|
||||||
|
|
||||||
|
if (!Utility.InRange(start, goal, 38))
|
||||||
|
{
|
||||||
|
from.SendMessage("PathDiag: goal is outside the A* search window (38 tiles); aborting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
var previousThreshold = cache.MissPromotionThreshold;
|
||||||
|
cache.MissPromotionThreshold = 1; // eager build — measure the cache's best case
|
||||||
|
|
||||||
|
StreamWriter log = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
||||||
|
log = new StreamWriter(new FileStream(LogPath, FileMode.Append, FileAccess.Write, FileShare.Read));
|
||||||
|
|
||||||
|
log.WriteLine($"===== [{Core.Now:yyyy-MM-dd HH:mm:ss}] PathDiag ({start.X},{start.Y},{start.Z}) -> ({goal.X},{goal.Y},{goal.Z}) on {map} =====");
|
||||||
|
|
||||||
|
DumpCell(log, map, start.X, start.Y, start.Z, "start");
|
||||||
|
DumpCell(log, map, goal.X, goal.Y, goal.Z, "goal");
|
||||||
|
var find = RunInstrumentedFind(log, from, map, start, goal);
|
||||||
|
var (minUs, avgUs) = TimeWarm(log, from, map, start, goal);
|
||||||
|
log.WriteLine();
|
||||||
|
|
||||||
|
from.SendMessage($"PathDiag ({start.X},{start.Y},{start.Z})->({goal.X},{goal.Y},{goal.Z}): {find.result}");
|
||||||
|
from.SendMessage($" cache fallthrough {find.fallthroughPct:F1}% of {find.total}; warm min={minUs:F1}us avg={avgUs:F1}us");
|
||||||
|
from.SendMessage($" full report appended to Logs/pathdiag.log");
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
from.SendMessage($"PathDiag: failed to write {LogPath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
log?.Dispose();
|
||||||
|
cache.MissPromotionThreshold = previousThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private static void DumpCell(TextWriter log, Map map, int x, int y, int queryZ, string label)
|
||||||
|
{
|
||||||
|
map.GetAverageZ(x, y, out var landZ, out var avgZ, out var landTop);
|
||||||
|
var landTile = map.Tiles.GetLandTile(x, y);
|
||||||
|
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
||||||
|
var landImpassable = (landFlags & TileFlag.Impassable) != 0;
|
||||||
|
var landWet = (landFlags & TileFlag.Wet) != 0;
|
||||||
|
|
||||||
|
Span<sbyte> surfaces = stackalloc sbyte[16];
|
||||||
|
var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaces);
|
||||||
|
|
||||||
|
log.WriteLine($"{label} cell ({x},{y}) queryZ={queryZ}:");
|
||||||
|
log.WriteLine($" land: avgZ={avgZ} landZ={landZ} landTop={landTop} impassable={landImpassable} wet={landWet} ignored={landTile.Ignored}");
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
for (var i = 0; i < surfaceCount; i++)
|
||||||
|
{
|
||||||
|
sb.Append(i == 0 ? "" : ",").Append(surfaces[i]);
|
||||||
|
}
|
||||||
|
log.WriteLine($" standable surfaces={surfaceCount} [{sb}] -> {(surfaceCount >= 2 ? "multi-Z (strata)" : "single-Z anchor")}");
|
||||||
|
|
||||||
|
log.WriteLine(" static/multi surfaces:");
|
||||||
|
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
|
||||||
|
{
|
||||||
|
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||||
|
log.WriteLine($" id=0x{tile.ID:X4} z={tile.Z} top={tile.Z + data.CalcHeight} h={data.Height} surface={data.Surface} impass={data.Impassable} bridge={data.Bridge} wet={data.Wet}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static (string result, double fallthroughPct, long total) RunInstrumentedFind(
|
||||||
|
TextWriter log, Mobile from, Map map, Point3D start, Point3D goal
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var cache = StepCache.Instance;
|
||||||
|
|
||||||
|
// Warm every chunk the route touches before measuring.
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
BitmapAStarAlgorithm.Instance.Find(from, map, start, goal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = cache.GetStats();
|
||||||
|
var path = BitmapAStarAlgorithm.Instance.Find(from, map, start, goal);
|
||||||
|
var after = cache.GetStats();
|
||||||
|
|
||||||
|
var served = after.Hits - before.Hits
|
||||||
|
+ (after.MissesNotBuilt - before.MissesNotBuilt)
|
||||||
|
+ (after.MissesDirtyRebuild - before.MissesDirtyRebuild);
|
||||||
|
var fallthrough = after.FallthroughMultiZ - before.FallthroughMultiZ
|
||||||
|
+ (after.FallthroughSourceZMismatch - before.FallthroughSourceZMismatch)
|
||||||
|
+ (after.FallthroughOffMap - before.FallthroughOffMap)
|
||||||
|
+ (after.FallthroughNotBuilt - before.FallthroughNotBuilt);
|
||||||
|
var total = served + fallthrough;
|
||||||
|
var pct = total == 0 ? 0 : 100.0 * fallthrough / total;
|
||||||
|
var result = path == null ? "NO PATH" : $"{path.Length} steps";
|
||||||
|
|
||||||
|
log.WriteLine($"warm Find: {result}");
|
||||||
|
log.WriteLine($" cache-served={served} fallthrough={fallthrough} ({pct:F1}% of {total} probes)");
|
||||||
|
log.WriteLine($" fallthrough breakdown: multiZ={after.FallthroughMultiZ - before.FallthroughMultiZ} " +
|
||||||
|
$"srcZ={after.FallthroughSourceZMismatch - before.FallthroughSourceZMismatch} " +
|
||||||
|
$"offMap={after.FallthroughOffMap - before.FallthroughOffMap} " +
|
||||||
|
$"notBuilt={after.FallthroughNotBuilt - before.FallthroughNotBuilt}");
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
log.WriteLine(" NO PATH: goal unreachable within the 38-tile window (or not standable). This is an A* scope limit, independent of the cache.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result, pct, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double minUs, double avgUs) TimeWarm(TextWriter log, Mobile from, Map map, Point3D start, Point3D goal)
|
||||||
|
{
|
||||||
|
var sw = new Stopwatch();
|
||||||
|
var minTicks = long.MaxValue;
|
||||||
|
long totalTicks = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < TimingIterations; i++)
|
||||||
|
{
|
||||||
|
sw.Restart();
|
||||||
|
BitmapAStarAlgorithm.Instance.Find(from, map, start, goal);
|
||||||
|
sw.Stop();
|
||||||
|
totalTicks += sw.ElapsedTicks;
|
||||||
|
if (sw.ElapsedTicks < minTicks)
|
||||||
|
{
|
||||||
|
minTicks = sw.ElapsedTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var usPerTick = 1_000_000.0 / Stopwatch.Frequency;
|
||||||
|
var minUs = minTicks * usPerTick;
|
||||||
|
var avgUs = totalTicks * usPerTick / TimingIterations;
|
||||||
|
log.WriteLine($"timing over {TimingIterations} warm Finds: min={minUs:F1}us avg={avgUs:F1}us");
|
||||||
|
return (minUs, avgUs);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue