perf(pathing): pool the StepCache strata buffer, then clean up the pathing engine around it (#2523)

Started as an allocation pass over `StepCache` and grew into a cleanup of the surrounding pathing engine. Four commits, each independently reviewable; net **−560 lines**.

Build clean (0 warnings). All 122 `Server.Tests.Pathfinding` tests pass.

---

## 1. `perf`: pool the strata buffer, cut a hot-path dictionary lookup

**The headline is that `TryGetMask` — the actual hot path — was already allocation-free.** `StepMask` is a readonly struct, `StaticTileEnumerable` is a `ref struct`, `ChunkMissState` is a struct in a `Dictionary`. So most of this is a bake-throughput and GC-churn win, with one exception noted below.

`BuildChunk` accumulated packed multi-Z strata into a `List<byte>` that grew by doubling (256 → 512 → 1024 → …) and then paid a final `ToArray()`. A full map bake runs it ~114k times. It now writes into a `byte[]` rented from `STArrayPool<byte>.Shared` through a span writer, and hands the chunk one exact-size copy.

**This required fixing a latent out-of-bounds guard.** The record-fit check reserved headroom for **8** strata (`StratumByteLength * 8`) while `ComputeStandableSurfaceZs` can return up to **16** — so a cell could write 305 bytes starting from a 65,383-byte offset. Against a `List` that was benign (it just grew past 64 KB, and emitted offsets stayed under the `NoStrata` sentinel). Against a fixed-size rented buffer it is an out-of-bounds write, so tightening it was a *prerequisite* for the pooling, not a drive-by. The guard is now exact, which additionally proves no emitted offset can collide with `NoStrata == ushort.MaxValue`.

**One genuine query-path win:** `ShouldPromoteAfterMiss` did *two* dictionary lookups per miss — a `TryGetValue`, then an indexer assignment that re-hashes and re-probes. It now mutates in place via `CollectionsMarshal.GetValueRefOrNullRef`. This runs on every uncached chunk touch during A* expansion. The window-expiry branch keeps its explicit early return, so `MissPromotionThreshold == 1` still resets rather than promoting.

Also dropped `StepProbe.ComputeStrataAt` / `ComputedStratum` (dead code, zero callers) and collapsed six 18-argument `new StepMask(0, 0, …, kind)` blocks into `Fallthrough(kind)`.

**Considered and rejected:** pooling the `Direction[]` that `Find` returns. It *escapes* the call — `MovementPath` holds it across ticks while `PathFollower` walks `m_Index` through it — so it cannot be rented-and-returned, and it cannot be borrowed from the shared `BitmapAStarAlgorithm.Instance` without one creature clobbering another's in-flight path. `CheckPath` rate-limits repaths to one per 2s per creature, putting this at roughly 60 KB/sec at 1,000 pathing creatures. Not worth a public API break plus a use-after-return footgun.

## 2. `docs`: rewrite the comments for publication

The comments had accumulated as development notes: internal phase jargon (`Tier 4`, `the Phase-2 synthesizer`), change narration aimed at a reviewer (`which the old ComputeStandingZ anchor missed`, `legacy behavior`), benchmark anecdotes (`benchmarked as near-optimal`, `a ~20 ns lookup`), and paragraphs restating the code.

Rewritten to keep the rationale you cannot recover by reading the code — why the source-Z guard cannot be widened, why multis fall through with a halo, why the promotion gate counts Finds rather than calls, why `ComputeFingerprint` must hash the *files* and not the live tile tables — and drop the history that got us there.

Three comments were **factually wrong**, not just wordy:

- `CacheEvictionTimer` and `CacheStats` documented a class called `StaticWalkabilityCache`. No such class exists — it is `StepCache`.
- `StepCacheFile` declared `File layout v8` while `FormatVersion` is 9, and called the current record layout "the v6 layout" in four places. The layout descriptions are now unversioned so they cannot drift again.
- `StepProbe.ComputeStandingZ` claimed `StepCache` uses it to bake `SourceZ`. It has not since the baker moved to the clearance-aware `ComputeStandableSurfaceZs`; only a parity test calls it.

## 3. `refactor`: simplify `StepCacheFile.Write`, consolidate the format tests

`SaveToFile` walked `_keysList` **twice** — once to count the map's chunks, then again through a `ChunkEnumerator` closure to emit them — because `Write` needed the count up front to size its index array. Both loops had the same root cause. Passing a **span** collapses them: the count is just `span.Length`.

That deletes the `ChunkEnumerator` delegate, the closure over the list enumerator, and **both `InvalidOperationException` throws**, which existed only to police the delegate's "yield exactly `chunkCount` chunks" contract — a contract a span makes unrepresentable.

`Write` now patches the header's `IndexOffset` by seeking back to it rather than reaching into the writer's live buffer with `BinaryPrimitives`. That also retires `IndexOffsetFieldPosition`, a hand-maintained byte offset that had to track the header layout, and sidesteps the stale-array hazard that motivated the manual patch (`BufferWriter` reallocates on growth).

**Tests:** `StepCacheFileV6/V7/V8Tests` were named for the format version that introduced each transform — and the format is now **v9**, so all three names described formats the loader rejects outright. Beyond triplicated builders and plumbing, two things were actually broken:

- The three near-identical rejection tests each cited a `MinSupportedVersion` that had since moved (`"version 5 < MinSupportedVersion 6"`, `"6 < 7"`, `"7 < 8"`). They passed for the wrong reason.
- `AssertBaseEqual` (used by V7 and V8) **silently skipped the swim and strata trailers**. A regression dropping either would not have failed those tests.

Now one `StepCacheFileFormatTests`, named for behavior — predictive-Z elision, compression, compact index — with a single `AssertIdentical` that does check both trailers, the three rejection tests folded into one theory that also covers a future version, and a zero-chunk case the delegate-based writer never had coverage for.

## 4. `test`: consolidate the parity and lifecycle tests

Three files tested "parity" and none of the names said *which*. They were three different layers, and the seams are the useful part, so they are now one `StepCacheParityTests` that names them:

| Test | Compares | Answers |
|---|---|---|
| `ProbeMatchesSlowPath` | StepProbe vs MovementImpl | Is the bake right? |
| `CacheMatchesProbe` | StepCache vs StepProbe | Is it stored and returned intact? |
| `CacheServesReachableWalkStates` | StepCache vs MovementImpl | End to end, over the states A* visits |

Merging removed a duplicated stub `Mobile`, duplicated region seeds, and a filename/class mismatch (`StepProbeParityTests.cs` declared `StaticWalkabilityParityTests`). `SwimBake_ProducesWetCells` moved with it — it lived in the cache parity file but never touched the cache.

Tests reached into `StepCache._chunks` via `GetField` in **9 places**, each rebuilding the key encoding and cell-index arithmetic by hand. `StepCache` now exposes `GetResidentChunk` and `ResidentIndexInSync` alongside the internal test hooks it already had (`LazyReaderHasChunk`, `CurrentFindGeneration`), and the shared arithmetic moved to `PathingTestSupport`. All 9 reflection blocks are gone.

`StepCacheLifecycleTests` is regrouped by what it covers — promotion gate, fallthrough routes, strata, swim layer, eviction — with the `Tier4*` names dropped. Removed `Singleton_IsAvailable`, which asserted an inline-initialized static property was not null; that is the entire 123 → 122 test-count delta.

---

## Verification

Tests were mutation-checked rather than just run, since round-trip and parity tests can pass while a transform silently no-ops:

- Injecting an off-by-one into the `IndexOffset` patch fails **15 of 123** — the format tests are load-bearing.
- Offsetting the cache's cell index by one fails **7 of 10** parity cases, and the 3 that stay green are exactly the ones that do not touch the cache. The layering localizes a fault rather than just reporting one.
This commit is contained in:
Kamron Batman 2026-07-12 20:02:29 -07:00 committed by GitHub
parent e035768ef8
commit b852bca41e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1663 additions and 2223 deletions

View file

@ -0,0 +1,69 @@
using System;
using Server.Engines.Pathing.Cache;
namespace Server.Tests.Pathfinding;
/// <summary>
/// Shared fixtures for the step-cache tests: the walker the parity tests measure against, the
/// cell-index arithmetic, and builders for the chunk state several tests inject by hand.
/// </summary>
internal static class PathingTestSupport
{
/// <summary>
/// Trammel. Every seed coordinate below is a real location on it, so these tests need the
/// client's map files; they skip when those are absent.
/// </summary>
public static Map TestMap => Map.Maps[1];
/// <summary>
/// A cell in open Britain countryside — flat, walkable in all directions, no statics. The
/// default subject when a test needs a chunk to exist and doesn't care what's in it.
/// </summary>
public const int PlainX = 1500;
public const int PlainY = 1600;
/// <summary>Index of world cell (x, y) within its own chunk.</summary>
public static int CellIndex(int x, int y) => ((y & 15) << 4) | (x & 15);
/// <summary>A strata offset table with every cell marked single-Z.</summary>
public static ushort[] NoStrataOffsets()
{
var offsets = new ushort[StepChunk.CellsPerChunk];
Array.Fill(offsets, StepChunk.NoStrata);
return offsets;
}
/// <summary>
/// Packs a one-stratum record: a count byte, then the stratum itself. Directions not named in
/// <paramref name="walkZs"/> stay at 0. Mirrors the layout StepCache.WriteStratum produces.
/// </summary>
public static byte[] OneStratum(sbyte zCenter, byte walkMask = 0, byte wetMask = 0, params sbyte[] walkZs)
{
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1; // stratum count
data[1] = (byte)zCenter;
data[2] = walkMask;
data[3] = wetMask;
// walkZ_N..NW occupy bytes 4..11; swimZ_N..NW follow at 12..19.
for (var i = 0; i < walkZs.Length && i < 8; i++)
{
data[4 + i] = (byte)walkZs[i];
}
return data;
}
/// <summary>
/// The default static walker. Deriving straight from <see cref="Mobile"/> rather than
/// BaseCreature is the point: MovementImpl then sees no creature capabilities (no swim, no fly,
/// no door-opening), which is exactly the walker the cache bakes for.
/// </summary>
public sealed class StaticWalker : Mobile
{
public StaticWalker()
{
Body = 0xC9;
}
}
}

View file

@ -0,0 +1,406 @@
using System;
using System.Collections.Generic;
using System.IO;
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
/// <summary>
/// The .swb encoding, exercised through Write → OpenForLazy → TryReadChunk. Three transforms stack
/// in a record and each can silently corrupt the ones under it, so every chunk shape here is
/// asserted byte-identical after a round trip:
///
/// predictive-Z — a directional-Z array that matches its prediction is omitted entirely,
/// compression — each record deflates independently, or stores raw when that doesn't shrink it,
/// compact index — the trailer carries no file offsets; the reader sums record lengths instead.
///
/// Several tests assert on file size, because a round trip alone cannot tell you a transform ran:
/// an encoder that elided nothing and compressed nothing would still round-trip perfectly.
/// </summary>
[Collection("Sequential Pathfinding Tests")]
public class StepCacheFileFormatTests
{
// ---- chunk builders ----
/// <summary>
/// Per-cell varying masks and Zs. Nothing about it is uniform or predictable, so it exercises
/// the Full record with residual arrays present.
/// </summary>
private static StepChunk VariedChunk(int seed = 0)
{
var c = new StepChunk();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)((i + seed) & 0xFF);
c.WetMask[i] = (byte)((i * 7 + seed) & 0xFF);
c.SourceZ[i] = (sbyte)((i + seed) % 40 - 20);
c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3);
c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2);
}
return c;
}
/// <summary>Every cell identical — the Uniform record, ~28 bytes on disk.</summary>
private static StepChunk UniformChunk(sbyte z = 10)
{
var c = new StepChunk();
Array.Fill(c.WalkMask, (byte)0xC1);
Array.Fill(c.SourceZ, z);
foreach (var arr in AllBaseZArrays(c))
{
Array.Fill(arr, z);
}
return c;
}
/// <summary>
/// Flat terrain, but NOT uniform: masks and SourceZ vary per cell while every directional Z
/// equals its masked prediction. That is the exact shape predictive-Z is built for, so all 16
/// arrays must elide. It doubles as the coastline case — partial walkability, non-zero SourceZ,
/// and 0 in every blocked direction, which is where a naive (unmasked) predictor would emit a
/// -SourceZ residual on every blocked direction and elide nothing.
/// </summary>
private static StepChunk FlatFullChunk(sbyte baseZ = 10)
{
var c = new StepChunk();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)(i & 0xFF);
c.WetMask[i] = (byte)(~i & 0xFF);
c.SourceZ[i] = (sbyte)(baseZ + i % 7 - 3);
}
var walk = new[] { c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW };
var swim = new[] { c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
for (var b = 0; b < 8; b++)
{
walk[b][i] = (sbyte)((c.WalkMask[i] >> b & 1) != 0 ? c.SourceZ[i] : 0);
swim[b][i] = (sbyte)((c.WetMask[i] >> b & 1) != 0 ? c.SourceZ[i] : 0);
}
}
return c;
}
private static StepChunk WithSwimLayer(StepChunk c)
{
c.AllocateSwimLayer();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.SwimSourceZ[i] = (sbyte)(i % 30 - 15);
c.SwimMask[i] = (byte)(i * 5 & 0xFF);
c.SwimZN_Layer[i] = (sbyte)(i % 7);
c.SwimZNW_Layer[i] = (sbyte)-(i % 4);
}
return c;
}
private static StepChunk WithStrataAt(StepChunk c, int cell)
{
var offsets = new ushort[StepChunk.CellsPerChunk];
Array.Fill(offsets, StepChunk.NoStrata);
offsets[cell] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1;
c.SetStrata(offsets, data);
return c;
}
private static sbyte[][] AllBaseZArrays(StepChunk c) =>
[
c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW,
c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW
];
private static sbyte[][] AllSwimLayerArrays(StepChunk c) =>
[
c.SwimZN_Layer, c.SwimZNE_Layer, c.SwimZE_Layer, c.SwimZSE_Layer,
c.SwimZS_Layer, c.SwimZSW_Layer, c.SwimZW_Layer, c.SwimZNW_Layer
];
// ---- round-trip plumbing ----
private static string Write(params (int cx, int cy, StepChunk c)[] chunks)
{
var path = Path.Combine(Path.GetTempPath(), $"swb_{Guid.NewGuid():N}.swb");
StepCacheFile.Write(path, 1u, chunks);
return path;
}
private static StepChunk RoundTrip(StepChunk src, out long fileLength, int cx = 3, int cy = 4)
{
var path = Write((cx, cy, src));
try
{
fileLength = new FileInfo(path).Length;
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
var rt = reader!.TryReadChunk(cx, cy);
Assert.NotNull(rt);
return rt!;
}
finally
{
File.Delete(path);
}
}
private static StepChunk RoundTrip(StepChunk src) => RoundTrip(src, out _);
private static void AssertIdentical(StepChunk expected, StepChunk actual)
{
Assert.True(expected.WalkMask.AsSpan().SequenceEqual(actual.WalkMask), "WalkMask differs");
Assert.True(expected.WetMask.AsSpan().SequenceEqual(actual.WetMask), "WetMask differs");
Assert.True(expected.SourceZ.AsSpan().SequenceEqual(actual.SourceZ), "SourceZ differs");
var ez = AllBaseZArrays(expected);
var az = AllBaseZArrays(actual);
for (var i = 0; i < ez.Length; i++)
{
Assert.True(ez[i].AsSpan().SequenceEqual(az[i]), $"base Z array {i} differs");
}
Assert.Equal(expected.HasSwimLayer, actual.HasSwimLayer);
if (expected.HasSwimLayer)
{
Assert.True(expected.SwimSourceZ.AsSpan().SequenceEqual(actual.SwimSourceZ), "SwimSourceZ differs");
Assert.True(expected.SwimMask.AsSpan().SequenceEqual(actual.SwimMask), "SwimMask differs");
var el = AllSwimLayerArrays(expected);
var al = AllSwimLayerArrays(actual);
for (var i = 0; i < el.Length; i++)
{
Assert.True(el[i].AsSpan().SequenceEqual(al[i]), $"swim-layer Z array {i} differs");
}
}
Assert.True(expected.StrataData.SequenceEqual(actual.StrataData), "StrataData differs");
}
// ---- predictive-Z transform ----
[Theory]
[InlineData((sbyte)0, (sbyte)0)]
[InlineData((sbyte)10, (sbyte)10)]
[InlineData((sbyte)0, (sbyte)10)]
[InlineData((sbyte)10, (sbyte)0)]
[InlineData((sbyte)-20, (sbyte)15)]
[InlineData(sbyte.MinValue, sbyte.MaxValue)]
[InlineData(sbyte.MaxValue, sbyte.MinValue)]
[InlineData(sbyte.MinValue, (sbyte)1)]
[InlineData((sbyte)127, (sbyte)-1)]
public void Residual_RoundTripsLosslessly_AcrossTheFullSByteRange(sbyte z, sbyte predict)
{
var residual = StepCacheFile.EncodeResidual(z, predict);
Assert.Equal(z, StepCacheFile.DecodeZ(predict, residual));
}
[Theory]
[InlineData((byte)0b0000_0001, 0, (sbyte)42, (sbyte)42)] // passable -> predict SourceZ
[InlineData((byte)0b0000_0000, 0, (sbyte)42, (sbyte)0)] // blocked -> predict 0
[InlineData((byte)0b1000_0000, 7, (sbyte)-13, (sbyte)-13)]
[InlineData((byte)0b0111_1111, 7, (sbyte)-13, (sbyte)0)]
public void Predict_IsSourceZWherePassable_ZeroWhereBlocked(byte maskByte, int bit, sbyte sourceZ, sbyte expected)
{
Assert.Equal(expected, StepCacheFile.Predict(maskByte, bit, sourceZ));
}
[Fact]
public void FlatChunk_ElidesEveryZArray()
{
var src = FlatFullChunk();
var rt = RoundTrip(src, out var fileLength);
AssertIdentical(src, rt);
// A Full record carrying all 16 Z arrays runs past 5 KB. Landing under 1100 bytes is only
// possible if every one of them elided.
Assert.True(fileLength < 1100, $"expected every base Z array to elide; file was {fileLength} bytes");
}
[Fact]
public void CoastlineChunk_ElidesEveryZArray()
{
// Partial walkability with a non-zero SourceZ: the shape that defeats an unmasked predictor.
var src = FlatFullChunk(baseZ: 25);
var rt = RoundTrip(src, out var fileLength);
AssertIdentical(src, rt);
Assert.True(fileLength < 1100, $"masked predictor should elide every array; file was {fileLength} bytes");
}
[Fact]
public void SlopeInOneDirection_StoresOnlyThatZArray()
{
var flat = FlatFullChunk();
RoundTrip(flat, out var flatLength);
// Raise WalkZN on cells walkable to the north. Exactly one array (WalkZN) now disagrees
// with its prediction; the other 15 must still elide.
var sloped = FlatFullChunk();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
if ((sloped.WalkMask[i] & 1) != 0)
{
sloped.WalkZN[i]++;
}
}
var rt = RoundTrip(sloped, out var slopedLength);
AssertIdentical(sloped, rt);
Assert.True(slopedLength > flatLength, "a present Z array should grow the record");
Assert.True(
slopedLength <= flatLength + StepChunk.CellsPerChunk,
$"only one 256-byte residual array should have been added; grew by {slopedLength - flatLength}"
);
}
// ---- compression ----
[Fact]
public void VariedChunk_Compresses_AndRoundTrips()
{
var src = VariedChunk(seed: 4);
var rt = RoundTrip(src, out var fileLength);
AssertIdentical(src, rt);
// The uncompressed Full record for a varied chunk exceeds 5 KB.
Assert.True(fileLength < 4000, $"expected compression to shrink the record; file was {fileLength} bytes");
}
[Fact]
public void UniformChunk_StoredRaw_RoundTrips()
{
// A Uniform body is ~28 bytes and deflate cannot shrink it, so the writer stores it raw and
// the reader has to notice that from the payload length alone.
var src = UniformChunk(z: 12);
var rt = RoundTrip(src, out var fileLength);
AssertIdentical(src, rt);
Assert.True(fileLength < 200, $"uniform record should stay tiny; file was {fileLength} bytes");
}
// ---- optional trailers ----
[Fact]
public void SwimLayer_RoundTrips() => AssertIdentical(
WithSwimLayer(VariedChunk()),
RoundTrip(WithSwimLayer(VariedChunk()))
);
[Fact]
public void Strata_RoundTrips()
{
var src = WithStrataAt(VariedChunk(), cell: 10);
var rt = RoundTrip(src);
AssertIdentical(src, rt);
Assert.True(rt.IsCellMultiZ(10));
}
[Fact]
public void SwimLayerAndStrata_RoundTripTogether()
{
// Both trailers present at once, which is the only case that pins their relative order.
var src = WithStrataAt(WithSwimLayer(VariedChunk()), cell: 20);
var rt = RoundTrip(src);
AssertIdentical(src, rt);
Assert.True(rt.HasSwimLayer);
Assert.True(rt.IsCellMultiZ(20));
}
// ---- compact index ----
[Fact]
public void MultipleChunks_ResolveIndividually_FromDerivedOffsets()
{
// The index stores no offsets, so a reader that mis-sums record lengths would hand back a
// neighbouring chunk's bytes. Distinct content per coordinate is what catches that. The mix
// of record sizes matters: a raw-stored Uniform sits between two compressed Full records,
// and one coordinate is large enough to exercise the packed key's high 16 bits.
var chunks = new List<(int cx, int cy, StepChunk c)>
{
(1, 1, VariedChunk(seed: 3)),
(2, 5, UniformChunk(z: 14)),
(10, 3, VariedChunk(seed: 99)),
(300, 200, VariedChunk(seed: 17))
};
var path = Write(chunks.ToArray());
try
{
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
Assert.Equal((uint)chunks.Count, reader!.ChunkCount);
foreach (var (cx, cy, src) in chunks)
{
Assert.True(reader.Has(cx, cy), $"missing chunk ({cx},{cy})");
var rt = reader.TryReadChunk(cx, cy);
Assert.NotNull(rt);
AssertIdentical(src, rt!);
}
Assert.Null(reader.TryReadChunk(7, 7)); // never written
}
finally
{
File.Delete(path);
}
}
[Fact]
public void EmptyChunkSet_WritesAReadableFile()
{
var path = Write();
try
{
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
Assert.Equal(0u, reader!.ChunkCount);
Assert.Null(reader.TryReadChunk(0, 0));
}
finally
{
File.Delete(path);
}
}
// ---- version gate ----
[Theory]
[InlineData(0u)]
[InlineData(5u)]
[InlineData(8u)]
[InlineData(StepCacheFile.FormatVersion + 1)]
public void UnsupportedVersion_IsRejected(uint version)
{
var path = Write((0, 0, UniformChunk()));
try
{
var bytes = File.ReadAllBytes(path);
BitConverter.GetBytes(version).CopyTo(bytes, 4); // Version sits right after Magic
File.WriteAllBytes(path, bytes);
Assert.Null(StepCacheFile.OpenForLazy(path));
}
finally
{
File.Delete(path);
}
}
}

View file

@ -272,15 +272,9 @@ 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.
/// A chunk's swim layer must survive Save → Clear → LazyOpen → first-touch query. The layer is
/// an optional trailer, so a chunk that has one is the only thing that proves it is written and
/// read back rather than silently dropped.
/// </summary>
[Fact]
public void SwimLayer_RoundTrips_ThroughLazyReader()
@ -292,19 +286,14 @@ public class StepCacheFileTests
var map = Map.Maps[1];
Assert.NotNull(map);
// Build a chunk and inject a synthetic swim layer onto cell (1500, 1600).
// Build a chunk and inject a synthetic swim layer onto one cell.
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];
var chunk = cache.GetResidentChunk(map.MapID, 1500 >> 4, 1600 >> 4);
Assert.NotNull(chunk);
chunk.AllocateSwimLayer();
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var cellIndex = PathingTestSupport.CellIndex(1500, 1600);
chunk.SwimSourceZ[cellIndex] = -7;
chunk.SwimMask[cellIndex] = 0b0000_1111;
chunk.SwimZN_Layer[cellIndex] = -7;
@ -402,6 +391,12 @@ public class StepCacheFileTests
}
}
/// <summary>
/// A chunk the .swb can satisfy must be served on first touch, without consulting the promotion
/// gate. This is the deployment shape where an admin ships baked files and expects the very
/// first pathfind through a region to use the cache rather than the slow path — the gate would
/// otherwise defer that first touch and defeat the whole point of shipping the bake.
/// </summary>
[SkippableFact]
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
{

View file

@ -1,280 +0,0 @@
using System;
using System.IO;
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
// v6 = predictive-Z residuals on top of the v5 uniform-elision format. Each base directional
// Z array is stored as a masked residual against the cell's own SourceZ; arrays that match
// their prediction are omitted entirely (ZArrayMask bit clear) and synthesized at read.
[Collection("Sequential Pathfinding Tests")]
public class StepCacheFileV6Tests
{
[Theory]
[InlineData((sbyte)0, (sbyte)0)]
[InlineData((sbyte)10, (sbyte)10)]
[InlineData((sbyte)0, (sbyte)10)]
[InlineData((sbyte)10, (sbyte)0)]
[InlineData((sbyte)-20, (sbyte)15)]
[InlineData(sbyte.MinValue, sbyte.MaxValue)]
[InlineData(sbyte.MaxValue, sbyte.MinValue)]
[InlineData(sbyte.MinValue, (sbyte)1)]
[InlineData((sbyte)127, (sbyte)-1)]
public void Residual_RoundTrips_Losslessly_ForAllInputs(sbyte z, sbyte predict)
{
var residual = StepCacheFile.EncodeResidual(z, predict);
Assert.Equal(z, StepCacheFile.DecodeZ(predict, residual));
}
[Theory]
[InlineData((byte)0b0000_0001, 0, (sbyte)42, (sbyte)42)] // bit set -> sourceZ
[InlineData((byte)0b0000_0000, 0, (sbyte)42, (sbyte)0)] // bit clear -> 0
[InlineData((byte)0b1000_0000, 7, (sbyte)-13, (sbyte)-13)]
[InlineData((byte)0b0111_1111, 7, (sbyte)-13, (sbyte)0)]
public void Predict_UsesSourceZWhenBitSet_ZeroOtherwise(byte maskByte, int bit, sbyte sourceZ, sbyte expected)
{
Assert.Equal(expected, StepCacheFile.Predict(maskByte, bit, sourceZ));
}
// ---- builders ----
// A FULL chunk (not uniform: masks/SourceZ vary per cell) whose every directional-Z equals
// its masked prediction => all 16 base Z arrays must elide. Doubles as the coastline case:
// per-cell partial walkability with SourceZ != 0, flat where walkable, 0 where not.
private static StepChunk FlatFullChunk(int multis = 3, sbyte baseZ = 10)
{
var c = new StepChunk { BuiltMultisVersion = multis };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)(i & 0xFF);
c.WetMask[i] = (byte)(~i & 0xFF);
c.SourceZ[i] = (sbyte)(baseZ + i % 7 - 3); // varies, mostly != 0
}
SetFlatDirectional(c);
return c;
}
// Sets every directional-Z to its masked prediction (walkable/wet -> SourceZ, else 0),
// i.e. perfectly flat terrain. Such arrays all elide under v6.
private static void SetFlatDirectional(StepChunk c)
{
var walk = new[] { c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW };
var swim = new[] { c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
for (var b = 0; b < 8; b++)
{
walk[b][i] = (sbyte)((c.WalkMask[i] >> b & 1) != 0 ? c.SourceZ[i] : 0);
swim[b][i] = (sbyte)((c.WetMask[i] >> b & 1) != 0 ? c.SourceZ[i] : 0);
}
}
}
private static StepChunk VariedChunk(int multis = 3)
{
var c = new StepChunk { BuiltMultisVersion = multis };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)(i & 0xFF);
c.WetMask[i] = (byte)((i * 7) & 0xFF);
c.SourceZ[i] = (sbyte)(i % 40 - 20);
c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3);
c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2);
}
return c;
}
private static StepChunk SwimChunk(int multis = 6)
{
var c = VariedChunk(multis);
c.AllocateSwimLayer();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.SwimSourceZ[i] = (sbyte)(i % 30 - 15);
c.SwimMask[i] = (byte)((i * 5) & 0xFF);
c.SwimZN_Layer[i] = (sbyte)(i % 7);
c.SwimZNW_Layer[i] = (sbyte)-(i % 4);
}
return c;
}
// ---- round-trip plumbing ----
private static string Write1(StepChunk c, int cx, int cy)
{
var path = Path.Combine(Path.GetTempPath(), $"swbv6_{Guid.NewGuid():N}.swb");
var emitted = false;
StepCacheFile.Write(path, 1u, 1u, (out int ox, out int oy, out StepChunk oc) =>
{
if (emitted) { ox = oy = 0; oc = null!; return false; }
emitted = true; ox = cx; oy = cy; oc = c; return true;
});
return path;
}
private static StepChunk RoundTrip(StepChunk src, int cx, int cy, out long fileLen)
{
var path = Write1(src, cx, cy);
try
{
fileLen = new FileInfo(path).Length;
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
var rt = reader!.TryReadChunk(cx, cy);
Assert.NotNull(rt);
return rt!;
}
finally { File.Delete(path); }
}
private static void AssertChunksEqual(StepChunk a, StepChunk b)
{
Assert.Equal(a.BuiltMultisVersion, b.BuiltMultisVersion);
Assert.True(a.WalkMask.AsSpan().SequenceEqual(b.WalkMask));
Assert.True(a.WetMask.AsSpan().SequenceEqual(b.WetMask));
Assert.True(a.SourceZ.AsSpan().SequenceEqual(b.SourceZ));
var az = new[] { a.WalkZN, a.WalkZNE, a.WalkZE, a.WalkZSE, a.WalkZS, a.WalkZSW, a.WalkZW, a.WalkZNW,
a.SwimZN, a.SwimZNE, a.SwimZE, a.SwimZSE, a.SwimZS, a.SwimZSW, a.SwimZW, a.SwimZNW };
var bz = new[] { b.WalkZN, b.WalkZNE, b.WalkZE, b.WalkZSE, b.WalkZS, b.WalkZSW, b.WalkZW, b.WalkZNW,
b.SwimZN, b.SwimZNE, b.SwimZE, b.SwimZSE, b.SwimZS, b.SwimZSW, b.SwimZW, b.SwimZNW };
for (var i = 0; i < az.Length; i++)
{
Assert.True(az[i].AsSpan().SequenceEqual(bz[i]), $"base Z array {i} differs");
}
Assert.Equal(a.HasSwimLayer, b.HasSwimLayer);
if (a.HasSwimLayer)
{
Assert.True(a.SwimSourceZ.AsSpan().SequenceEqual(b.SwimSourceZ));
Assert.True(a.SwimMask.AsSpan().SequenceEqual(b.SwimMask));
var al = new[] { a.SwimZN_Layer, a.SwimZNE_Layer, a.SwimZE_Layer, a.SwimZSE_Layer,
a.SwimZS_Layer, a.SwimZSW_Layer, a.SwimZW_Layer, a.SwimZNW_Layer };
var bl = new[] { b.SwimZN_Layer, b.SwimZNE_Layer, b.SwimZE_Layer, b.SwimZSE_Layer,
b.SwimZS_Layer, b.SwimZSW_Layer, b.SwimZW_Layer, b.SwimZNW_Layer };
for (var i = 0; i < al.Length; i++)
{
Assert.True(al[i].AsSpan().SequenceEqual(bl[i]), $"swim-layer Z array {i} differs");
}
}
}
// ---- transform tests (Task 2) ----
[Fact]
public void FlatFull_AllArraysElide_RoundTripsAndIsCompact()
{
var src = FlatFullChunk();
var rt = RoundTrip(src, 5, 6, out var fileLen);
AssertChunksEqual(src, rt);
// Full record with all 16 Z arrays elided: header(48) + ~783-byte record + index(20).
// A v5 full record alone is > 5 KB, so a sub-1100-byte file proves elision fired.
Assert.True(fileLen < 1100, $"expected all base Z arrays to elide; file was {fileLen} bytes");
}
[Fact]
public void SlopedSubset_OnlyVaryingArraysPresent_RoundTrips()
{
var flat = FlatFullChunk();
var flatPath = Write1(flat, 1, 1);
long flatLen;
try { flatLen = new FileInfo(flatPath).Length; } finally { File.Delete(flatPath); }
// Bump WalkZN by +1 on cells walkable to the N (slope in one direction only) -> exactly
// one base Z array (WalkZN, bit 0) becomes present; the other 15 still elide.
var sloped = FlatFullChunk();
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
if ((sloped.WalkMask[i] & 1) != 0)
{
sloped.WalkZN[i] = (sbyte)(sloped.WalkZN[i] + 1);
}
}
var rt = RoundTrip(sloped, 2, 3, out var slopedLen);
AssertChunksEqual(sloped, rt);
Assert.True(slopedLen > flatLen, "one present array should grow the record vs all-flat");
Assert.True(slopedLen <= flatLen + StepChunk.CellsPerChunk, "only one 256-byte residual array should be added");
}
[Fact]
public void Varied_Full_RoundTrips_Identically()
{
var src = VariedChunk(multis: 4);
AssertChunksEqual(src, RoundTrip(src, 1, 2, out _));
}
// ---- shape coverage (Task 3) ----
[Fact]
public void Coastline_NonzeroSourceZ_PartialWalkability_AllElide()
{
// FlatFullChunk already models a coastline: per-cell partial walk/wet masks, SourceZ != 0,
// flat where walkable and 0 (baker default) where not. A plain SourceZ residual would emit
// -SourceZ on every unwalkable direction; the masked predictor must drive ALL arrays to elide.
var src = FlatFullChunk(multis: 2, baseZ: 25);
var rt = RoundTrip(src, 7, 7, out var fileLen);
AssertChunksEqual(src, rt);
Assert.True(fileLen < 1100, $"masked predictor should elide every array on flat coastline; file was {fileLen} bytes");
}
[Fact]
public void SwimLayer_Full_RoundTrips_Identically()
{
var src = SwimChunk(multis: 8);
var rt = RoundTrip(src, 7, 8, out _);
Assert.True(rt.HasSwimLayer);
AssertChunksEqual(src, rt);
}
[Fact]
public void Strata_Full_RoundTrips_Identically()
{
var src = VariedChunk(multis: 5);
var offsets = new ushort[StepChunk.CellsPerChunk];
Array.Fill(offsets, StepChunk.NoStrata);
offsets[10] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1;
src.SetStrata(offsets, data);
var rt = RoundTrip(src, 3, 4, out _);
AssertChunksEqual(src, rt);
Assert.True(rt.IsCellMultiZ(10));
Assert.True(rt.StrataData.SequenceEqual(src.StrataData));
}
[Fact]
public void SwimAndStrata_Full_RoundTrips_Identically()
{
// Combined trailer ordering: swim-layer trailer THEN strata trailer, after the residual blocks.
var src = SwimChunk(multis: 11);
var offsets = new ushort[StepChunk.CellsPerChunk];
Array.Fill(offsets, StepChunk.NoStrata);
offsets[20] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1;
src.SetStrata(offsets, data);
var rt = RoundTrip(src, 9, 9, out _);
Assert.True(rt.HasSwimLayer);
Assert.True(rt.IsCellMultiZ(20));
AssertChunksEqual(src, rt);
Assert.True(rt.StrataData.SequenceEqual(src.StrataData));
}
[Fact]
public void OlderVersion_IsRejected()
{
var path = Write1(FlatFullChunk(), 0, 0);
try
{
var bytes = File.ReadAllBytes(path);
bytes[4] = 5; bytes[5] = 0; bytes[6] = 0; bytes[7] = 0; // version 5 < MinSupportedVersion 6
File.WriteAllBytes(path, bytes);
Assert.Null(StepCacheFile.OpenForLazy(path));
}
finally { File.Delete(path); }
}
}

View file

@ -1,122 +0,0 @@
using System;
using System.IO;
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
// v7 = per-chunk libdeflate compression on top of the v6 predictive-Z format. Each record is
// compressed independently (random access preserved) behind a u32 uncompressed-length prefix;
// records that do not shrink (tiny Uniform records) are stored raw.
[Collection("Sequential Pathfinding Tests")]
public class StepCacheFileV7Tests
{
private static StepChunk VariedChunk(int multis = 3)
{
var c = new StepChunk { BuiltMultisVersion = multis };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)(i & 0xFF);
c.WetMask[i] = (byte)((i * 7) & 0xFF);
c.SourceZ[i] = (sbyte)(i % 40 - 20);
c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3);
c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2);
}
return c;
}
private static StepChunk UniformChunk(byte walk = 0xC1, sbyte z = 10, int multis = 7)
{
var c = new StepChunk { BuiltMultisVersion = multis };
Array.Fill(c.WalkMask, walk);
Array.Fill(c.SourceZ, z);
foreach (var arr in new[]
{
c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW,
c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW
})
{
Array.Fill(arr, z);
}
return c;
}
private static string Write1(StepChunk c, int cx, int cy)
{
var path = Path.Combine(Path.GetTempPath(), $"swbv7_{Guid.NewGuid():N}.swb");
var emitted = false;
StepCacheFile.Write(path, 1u, 1u, (out int ox, out int oy, out StepChunk oc) =>
{
if (emitted) { ox = oy = 0; oc = null!; return false; }
emitted = true; ox = cx; oy = cy; oc = c; return true;
});
return path;
}
private static StepChunk RoundTrip(StepChunk src, int cx, int cy, out long fileLen)
{
var path = Write1(src, cx, cy);
try
{
fileLen = new FileInfo(path).Length;
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
var rt = reader!.TryReadChunk(cx, cy);
Assert.NotNull(rt);
return rt!;
}
finally { File.Delete(path); }
}
private static void AssertBaseEqual(StepChunk a, StepChunk b)
{
Assert.Equal(a.BuiltMultisVersion, b.BuiltMultisVersion);
Assert.True(a.WalkMask.AsSpan().SequenceEqual(b.WalkMask));
Assert.True(a.WetMask.AsSpan().SequenceEqual(b.WetMask));
Assert.True(a.SourceZ.AsSpan().SequenceEqual(b.SourceZ));
var az = new[] { a.WalkZN, a.WalkZNE, a.WalkZE, a.WalkZSE, a.WalkZS, a.WalkZSW, a.WalkZW, a.WalkZNW,
a.SwimZN, a.SwimZNE, a.SwimZE, a.SwimZSE, a.SwimZS, a.SwimZSW, a.SwimZW, a.SwimZNW };
var bz = new[] { b.WalkZN, b.WalkZNE, b.WalkZE, b.WalkZSE, b.WalkZS, b.WalkZSW, b.WalkZW, b.WalkZNW,
b.SwimZN, b.SwimZNE, b.SwimZE, b.SwimZSE, b.SwimZS, b.SwimZSW, b.SwimZW, b.SwimZNW };
for (var i = 0; i < az.Length; i++)
{
Assert.True(az[i].AsSpan().SequenceEqual(bz[i]), $"base Z array {i} differs");
}
}
[Fact]
public void Varied_Compresses_AndRoundTrips()
{
var src = VariedChunk(multis: 4);
var rt = RoundTrip(src, 1, 2, out var fileLen);
AssertBaseEqual(src, rt);
// The uncompressed v6 Full record for a varied chunk is > 5 KB. Compressed + header(48)
// + index(20), the whole file must be well under that — proving compression engaged.
Assert.True(fileLen < 4000, $"expected compression to shrink the record; file was {fileLen} bytes");
}
[Fact]
public void Uniform_StoredRaw_RoundTrips()
{
// A Uniform record body is ~24 bytes; libdeflate cannot shrink it, so WriteChunk stores it
// raw (payload length == uncompressed length). The reader must take the raw path and rebuild.
var src = UniformChunk(walk: 0xC1, z: 12, multis: 9);
var rt = RoundTrip(src, 5, 6, out var fileLen);
AssertBaseEqual(src, rt);
Assert.True(fileLen < 200, $"uniform record should stay tiny; file was {fileLen} bytes");
}
[Fact]
public void V6_IsRejected()
{
var path = Write1(UniformChunk(), 0, 0);
try
{
var bytes = File.ReadAllBytes(path);
bytes[4] = 6; bytes[5] = 0; bytes[6] = 0; bytes[7] = 0; // version 6 < MinSupportedVersion 7
File.WriteAllBytes(path, bytes);
Assert.Null(StepCacheFile.OpenForLazy(path));
}
finally { File.Delete(path); }
}
}

View file

@ -1,121 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
// v8 = compact index on top of the v7 compression format. The trailer drops the per-chunk file
// offset (reconstructed by cumulative record length in write order) and packs the key to u32.
[Collection("Sequential Pathfinding Tests")]
public class StepCacheFileV8Tests
{
private static StepChunk VariedChunk(int seed, int multis)
{
var c = new StepChunk { BuiltMultisVersion = multis };
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
c.WalkMask[i] = (byte)((i + seed) & 0xFF);
c.WetMask[i] = (byte)((i * 7 + seed) & 0xFF);
c.SourceZ[i] = (sbyte)((i + seed) % 40 - 20);
c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3);
c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2);
}
return c;
}
private static StepChunk UniformChunk(sbyte z, int multis)
{
var c = new StepChunk { BuiltMultisVersion = multis };
Array.Fill(c.WalkMask, (byte)0xC1);
Array.Fill(c.SourceZ, z);
foreach (var arr in new[]
{
c.WalkZN, c.WalkZNE, c.WalkZE, c.WalkZSE, c.WalkZS, c.WalkZSW, c.WalkZW, c.WalkZNW,
c.SwimZN, c.SwimZNE, c.SwimZE, c.SwimZSE, c.SwimZS, c.SwimZSW, c.SwimZW, c.SwimZNW
})
{
Array.Fill(arr, z);
}
return c;
}
private static void AssertBaseEqual(StepChunk a, StepChunk b)
{
Assert.Equal(a.BuiltMultisVersion, b.BuiltMultisVersion);
Assert.True(a.WalkMask.AsSpan().SequenceEqual(b.WalkMask));
Assert.True(a.WetMask.AsSpan().SequenceEqual(b.WetMask));
Assert.True(a.SourceZ.AsSpan().SequenceEqual(b.SourceZ));
var az = new[] { a.WalkZN, a.WalkZNE, a.WalkZE, a.WalkZSE, a.WalkZS, a.WalkZSW, a.WalkZW, a.WalkZNW,
a.SwimZN, a.SwimZNE, a.SwimZE, a.SwimZSE, a.SwimZS, a.SwimZSW, a.SwimZW, a.SwimZNW };
var bz = new[] { b.WalkZN, b.WalkZNE, b.WalkZE, b.WalkZSE, b.WalkZS, b.WalkZSW, b.WalkZW, b.WalkZNW,
b.SwimZN, b.SwimZNE, b.SwimZE, b.SwimZSE, b.SwimZS, b.SwimZSW, b.SwimZW, b.SwimZNW };
for (var i = 0; i < az.Length; i++)
{
Assert.True(az[i].AsSpan().SequenceEqual(bz[i]), $"base Z array {i} differs");
}
}
private static string WriteMany(IReadOnlyList<(int cx, int cy, StepChunk c)> chunks)
{
var path = Path.Combine(Path.GetTempPath(), $"swbv8_{Guid.NewGuid():N}.swb");
var idx = 0;
StepCacheFile.Write(path, 1u, (uint)chunks.Count, (out int ox, out int oy, out StepChunk oc) =>
{
if (idx >= chunks.Count) { ox = oy = 0; oc = null!; return false; }
var e = chunks[idx++];
ox = e.cx; oy = e.cy; oc = e.c;
return true;
});
return path;
}
[Fact]
public void MultiChunk_RoundTrips_WithDerivedOffsets()
{
// Distinct chunks at distinct coords. A wrong derived offset would read another chunk's
// bytes, so per-chunk identity verifies cumulative offset reconstruction across records.
var chunks = new List<(int, int, StepChunk)>
{
(1, 1, VariedChunk(seed: 3, multis: 2)),
(2, 5, UniformChunk(z: 14, multis: 9)), // tiny record (stored-raw path) in the middle
(10, 3, VariedChunk(seed: 99, multis: 4)),
(300, 200, VariedChunk(seed: 17, multis: 5)), // large packed-key coords (high 16 bits)
};
var path = WriteMany(chunks);
try
{
using var reader = StepCacheFile.OpenForLazy(path);
Assert.NotNull(reader);
Assert.Equal((uint)chunks.Count, reader!.ChunkCount);
foreach (var (cx, cy, src) in chunks)
{
Assert.True(reader.Has(cx, cy), $"missing chunk ({cx},{cy})");
var rt = reader.TryReadChunk(cx, cy);
Assert.NotNull(rt);
AssertBaseEqual(src, rt!);
}
// A coordinate that was never written must not resolve.
Assert.Null(reader.TryReadChunk(7, 7));
}
finally { File.Delete(path); }
}
[Fact]
public void V7_IsRejected()
{
var path = WriteMany(new List<(int, int, StepChunk)> { (0, 0, UniformChunk(z: 10, multis: 1)) });
try
{
var bytes = File.ReadAllBytes(path);
bytes[4] = 7; bytes[5] = 0; bytes[6] = 0; bytes[7] = 0; // version 7 < MinSupportedVersion 8
File.WriteAllBytes(path, bytes);
Assert.Null(StepCacheFile.OpenForLazy(path));
}
finally { File.Delete(path); }
}
}

View file

@ -1,48 +1,64 @@
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit;
using static Server.Tests.Pathfinding.PathingTestSupport;
namespace Server.Tests.Pathfinding;
/// <summary>
/// How the cache decides what to build, what to serve, and what to throw away: the promotion gate,
/// the four fallthrough routes out of <see cref="StepCache.TryGetMask"/>, the strata and swim
/// layers, and LRU eviction.
/// </summary>
[Collection("Sequential Pathfinding Tests")]
public class StepCacheLifecycleTests
{
[Fact]
public void Singleton_IsAvailable()
/// <summary>Resets to a known state and returns the singleton.</summary>
private static StepCache FreshCache(int promotionThreshold)
{
var cache = StepCache.Instance;
Assert.NotNull(cache);
cache.Clear();
cache.MissPromotionThreshold = promotionThreshold;
return cache;
}
/// <summary>Builds the plain chunk and hands it back for a test to inject state into.</summary>
private static StepChunk BuiltPlainChunk(StepCache cache, Map map)
{
cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4);
Assert.NotNull(chunk);
return chunk;
}
[Fact]
public void Clear_OnEmptyCache_LeavesStatsZero()
{
var cache = StepCache.Instance;
cache.Clear();
var stats = FreshCache(2).GetStats();
var stats = cache.GetStats();
Assert.Equal(0, stats.ResidentChunks);
Assert.Equal(0L, stats.Hits);
Assert.Equal(0L, stats.BuildsTotal);
}
// ---- promotion gate ----
/// <summary>
/// A chunk nothing has shown sustained interest in must not be built. The caller reads
/// IsHit=false as "use the slow path", which is the cheaper trade for a pet crossing a chunk
/// once: BuildChunk costs far more than the handful of slow-path steps it would save.
/// </summary>
[Fact]
public void TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough()
public void FirstTouch_DefersBuild_AndFallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var cache = FreshCache(promotionThreshold: 2);
var map = TestMap;
var map = Map.Maps[1];
Assert.NotNull(map);
// 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, PlainX, PlainY, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
@ -55,25 +71,20 @@ public class StepCacheLifecycleTests
}
[SkippableFact]
public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
public void SecondTouchInsideWindow_PromotesAndServes()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
var cache = FreshCache(promotionThreshold: 2);
var map = TestMap;
// 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);
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).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 promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.True(promoted.IsHit);
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
Assert.Equal((byte)0xC1, promoted.WalkMask); // pinned: open plain, walkable N/NE/... per the bake
Assert.Equal((sbyte)10, promoted.WalkZ_N);
var stats = cache.GetStats();
Assert.Equal(1, stats.ResidentChunks);
@ -81,58 +92,53 @@ public class StepCacheLifecycleTests
Assert.Equal(1L, stats.BuildsTotal);
Assert.Equal(1L, stats.FallthroughNotBuilt);
// Third query of same cell → Hit (chunk now resident).
var third = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.True(third.IsHit);
Assert.Equal(CacheHitKind.Hit, third.HitKind);
Assert.Equal((byte)0xC1, third.WalkMask);
// Now resident: a third query is a clean hit, not another miss.
var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.Equal(CacheHitKind.Hit, hit.HitKind);
Assert.Equal((byte)0xC1, hit.WalkMask);
}
/// <summary>
/// Two touches spread wider than the window are not interest, they're coincidence — a chunk
/// someone glanced through, then an unrelated creature wandering past minutes later. The count
/// restarts rather than accumulating toward a build.
/// </summary>
[Fact]
public void TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers()
public void SecondTouchAfterWindow_RestartsTheCount()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
cache.MissPromotionWindowMs = 1; // 1ms window for testability
var cache = FreshCache(promotionThreshold: 2);
cache.MissPromotionWindowMs = 1;
var map = Map.Maps[1];
var map = TestMap;
var first = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.False(first.IsHit);
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
Thread.Sleep(20); // outrun the window
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);
var second = cache.TryGetMask(map, PlainX, PlainY, 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);
}
/// <summary>
/// The gate counts Finds, not probes. A single pathfind hits a chunk once per cell it expands
/// there, so counting probes would cross any threshold on the second cell and gate nothing at
/// all — the deferral would be dead code.
/// </summary>
[SkippableFact]
public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
public void ManyProbesInOneFind_CountAsOneTouch()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var map = Map.Maps[1];
var cache = FreshCache(promotionThreshold: 2);
var map = TestMap;
// 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);
// Eight different cells, all inside the same chunk.
var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
}
@ -141,111 +147,101 @@ public class StepCacheLifecycleTests
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.
// A second Find is the second distinct touch, and crosses the threshold.
cache.BeginFindGeneration();
var promoted = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
Assert.True(promoted.IsHit);
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
Assert.Equal(1, cache.GetStats().ResidentChunks);
Assert.Equal(1L, cache.GetStats().BuildsTotal);
}
/// <summary>Distinct Finds still don't promote if they straddle the window.</summary>
[Fact]
public void TryGetMask_TwoFindGenerationsAcrossWindow_RestartsCounter()
public void TwoFindsAcrossTheWindow_DoNotPromote()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
cache.MissPromotionWindowMs = 1; // 1ms window for testability
var cache = FreshCache(promotionThreshold: 2);
cache.MissPromotionWindowMs = 1;
var map = Map.Maps[1];
var map = TestMap;
cache.BeginFindGeneration();
Assert.False(cache.TryGetMask(map, 1500, 1600, sourceZ: 10).IsHit);
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
System.Threading.Thread.Sleep(20); // exceed window
Thread.Sleep(20);
// 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);
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
Assert.Equal(0, cache.GetStats().ResidentChunks);
}
[Fact]
public void TryGetMask_DistinctChunks_TrackedIndependently()
public void EachChunkIsTrackedSeparately()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
var cache = FreshCache(promotionThreshold: 2);
var map = TestMap;
var map = Map.Maps[1];
// One touch each, in two different chunks: neither reaches the threshold on its own.
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: 10).IsHit);
// 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);
}
// ---- fallthrough routes ----
[Fact]
public void TryGetMask_OffMap_ReturnsFalseFallthrough()
public void OffMapCell_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
var map = Map.Maps[1];
var lookup = cache.TryGetMask(map, -1, -1, sourceZ: 0);
var lookup = FreshCache(2).TryGetMask(TestMap, -1, -1, sourceZ: 0);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_OffMap, lookup.HitKind);
Assert.Equal((byte)0, lookup.WalkMask);
}
/// <summary>
/// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes
/// the edges TO its neighbours, so a wall one cell over has to block them.
/// </summary>
[SkippableFact]
public void MultiCoveredCell_AndHalo_RouteToFallthrough()
public void MultiCoveredCell_AndItsHalo_FallThrough()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately
var map = Map.Maps[1];
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
// A cell far from any multi serves from the static cache.
Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit);
// A cell nowhere near a multi still serves from the static cache.
Assert.True(cache.TryGetMask(map, PlainX, PlainY, 10).IsHit);
// Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a
// single-entry list is enough to mark the sector as multi-bearing — the fallthrough
// decision never dereferences the multi, so no real BaseMulti instance is needed.
// Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the
// fallthrough never dereferences the multi, so a single null entry is enough — no real
// BaseMulti needed.
const int mx = 2000;
const int my = 2000;
var sx = mx >> 4;
var sy = my >> 4;
var sector = map.GetRealSector(sx, sy);
var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(multisField);
var original = multisField.GetValue(sector);
try
{
multisField.SetValue(sector, new List<BaseMulti> { null });
// Cell inside the multi sector → routed to the live path.
// Inside the multi's sector.
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind);
// Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo
// (its mask would otherwise propose an edge into the multi sector).
var boundaryX = sx * 16 - 1; // last tile of sector sx-1; halo (x+1) reaches into sx
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, boundaryX, my, 0).HitKind);
// Last cell of the neighbouring sector: its halo reaches across the boundary.
Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 1, my, 0).HitKind);
// Two tiles out → interior of the multi-free sector, unaffected.
// One cell further out: halo no longer reaches, so the static cache handles it.
Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind);
Assert.True(cache.GetStats().FallthroughMulti >= 2);
@ -256,95 +252,38 @@ public class StepCacheLifecycleTests
}
}
/// <summary>A query too far from the cell's baked Z gets no answer, rather than a wrong one.</summary>
[Fact]
public void MultiZCell_RoutesToFallthrough()
public void SourceZFarFromBake_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build for prime-then-inspect tests
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var map = Map.Maps[1];
cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
var before = cache.GetStats().FallthroughSourceZMismatch;
// Build a chunk first so it exists.
cache.TryGetMask(map, 1500, 1600, 10);
// Snapshot current FallthroughMultiZ in case (1500, 1600) is naturally multi-Z
// in real tile data; we only assert the synthetic injection produces a delta of 1.
var preInjectionFallthroughMultiZ = cache.GetStats().FallthroughMultiZ;
// Inject a multi-Z bit via reflection on the resident chunk.
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
Assert.NotNull(chunksField);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var key = StepCache.EncodeKey(map.MapID, 1500 >> 4, 1600 >> 4);
Assert.True(chunks.ContainsKey(key));
var chunk = chunks[key];
// Inject "this cell has strata but none match the query Z" — proves the cache
// still falls through to slow path when no stratum can answer.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0; // points to a 0-stratum-count entry → no match
var data = new byte[] { 0 };
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 10);
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
var stats = cache.GetStats();
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
}
// ---- strata ----
[Fact]
public void Tier4Strata_MatchingZ_ReturnsHitFromStratum()
public void Stratum_MatchingQueryZ_IsServed()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var chunk = BuiltPlainChunk(cache, map);
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, 10);
var offsets = NoStrataOffsets();
offsets[CellIndex(PlainX, PlainY)] = 0;
chunk.SetStrata(offsets, OneStratum(zCenter: 42, walkMask: 0b0000_0011, walkZs: [42, 42]));
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];
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 42);
// Inject one stratum at zCenter=42, walkMask=0b00000011 (N + NE).
// Query at sourceZ=42 must hit and return that stratum's data.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1; // count
data[1] = 42; // zCenter
data[2] = 0b0000_0011; // walkMask (N | NE)
data[3] = 0; // wetMask
data[4] = 42; data[5] = 42; data[6] = 0; data[7] = 0;
data[8] = 0; data[9] = 0; data[10] = 0; data[11] = 0;
data[12] = 0; data[13] = 0; data[14] = 0; data[15] = 0;
data[16] = 0; data[17] = 0; data[18] = 0; data[19] = 0;
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 42);
Assert.True(lookup.IsHit);
Assert.Equal((byte)0b0000_0011, lookup.WalkMask);
Assert.Equal((sbyte)42, lookup.WalkZ_N);
@ -352,195 +291,130 @@ public class StepCacheLifecycleTests
}
[Fact]
public void SwimLayer_NotInjected_StaysFallthroughOnSourceZMismatch()
public void Stratum_QueryZOutOfReach_FallsThrough()
{
// 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 cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var chunk = BuiltPlainChunk(cache, map);
var map = Map.Maps[1];
var offsets = NoStrataOffsets();
offsets[CellIndex(PlainX, PlainY)] = 0;
chunk.SetStrata(offsets, OneStratum(zCenter: 42));
cache.TryGetMask(map, 1500, 1600, sourceZ: 10); // build chunk
var beforeMismatch = cache.GetStats().FallthroughSourceZMismatch;
// 10 is more than StepHeight from the only stratum, so nothing can answer.
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
// 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]
public void Tier4Strata_NonMatchingZ_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
var map = Map.Maps[1];
cache.TryGetMask(map, 1500, 1600, 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];
// Stratum at zCenter=42; query at sourceZ=10 (delta > StepHeight=2). Must fallthrough.
var cellIndex = ((1600 - ((1600 >> 4) << 4)) << 4) | (1500 - ((1500 >> 4) << 4));
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = StepChunk.NoStrata;
}
offsets[cellIndex] = 0;
var data = new byte[1 + StepChunk.StratumByteLength];
data[0] = 1; data[1] = 42; // zCenter=42, all other bytes 0
chunk.SetStrata(offsets, data);
var lookup = cache.TryGetMask(map, 1500, 1600, 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
}
/// <summary>
/// A cell flagged multi-Z is served only from its strata. If it has none that match — here, a
/// zero-count record — it must fall through rather than quietly fall back to the main mask,
/// which was baked for a different surface.
/// </summary>
[Fact]
public void LruCap_OverflowEvictsToCap()
public void MultiZCell_WithNoUsableStratum_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var chunk = BuiltPlainChunk(cache, map);
var before = cache.GetStats().FallthroughMultiZ;
var offsets = NoStrataOffsets();
offsets[CellIndex(PlainX, PlainY)] = 0;
chunk.SetStrata(offsets, [0]); // a record declaring zero strata
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
Assert.Equal(before + 1L, cache.GetStats().FallthroughMultiZ);
}
// ---- swim layer ----
[Fact]
public void SwimLayer_QueryAtWaterZ_IsServedFromTheLayer()
{
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var chunk = BuiltPlainChunk(cache, map);
var cell = CellIndex(PlainX, PlainY);
var bakedZ = chunk.SourceZ[cell];
// Place the water surface well clear of the walk surface, so the primary source-Z guard is
// guaranteed to reject the swim query and hand it to the layer.
var swimZ = (sbyte)(bakedZ - 20);
chunk.AllocateSwimLayer();
chunk.SwimSourceZ[cell] = swimZ;
chunk.SwimMask[cell] = 0b0000_0011;
chunk.SwimZN_Layer[cell] = swimZ;
chunk.SwimZNE_Layer[cell] = swimZ;
// At the walk surface, the layer is not consulted at all.
Assert.Equal(CacheHitKind.Hit, cache.TryGetMask(map, PlainX, PlainY, bakedZ).HitKind);
var swim = cache.TryGetMask(map, PlainX, PlainY, swimZ);
Assert.True(swim.IsHit);
Assert.Equal((byte)0, swim.WalkMask); // a swimmer can't walk
Assert.Equal((byte)0b0000_0011, swim.WetMask);
Assert.Equal(swimZ, swim.SwimZ_N);
Assert.Equal(swimZ, swim.SwimZ_NE);
}
/// <summary>
/// An inland cell in a chunk that has a swim layer carries the NoSwimLayerCell sentinel. That
/// sentinel is sbyte.MinValue, so a query at sbyte.MinValue would match it exactly on a naive
/// distance check — the guard has to reject the sentinel before measuring anything.
/// </summary>
[Fact]
public void SwimLayer_SentinelCell_IsNeverMatched()
{
var cache = FreshCache(promotionThreshold: 1);
var map = TestMap;
var chunk = BuiltPlainChunk(cache, map);
chunk.AllocateSwimLayer(); // allocated for some other cell; this one stays at the sentinel
var cell = CellIndex(PlainX, PlainY);
Assert.Equal(StepChunk.NoSwimLayerCell, chunk.SwimSourceZ[cell]);
var before = cache.GetStats().FallthroughSourceZMismatch;
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: sbyte.MinValue);
Assert.False(lookup.IsHit);
Assert.Equal(CacheHitKind.Fallthrough_SourceZMismatch, lookup.HitKind);
Assert.Equal(before + 1L, cache.GetStats().FallthroughSourceZMismatch);
}
// ---- eviction ----
[Fact]
public void LruCap_EvictsDownToTheCap()
{
var cache = FreshCache(promotionThreshold: 1);
cache.MaxResidentChunks = 4;
cache.MissPromotionThreshold = 1;
try
{
var map = Map.Maps[1];
var map = TestMap;
// Build 5 distinct chunks by querying different sectors.
// Five chunks into a cache that holds four.
for (var i = 0; i < 5; i++)
{
var x = 1500 + i * 16;
var y = 1600;
cache.TryGetMask(map, x, y, 10);
System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs
cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10);
Thread.Sleep(2); // separate their LastTouchedTicks so LRU has something to order by
}
cache.EnforceLruCap();
Assert.Equal(4, cache.GetStats().ResidentChunks);
Assert.True(cache.GetStats().EvictionsByLruCap >= 1L);
// _keysList must stay in lockstep with _chunks. A desync would silently
// break sampled eviction (KeyNotFoundException on stale keys, or a stuck
// resident set on missing keys).
var chunksField = typeof(StepCache).GetField(
"_chunks",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var keysListField = typeof(StepCache).GetField(
"_keysList",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
);
var chunks = (System.Collections.Generic.Dictionary<long, StepChunk>)chunksField.GetValue(cache);
var keysList = (System.Collections.Generic.List<long>)keysListField.GetValue(cache);
Assert.Equal(chunks.Count, keysList.Count);
foreach (var k in keysList)
{
Assert.True(chunks.ContainsKey(k), $"keysList holds key {k} not in _chunks");
}
Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set");
}
finally
{

View file

@ -1,125 +1,126 @@
using System;
using System.Collections.Generic;
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
using static Server.Tests.Pathfinding.PathingTestSupport;
namespace Server.Tests.Pathfinding;
/// <summary>
/// The cache is only worth having if it answers exactly as MovementImpl would. These tests pin
/// that down at each layer, so a failure says which one broke:
///
/// <see cref="ProbeMatchesSlowPath"/> StepProbe vs MovementImpl — does the bake compute the right answer?
/// <see cref="CacheMatchesProbe"/> StepCache vs StepProbe — does the chunk store and return it intact?
/// <see cref="CacheServesReachableWalkStates"/> StepCache vs MovementImpl — end to end, over the states A* actually visits.
///
/// The end-to-end test is the one that matters, but it can only tell you something is wrong; the
/// two layer tests tell you where. It also measures coverage, not just correctness — a cache that
/// falls through on everything agrees with the slow path perfectly and is worthless.
/// </summary>
[Collection("Sequential Pathfinding Tests")]
public class StepCacheParityTests
{
private readonly ITestOutputHelper _output;
public StepCacheParityTests(ITestOutputHelper output)
{
_output = output;
}
public StepCacheParityTests(ITestOutputHelper output) => _output = output;
[Theory]
// ---- layer 1: the bake agrees with MovementImpl ----
/// <summary>
/// Sweeps a region and compares StepProbe's mask against MovementImpl for all 8 directions.
/// The probe stores raw masks and leaves the diagonal corner-cut to the caller, so the rule has
/// to be applied here before the two are comparable.
/// </summary>
[SkippableTheory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
[InlineData("britain_causeway", 1475, 1641, 32)]
public void CacheMatchesBaker(string label, int xStart, int yStart, int size)
public void ProbeMatchesSlowPath(string label, int xStart, int yStart, int size)
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // sweep cells expecting cache to answer immediately
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
var map = TestMap;
Assert.NotNull(map);
var walker = new StaticWalker();
walker.MoveToWorld(new Point3D(xStart, yStart, 0), map);
var disagreements = 0;
var samples = 0;
var multiZ = 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];
var walkable = 0;
for (var x = xStart; x < xStart + size; x++)
{
for (var y = yStart; y < yStart + size; y++)
{
if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfZ) == 0)
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var loc = new Point3D(x, y, sourceZ);
var probe = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
for (var d = 0; d < 8; d++)
{
continue;
}
var sourceZ = surfZ[0];
var dir = (Direction)d;
samples++;
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
var slowOk = Movement.Movement.CheckMovement(walker, map, loc, dir, out var slowZ);
var lookup = cache.TryGetMask(map, x, y, sourceZ);
// Creature corner-cut: a diagonal needs at least one flanking cardinal.
var probeOk = probe.IsWalkable(dir);
if (probeOk && (d & 1) == 1)
{
probeOk = probe.IsWalkable((Direction)((d - 1) & 7)) || probe.IsWalkable((Direction)((d + 1) & 7));
}
samples++;
if (slowOk)
{
walkable++;
}
if (lookup.HitKind == CacheHitKind.Fallthrough_MultiZ)
{
multiZ++;
continue;
}
Assert.True(lookup.IsHit, $"Cache returned !ok at ({x},{y}) hitKind={lookup.HitKind}");
if (lookup.WalkMask != baker.WalkMask)
{
disagreements++;
_output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{lookup.WalkMask:X2} baker=0x{baker.WalkMask:X2}");
continue;
}
if (lookup.WetMask != baker.WetMask)
{
disagreements++;
_output.WriteLine($"WET MASK DIFF @ ({x},{y}) cache=0x{lookup.WetMask:X2} baker=0x{baker.WetMask:X2}");
continue;
}
if (lookup.WetMask != 0)
{
wetCells++;
}
if (lookup.WalkZ_N != baker.WalkZ_N
|| lookup.WalkZ_NE != baker.WalkZ_NE || lookup.WalkZ_E != baker.WalkZ_E
|| lookup.WalkZ_SE != baker.WalkZ_SE || lookup.WalkZ_S != baker.WalkZ_S
|| lookup.WalkZ_SW != baker.WalkZ_SW || lookup.WalkZ_W != baker.WalkZ_W
|| lookup.WalkZ_NW != baker.WalkZ_NW)
{
disagreements++;
_output.WriteLine($"Z DIFF @ ({x},{y}) cache=({lookup.WalkZ_N},{lookup.WalkZ_NE},{lookup.WalkZ_E},{lookup.WalkZ_SE},{lookup.WalkZ_S},{lookup.WalkZ_SW},{lookup.WalkZ_W},{lookup.WalkZ_NW}) baker=({baker.WalkZ_N},{baker.WalkZ_NE},{baker.WalkZ_E},{baker.WalkZ_SE},{baker.WalkZ_S},{baker.WalkZ_SW},{baker.WalkZ_W},{baker.WalkZ_NW})");
if (slowOk != probeOk)
{
disagreements++;
_output.WriteLine($"WALKABLE DIFF @ ({x},{y},{sourceZ}) dir={dir} slow={slowOk} probe={probeOk}");
}
else if (slowOk && slowZ != probe.GetWalkZ(dir))
{
disagreements++;
_output.WriteLine($"Z DIFF @ ({x},{y},{sourceZ}) dir={dir} slow={slowZ} probe={probe.GetWalkZ(dir)}");
}
}
}
}
_output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ} wetCells={wetCells}");
walker.Delete();
_output.WriteLine($"[{label}] samples={samples} walkable={walkable} disagreements={disagreements}");
// Non-vacuity: at least the inn region must have at least one cell that produced a real cache answer.
// The dense region must contain a mix. All-walkable or all-blocked would mean the sweep
// agreed about nothing interesting.
if (label == "britain_inn_dense")
{
Assert.True(samples - multiZ > 0, "expected real cache answers in dense region");
Assert.NotEqual(0, walkable);
Assert.NotEqual(samples, walkable);
}
Assert.Equal(0, disagreements);
}
/// <summary>
/// Non-vacuity guard for the swim bake: scans a wide swath of the south-Britain bay
/// (Atlantic coast) and asserts at least one cell has a non-zero WetMask. Catches the
/// failure mode where StepProbe silently bakes zero swim output everywhere.
/// The swim bake must actually produce swim output. A probe that silently returned an empty
/// WetMask everywhere would pass every parity test above — walkers would still agree — while
/// leaving every swimming creature unable to move.
/// </summary>
[SkippableFact]
public void SwimBake_ProducesWetCells_OnKnownWaterRegion()
public void ProbeBakesWetCells_OnAKnownCoastline()
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
var map = TestMap;
Assert.NotNull(map);
// South Britain → Britain bay, includes Atlantic shoreline. 64×64 = 4096 cells;
// even a partial coastline straddle should yield dozens of wet cells.
// South Britain into Britain bay: 64x64 straddling the Atlantic shoreline.
const int xStart = 1430;
const int yStart = 1740;
const int size = 64;
@ -131,15 +132,240 @@ public class StepCacheParityTests
{
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)StepProbe.ComputeStandingZ(map, x, y, avgZ);
var baker = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
if (baker.WetMask != 0)
if (StepProbe.ComputeMaskAt(map, x, y, sourceZ).WetMask != 0)
{
wetCells++;
}
}
}
_output.WriteLine($"south-britain swim probe: wetCells={wetCells} of 4096");
Assert.True(wetCells > 0, "swim bake produced zero wet cells across a 64×64 coastal region");
_output.WriteLine($"south-britain coastline: wetCells={wetCells} of {size * size}");
Assert.True(wetCells > 0, $"swim bake produced zero wet cells across a {size}x{size} coastal region");
}
// ---- layer 2: the chunk returns what was baked ----
/// <summary>
/// Sweeps a region and compares what the cache serves against what StepProbe computes for the
/// same cell. The chunk is built from the probe, so any disagreement is a storage fault — a
/// bad cell index, a Z array crossed with another, a guard firing when it shouldn't.
///
/// Queries run at the cell's standable surface Z, which is where the cache anchors. Querying at
/// the land average instead would trip the source-Z guard on raised terrain (a causeway, a
/// walkway) and report a fallthrough that is correct behaviour rather than a fault.
/// </summary>
[Theory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
[InlineData("britain_causeway", 1475, 1641, 32)]
public void CacheMatchesProbe(string label, int xStart, int yStart, int size)
{
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // build on first touch: every cell should get a real answer
var map = TestMap;
Assert.NotNull(map);
var disagreements = 0;
var samples = 0;
var multiZ = 0;
Span<sbyte> surfaces = stackalloc sbyte[16];
for (var x = xStart; x < xStart + size; x++)
{
for (var y = yStart; y < yStart + size; y++)
{
if (StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaces) == 0)
{
continue; // nothing for a walker to stand on here
}
var sourceZ = surfaces[0];
var probe = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
var cached = cache.TryGetMask(map, x, y, sourceZ);
samples++;
if (cached.HitKind == CacheHitKind.Fallthrough_MultiZ)
{
multiZ++;
continue;
}
Assert.True(cached.IsHit, $"cache returned {cached.HitKind} at ({x},{y})");
if (cached.WalkMask != probe.WalkMask)
{
disagreements++;
_output.WriteLine($"WALK MASK DIFF @ ({x},{y}) cache=0x{cached.WalkMask:X2} probe=0x{probe.WalkMask:X2}");
continue;
}
if (cached.WetMask != probe.WetMask)
{
disagreements++;
_output.WriteLine($"WET MASK DIFF @ ({x},{y}) cache=0x{cached.WetMask:X2} probe=0x{probe.WetMask:X2}");
continue;
}
for (var d = 0; d < 8; d++)
{
var dir = (Direction)d;
if (cached.GetWalkZ(dir) != probe.GetWalkZ(dir))
{
disagreements++;
_output.WriteLine(
$"Z DIFF @ ({x},{y}) dir={dir} cache={cached.GetWalkZ(dir)} probe={probe.GetWalkZ(dir)}"
);
break;
}
}
}
}
_output.WriteLine($"[{label}] samples={samples} disagreements={disagreements} multiZ={multiZ}");
// Guard the sweep itself: if every cell fell through as multi-Z, the comparison above never
// actually ran and a zero disagreement count would mean nothing.
if (label == "britain_inn_dense")
{
Assert.True(samples - multiZ > 0, "no cell produced a real cache answer — the sweep proved nothing");
}
Assert.Equal(0, disagreements);
}
// ---- layer 3: end to end, over the states A* actually visits ----
/// <summary>
/// Flood-fills outward from a known-walkable tile using MovementImpl itself, and demands the
/// cache serve — and agree on — every state it reaches.
///
/// The fill is what makes this meaningful. MovementImpl returns the Z a step lands on, so each
/// reached (x, y, z) is a genuine standing state at its true Z: exactly the set A* would query,
/// discovered rather than assumed. It follows stair treads up at their own Zs and climbs onto
/// upper floors, so a single seed covers a whole connected structure with no fixed-Z guess to
/// get wrong. That matters because the failure this test exists to catch — anchoring a cell at
/// the land beneath a walkway instead of the walkway itself — is invisible to any test that
/// queries at the land Z, and turned the Britain sewer into a ~98% cache miss.
///
/// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a
/// raw diagonal bit legitimately differs from MovementImpl's diagonal answer.
/// </summary>
[Theory]
// Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one
// floods across a wide local area, so a handful covers thousands of states without a map walk.
[InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)] // static walkway over impassable land
[InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)] // stairs up to multi-Z upper floors
[InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // mixed buildings, stairs, raised floors
[InlineData("trammel_open_plain", 1500, 1600, 10, 2500)] // flat ground: catches 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; // build on first touch: every reached state should be answered
var map = TestMap;
Assert.NotNull(map);
var walker = new StaticWalker();
walker.MoveToWorld(new Point3D(sx, sy, sz), map);
var startIsWalkable = false;
for (var d = 0; d < 8 && !startIsWalkable; d++)
{
startIsWalkable = Movement.Movement.CheckMovement(walker, map, new Point3D(sx, sy, sz), (Direction)d, out _);
}
Assert.True(startIsWalkable, $"[{label}] seed ({sx},{sy},{sz}) is not walkable — bad waypoint");
var visited = new HashSet<(int x, int y, int z)> { (sx, sy, sz) };
var frontier = new Queue<(int x, int y, int z)>();
frontier.Enqueue((sx, sy, sz));
var states = 0;
var fellThrough = 0;
var disagreements = 0;
const int maxLog = 12;
while (frontier.Count > 0)
{
var (x, y, z) = frontier.Dequeue();
var loc = new Point3D(x, y, z);
var cached = cache.TryGetMask(map, x, y, (sbyte)z);
states++;
if (!cached.IsHit)
{
if (fellThrough < maxLog)
{
_output.WriteLine($"FELL THROUGH @ ({x},{y},{z}) hitKind={cached.HitKind}");
}
fellThrough++;
}
for (var d = 0; d < 8; d++)
{
var dir = (Direction)d;
var slowOk = Movement.Movement.CheckMovement(walker, map, loc, dir, out var slowZ);
if (slowOk)
{
var nx = x;
var ny = y;
Movement.Movement.Offset(dir, ref nx, ref ny);
if (visited.Count < maxStates && visited.Add((nx, ny, slowZ)))
{
frontier.Enqueue((nx, ny, slowZ));
}
}
if ((d & 1) != 0 || !cached.IsHit)
{
continue;
}
if (cached.IsWalkable(dir) != slowOk)
{
if (disagreements < maxLog)
{
_output.WriteLine($"WALK DIFF @ ({x},{y},{z}) dir={dir} slow={slowOk} cache={cached.IsWalkable(dir)}");
}
disagreements++;
}
else if (slowOk && slowZ != cached.GetWalkZ(dir))
{
if (disagreements < maxLog)
{
_output.WriteLine($"Z DIFF @ ({x},{y},{z}) dir={dir} slow={slowZ} cache={cached.GetWalkZ(dir)}");
}
disagreements++;
}
}
}
walker.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}] flood-fill stalled at {states} states — bad waypoint");
// Where the cache answers at all, it must be right.
Assert.Equal(0, disagreements);
// And it must answer nearly everywhere. A small residual is legitimate: a walkable surface
// directly beneath a bridge or stair ramp falls through because the bake's clearance check
// is deliberately conservative there. An anchor regression is not small — the pre-fix sewer
// fell through on ~98% — so a 1% ceiling separates the two comfortably.
Assert.True(
fallthroughPct < 1.0,
$"[{label}] cache fell through on {fallthroughPct:F2}% ({fellThrough}/{states}) of reachable states"
);
}
}

View file

@ -1,183 +0,0 @@
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;
}
}
}

View file

@ -1,127 +0,0 @@
using Server.Engines.Pathing.Cache;
using Xunit;
using Xunit.Abstractions;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StaticWalkabilityParityTests
{
private readonly ITestOutputHelper _output;
public StaticWalkabilityParityTests(ITestOutputHelper output)
{
_output = output;
}
[SkippableTheory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
public void BakerMatchesCheckMovement(string label, int xStart, int yStart, int size)
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
Assert.NotNull(map);
var stub = new ParityStubMobile();
stub.MoveToWorld(new Point3D(xStart, yStart, 0), map);
var disagreements = 0;
var samples = 0;
var oldWalkable = 0;
var newWalkable = 0;
for (var x = xStart; x < xStart + size; x++)
{
for (var y = yStart; y < yStart + size; y++)
{
map.GetAverageZ(x, y, out _, out var avgZ, out _);
var sourceZ = (sbyte)avgZ;
var loc = new Point3D(x, y, sourceZ);
var bakerResult = StepProbe.ComputeMaskAt(map, x, y, sourceZ);
for (var d = 0; d < 8; d++)
{
var dir = (Direction)d;
samples++;
var oldOk = Movement.Movement.CheckMovement(stub, map, loc, dir, out var oldZ);
// Apply creature diagonal corner-cut rule at query time:
// diagonal walkable iff raw-diagonal AND (left-partner OR right-partner).
// (Raw masks are correct per spec; baker omits diagonal logic per design.)
var newOk = bakerResult.IsWalkable(dir);
if (newOk && (d & 1) == 1)
{
var leftPartner = (Direction)((d - 1) & 7);
var rightPartner = (Direction)((d + 1) & 7);
if (!bakerResult.IsWalkable(leftPartner) && !bakerResult.IsWalkable(rightPartner))
{
newOk = false;
}
}
var newZ = bakerResult.GetWalkZ(dir);
if (oldOk)
{
oldWalkable++;
}
if (newOk)
{
newWalkable++;
}
if (oldOk != newOk)
{
disagreements++;
_output.WriteLine(
$"DISAGREE walkable @ ({x},{y},{sourceZ}) dir={dir}: " +
$"old={oldOk} new={newOk}"
);
}
else if (oldOk && oldZ != newZ)
{
disagreements++;
_output.WriteLine(
$"DISAGREE destZ @ ({x},{y},{sourceZ}) dir={dir}: " +
$"old={oldZ} new={newZ}"
);
}
}
}
}
stub.Delete();
_output.WriteLine(
$"[{label}] Samples: {samples}, Disagreements: {disagreements}, " +
$"OldWalkable: {oldWalkable}, NewWalkable: {newWalkable}"
);
// Non-vacuity guard for the variety case: at least one region must show some
// blocked directions. The open_plain region is allowed to be all-walkable.
if (label == "britain_inn_dense")
{
Assert.NotEqual(0, oldWalkable);
Assert.NotEqual(samples, oldWalkable);
}
Assert.Equal(0, disagreements);
}
/// <summary>
/// Minimal Mobile stub for parity testing. Inherits directly from Mobile so that
/// MovementImpl sees no BaseCreature-specific flags (CanSwim=false, CanFly=false,
/// bc==null → BaseCreature branches skipped) giving us the default static walker baseline.
/// </summary>
private class ParityStubMobile : Mobile
{
public ParityStubMobile()
{
Body = 0xC9; // arbitrary horse body
}
}
}