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:
parent
e035768ef8
commit
b852bca41e
23 changed files with 1663 additions and 2223 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue