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
|
|
@ -1,17 +1,24 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Server.Buffers;
|
||||
using Server.Collections;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Engines.Pathing.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton store of per-chunk static walkability data. Chunks correspond to
|
||||
/// Map.SectorSize = 16; key encoding packs (mapId, chunkX, chunkY) into a long.
|
||||
/// Lazily built on first query; invalidated by version-check vs Sector.MultisVersion;
|
||||
/// memory bounded by MaxResidentChunks via probabilistic LRU eviction.
|
||||
/// Singleton store of static walkability, keyed by 16x16 chunk (one per map sector). Chunks build
|
||||
/// on demand and memory stays bounded by MaxResidentChunks through probabilistic LRU eviction, so
|
||||
/// the cache is usable with no on-disk bake at all; a baked .swb file only removes the first-touch
|
||||
/// build cost.
|
||||
///
|
||||
/// Default-walker scope only. Cells with multi-Z surfaces and queries for non-default
|
||||
/// walkers route to the MovementImpl slow path via the Fallthrough_* hit kinds.
|
||||
/// The cache answers for a default walker on static terrain. Anything outside that — a multi
|
||||
/// covering the cell, a query Z that doesn't match what the cell was baked at, stacked surfaces
|
||||
/// with no matching stratum — returns a Fallthrough_* kind, and the caller resolves that cell
|
||||
/// through MovementImpl instead. Callers must check <see cref="StepMask.IsHit"/>.
|
||||
/// </summary>
|
||||
public sealed class StepCache
|
||||
{
|
||||
|
|
@ -19,26 +26,23 @@ public sealed class StepCache
|
|||
|
||||
public static StepCache Instance { get; } = new();
|
||||
|
||||
private readonly Dictionary<long, StepChunk> _chunks = new();
|
||||
// Parallel list of keys for O(1) random sampling during eviction. Kept in lockstep
|
||||
// with _chunks: append on Miss_NotBuilt, swap-and-pop on eviction.
|
||||
private readonly Dictionary<long, StepChunk> _chunks = [];
|
||||
// Keys of _chunks, kept in lockstep with it, so eviction can sample a random resident chunk
|
||||
// in O(1). Appended on insert, swap-and-popped on eviction.
|
||||
private readonly List<long> _keysList = [];
|
||||
|
||||
// Second-touch promotion tracker. A chunk's first miss within the window returns
|
||||
// Fallthrough_NotBuilt; the caller takes the slow path. The Nth DISTINCT-FIND miss
|
||||
// within the same window (where N = MissPromotionThreshold) promotes to BuildChunk +
|
||||
// serve. We count distinct Find generations, not raw TryGetMask calls — A* expansion
|
||||
// hits each visited chunk many times in one Find, so per-call counting hits threshold
|
||||
// immediately and defeats the gate. Per-Find counting filters single-Find pass-throughs
|
||||
// (pet following a moving player) while still promoting chunks revisited by multiple
|
||||
// Finds (NPC patrolling fixed territory).
|
||||
private readonly Dictionary<long, ChunkMissState> _chunkMissTracker = new();
|
||||
// Promotion gate. A chunk's first miss returns Fallthrough_NotBuilt and the caller takes the
|
||||
// slow path; only once misses reach MissPromotionThreshold within MissPromotionWindowMs does
|
||||
// the chunk get built and served. This keeps one-off traffic — a pet trailing a player across
|
||||
// the map — from building chunks nothing will query again, while a creature working a fixed
|
||||
// territory still warms the chunks it revisits.
|
||||
//
|
||||
// The gate counts distinct Finds, not TryGetMask calls: A* probes each chunk it visits dozens
|
||||
// of times within a single pathfind, so per-call counting would cross any threshold instantly
|
||||
// and gate nothing.
|
||||
private readonly Dictionary<long, ChunkMissState> _chunkMissTracker = [];
|
||||
private const int MaxMissTrackerEntries = 4096;
|
||||
|
||||
// Generation counter incremented by BeginFindGeneration(). Sentinel 0 = "no Find started
|
||||
// yet"; treated as a distinct generation per call so callers that bypass BeginFindGeneration
|
||||
// (single-call tests, BakeMap with threshold=1) get sensible behavior.
|
||||
|
||||
private struct ChunkMissState
|
||||
{
|
||||
public byte MissCount;
|
||||
|
|
@ -78,24 +82,21 @@ public sealed class StepCache
|
|||
public bool PreloadOnLazyOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of misses on the same chunk within <see cref="MissPromotionWindowMs"/>
|
||||
/// required to trigger a build. 1 = eager (legacy behavior). 2 = second-touch (default,
|
||||
/// filters single-touch pass-throughs).
|
||||
/// Misses on the same chunk, within <see cref="MissPromotionWindowMs"/>, needed to build it.
|
||||
/// 1 builds eagerly on first touch; the default 2 waits for a second Find to show interest.
|
||||
/// </summary>
|
||||
public int MissPromotionThreshold { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Window over which misses against the same chunk accumulate toward promotion.
|
||||
/// Misses spaced wider than this restart the count. Default 30s.
|
||||
/// How long misses on a chunk accumulate toward promotion. A gap wider than this restarts
|
||||
/// the count.
|
||||
/// </summary>
|
||||
public uint MissPromotionWindowMs { get; set; } = 30_000;
|
||||
|
||||
/// <summary>
|
||||
/// Marks the start of a new pathfind. The promotion gate counts distinct Find
|
||||
/// generations per chunk, not raw TryGetMask calls — call this once at the top of
|
||||
/// each pathfind invocation so multiple cell expansions within one Find don't trip
|
||||
/// the threshold. Wraps at uint.MaxValue back to 1 (0 is reserved as the
|
||||
/// "no Find started yet" sentinel).
|
||||
/// Opens a new pathfind for the promotion gate. Call once per pathfind: the gate counts
|
||||
/// distinct Finds, so without this every cell expansion would count separately and the
|
||||
/// threshold would be met immediately. Wraps back to 1, since 0 means "no Find open".
|
||||
/// </summary>
|
||||
public void BeginFindGeneration()
|
||||
{
|
||||
|
|
@ -107,12 +108,11 @@ public sealed class StepCache
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Test-only: read the current Find generation.</summary>
|
||||
/// <summary>The open pathfind's generation, or 0 if none. See <see cref="BeginFindGeneration"/>.</summary>
|
||||
internal uint CurrentFindGeneration { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack (mapId, chunkX, chunkY) into a single long key.
|
||||
/// Layout: [reserved 16][mapId 16][chunkX 16][chunkY 16].
|
||||
/// Packs (mapId, chunkX, chunkY) into one key: [reserved 16][mapId 16][chunkX 16][chunkY 16].
|
||||
/// </summary>
|
||||
internal static long EncodeKey(int mapId, int chunkX, int chunkY) =>
|
||||
((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF);
|
||||
|
|
@ -134,9 +134,8 @@ public sealed class StepCache
|
|||
);
|
||||
|
||||
/// <summary>
|
||||
/// Drop all cached chunks AND zero every telemetry counter. Used by tests and
|
||||
/// benchmarks that need a known cold-start state. Counter reset is intentional —
|
||||
/// counters are since-last-clear, not since-startup.
|
||||
/// Returns the cache to a cold-start state: drops every chunk, closes the .swb readers, and
|
||||
/// zeroes the counters.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
|
|
@ -146,10 +145,8 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop all resident chunks AND zero counters, but keep lazy readers open.
|
||||
/// Useful in benchmark loops that want to measure "first query after boot" cost
|
||||
/// without paying the lazy-reader reopen overhead each iteration. Same intent as
|
||||
/// <see cref="Clear"/> minus the file-handle teardown.
|
||||
/// <see cref="Clear"/> without the file-handle teardown: drops the resident chunks and zeroes
|
||||
/// the counters, but leaves the .swb readers open so the next query can refill from them.
|
||||
/// </summary>
|
||||
public void ClearResidentChunks()
|
||||
{
|
||||
|
|
@ -171,16 +168,14 @@ public sealed class StepCache
|
|||
_buildsTotal = 0;
|
||||
}
|
||||
|
||||
// Per-map open .swb readers, populated by TryOpenLazyReader at startup. Chunks are
|
||||
// fetched on demand from the file when ResolveMissingChunk fires; resident memory
|
||||
// stays bounded by MaxResidentChunks regardless of file size.
|
||||
private readonly Dictionary<int, StepCacheFile.LazyReader> _lazyReaders = new();
|
||||
// Open .swb readers, one per map. Chunks are pulled from them on demand, so resident memory
|
||||
// stays bounded by MaxResidentChunks no matter how large the file is.
|
||||
private readonly Dictionary<int, StepCacheFile.LazyReader> _lazyReaders = [];
|
||||
|
||||
/// <summary>
|
||||
/// Walk every chunk in <paramref name="mapId"/>, populate the resident set, then
|
||||
/// save to <paramref name="path"/>. Returns the number of chunks written.
|
||||
/// Designed for offline / fixture use; blocks the calling thread for many seconds
|
||||
/// on a full Trammel walk.
|
||||
/// Builds every chunk in the map and writes them to <paramref name="path"/>, returning the
|
||||
/// number written. Blocks the caller for many seconds on a full-size map — run it offline or
|
||||
/// during maintenance, not on a live shard at peak.
|
||||
/// </summary>
|
||||
public int BakeMap(int mapId, string path)
|
||||
{
|
||||
|
|
@ -190,12 +185,11 @@ public sealed class StepCache
|
|||
return 0;
|
||||
}
|
||||
|
||||
// BakeMap is an explicit decision to populate every chunk; the promotion gate
|
||||
// would otherwise return Fallthrough_NotBuilt for every chunk (each touched once)
|
||||
// and the bake would write an empty file. Force eager build for the duration.
|
||||
// A bake touches each chunk exactly once, so the promotion gate would defer every one of
|
||||
// them and write an empty file. Baking is an explicit decision to populate everything, so
|
||||
// build eagerly for the duration.
|
||||
var prevThreshold = MissPromotionThreshold;
|
||||
MissPromotionThreshold = 1;
|
||||
var startTick = Core.TickCount;
|
||||
try
|
||||
{
|
||||
var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize;
|
||||
|
|
@ -207,12 +201,13 @@ public sealed class StepCache
|
|||
mapId, chunkCols, chunkRows, chunkCols * chunkRows
|
||||
);
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
for (var cy = 0; cy < chunkRows; cy++)
|
||||
{
|
||||
for (var cx = 0; cx < chunkCols; cx++)
|
||||
{
|
||||
// Any sourceZ works — the chunk is built on first access regardless of
|
||||
// whether the query returns Hit or Fallthrough_SourceZMismatch.
|
||||
// The sourceZ is irrelevant here: the chunk gets built on first access whether
|
||||
// the query ends up a Hit or a Fallthrough_SourceZMismatch.
|
||||
TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
|
||||
}
|
||||
|
||||
|
|
@ -221,14 +216,14 @@ public sealed class StepCache
|
|||
logger.Information(
|
||||
"PathBake map {MapId}: row {Row}/{Rows} ({Pct}%), {Resident} chunks resident, {Elapsed:F1}s, {HeapMB} MB heap",
|
||||
mapId, cy + 1, chunkRows, (cy + 1) * 100 / chunkRows,
|
||||
_chunks.Count, (Core.TickCount - startTick) / 1000.0, GC.GetTotalMemory(false) >> 20
|
||||
_chunks.Count, stopWatch.ElapsedMilliseconds / 1000.0, GC.GetTotalMemory(false) >> 20
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.Information(
|
||||
"PathBake map {MapId}: walk complete in {Elapsed:F1}s, writing {Resident} chunks to disk...",
|
||||
mapId, (Core.TickCount - startTick) / 1000.0, _chunks.Count
|
||||
mapId, stopWatch.ElapsedMilliseconds / 1000.0, _chunks.Count
|
||||
);
|
||||
}
|
||||
finally
|
||||
|
|
@ -240,50 +235,31 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist all resident chunks for <paramref name="mapId"/> to a .swb file. Returns
|
||||
/// the number of chunks written. The file embeds a TileData fingerprint so a stale
|
||||
/// file (built before a client patch) can be detected and rejected at open time.
|
||||
/// Writes the map's resident chunks to a .swb file and returns the count. The file carries a
|
||||
/// fingerprint of the tile and map data, so a bake made before a client patch is detected and
|
||||
/// rejected when it is next opened.
|
||||
/// </summary>
|
||||
public int SaveToFile(string path, int mapId)
|
||||
{
|
||||
var matching = 0;
|
||||
using var chunks = PooledRefList<(int chunkX, int chunkY, StepChunk chunk)>.Create();
|
||||
|
||||
foreach (var key in _keysList)
|
||||
{
|
||||
DecodeKey(key, out var keyMapId, out _, out _);
|
||||
DecodeKey(key, out var keyMapId, out var chunkX, out var chunkY);
|
||||
if (keyMapId == mapId)
|
||||
{
|
||||
matching++;
|
||||
chunks.Add((chunkX, chunkY, _chunks[key]));
|
||||
}
|
||||
}
|
||||
|
||||
var enumerator = _keysList.GetEnumerator();
|
||||
StepCacheFile.Write(path, (uint)mapId, (uint)matching, EmitChunk);
|
||||
enumerator.Dispose();
|
||||
return matching;
|
||||
|
||||
bool EmitChunk(out int chunkX, out int chunkY, out StepChunk chunk)
|
||||
{
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
var key = enumerator.Current;
|
||||
DecodeKey(key, out var emittedMapId, out chunkX, out chunkY);
|
||||
if (emittedMapId == mapId)
|
||||
{
|
||||
chunk = _chunks[key];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
chunkX = chunkY = 0;
|
||||
chunk = null!;
|
||||
return false;
|
||||
}
|
||||
StepCacheFile.Write(path, (uint)mapId, chunks.AsSpan());
|
||||
return chunks.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a .swb file as a lazy backing store for <paramref name="mapId"/>. Reads only
|
||||
/// header + chunk-offset index (~16 bytes per chunk); individual records are fetched
|
||||
/// on demand by <see cref="ResolveMissingChunk"/>. Returns false on missing file,
|
||||
/// magic / version mismatch, or TileData hash mismatch (stale bake).
|
||||
/// Opens a .swb file as a backing store for the map, reading only the header and chunk index
|
||||
/// up front; records are pulled as queries ask for them. Returns false if the file is missing,
|
||||
/// unreadable, or a stale bake whose fingerprint no longer matches the live tile data.
|
||||
/// </summary>
|
||||
public bool TryOpenLazyReader(string path, int mapId)
|
||||
{
|
||||
|
|
@ -323,10 +299,7 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes every chunk in <paramref name="reader"/> into the resident set.
|
||||
/// Called from <see cref="TryOpenLazyReader"/> when <see cref="PreloadOnLazyOpen"/>
|
||||
/// is set. Skips chunks whose live <see cref="Map.Sector.MultisVersion"/> doesn't
|
||||
/// match the file's snapshot — those will rebake on first query.
|
||||
/// Loads every chunk in the file into the resident set, for <see cref="PreloadOnLazyOpen"/>.
|
||||
/// </summary>
|
||||
private void PreloadFromLazyReader(int mapId, StepCacheFile.LazyReader reader)
|
||||
{
|
||||
|
|
@ -361,29 +334,52 @@ public sealed class StepCache
|
|||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of .swb readers currently open. Mostly for tests / telemetry.
|
||||
/// </summary>
|
||||
/// <summary>Number of .swb readers currently open.</summary>
|
||||
public int OpenLazyReaderCount => _lazyReaders.Count;
|
||||
|
||||
/// <summary>
|
||||
/// True if a valid .swb reader is open for <paramref name="mapId"/>. A reader only opens via
|
||||
/// <see cref="TryOpenLazyReader"/> after <see cref="StepCacheFile.OpenForLazy"/> validates the
|
||||
/// file's fingerprint against the live tile data, so "has reader" already means "present and
|
||||
/// up-to-date" — the boot prebake uses this to skip baking maps that don't need it, instead of
|
||||
/// recomputing the fingerprint a second time.
|
||||
/// True when a .swb reader is open for the map. A reader only opens after its fingerprint
|
||||
/// validates against the live tile data, so this already answers "is there an up-to-date bake
|
||||
/// for this map?" — the boot prebake leans on that to skip maps rather than fingerprint them
|
||||
/// a second time.
|
||||
/// </summary>
|
||||
public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId);
|
||||
|
||||
/// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset for (chunkX, chunkY)?</summary>
|
||||
/// <summary>Diagnostic: does the map's .swb hold a record for (chunkX, chunkY)?</summary>
|
||||
internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) =>
|
||||
_lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY);
|
||||
|
||||
/// <summary>
|
||||
/// Closes all open lazy readers, releasing their underlying file streams. Called from
|
||||
/// <see cref="Clear"/> so test cleanup can delete .swb files (they're held with
|
||||
/// FileShare.Read | FileShare.Delete, so this is mostly belt-and-suspenders).
|
||||
/// Diagnostic: the resident chunk covering (chunkX, chunkY), or null if it isn't resident.
|
||||
/// Exposed so tests can inspect and inject chunk state without reflecting into the internals.
|
||||
/// </summary>
|
||||
internal StepChunk GetResidentChunk(int mapId, int chunkX, int chunkY) =>
|
||||
_chunks.GetValueOrDefault(EncodeKey(mapId, chunkX, chunkY));
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: whether the eviction key list still mirrors the resident set exactly. A desync
|
||||
/// breaks sampled eviction — a stale key throws on lookup, a missing one pins a chunk resident
|
||||
/// forever — and it is invisible from the outside, so tests assert on it directly.
|
||||
/// </summary>
|
||||
internal bool ResidentIndexInSync()
|
||||
{
|
||||
if (_keysList.Count != _chunks.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var key in _keysList)
|
||||
{
|
||||
if (!_chunks.ContainsKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Closes every open .swb reader, releasing the underlying file streams.</summary>
|
||||
public void CloseLazyReaders()
|
||||
{
|
||||
foreach (var reader in _lazyReaders.Values)
|
||||
|
|
@ -394,18 +390,16 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probabilistic LRU sample size — picks SampleSize random resident chunks per
|
||||
/// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny
|
||||
/// fraction of the cost (no full sort). Redis uses the same approach (`maxmemory-samples`).
|
||||
/// 5 yields ~quality-of-true-LRU for cache eviction; higher values trade speed for accuracy.
|
||||
/// How many random resident chunks each eviction samples before dropping the oldest of them.
|
||||
/// Sampling approximates true LRU closely enough at a fraction of the cost, since it needs no
|
||||
/// sort and no access-ordered structure. Raising it trades speed for accuracy.
|
||||
/// </summary>
|
||||
private const int LruSampleSize = 5;
|
||||
|
||||
/// <summary>
|
||||
/// If resident chunk count exceeds MaxResidentChunks, evict via probabilistic LRU
|
||||
/// until the count is at or below the cap. Per-eviction cost is O(LruSampleSize),
|
||||
/// independent of resident count — sustained cap pressure has no perpetual perf hit.
|
||||
/// Called from CacheEvictionTimer; also callable directly from tests.
|
||||
/// Evicts chunks until the resident count is back within MaxResidentChunks. Each eviction costs
|
||||
/// O(<see cref="LruSampleSize"/>) regardless of how many chunks are resident, so sustained cap
|
||||
/// pressure doesn't degrade. Driven by <see cref="CacheEvictionTimer"/>.
|
||||
/// </summary>
|
||||
public void EnforceLruCap()
|
||||
{
|
||||
|
|
@ -421,8 +415,8 @@ public sealed class StepCache
|
|||
long oldestTouched = long.MaxValue;
|
||||
long oldestKey = 0;
|
||||
|
||||
// Sample LruSampleSize random keys; track the oldest by LastTouchedTicks.
|
||||
// With replacement is fine — collisions are rare and don't break correctness.
|
||||
// Sampling with replacement: a repeated key just wastes one sample, it can't pick a
|
||||
// wrong victim.
|
||||
var samples = Math.Min(LruSampleSize, _keysList.Count);
|
||||
for (var s = 0; s < samples; s++)
|
||||
{
|
||||
|
|
@ -459,11 +453,28 @@ public sealed class StepCache
|
|||
private const int ChunkSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// True if a multi (house / boat) covers (x, y) or any of its 8 neighbours. Multi-covered
|
||||
/// cells — plus the 1-cell halo, because a cell's mask encodes the edges TO its neighbours, so
|
||||
/// a neighbouring wall must block those edges — are served by the live movement path, not the
|
||||
/// static chunk cache. Cheap: an interior cell checks only its own sector (chunk == sector);
|
||||
/// only edge/corner cells additionally check the adjacent sector(s) the halo reaches.
|
||||
/// All-zero mask carrying a Fallthrough_* kind. <see cref="StepMask.IsHit"/> is false for
|
||||
/// these, so the caller ignores the payload and takes the slow path.
|
||||
/// </summary>
|
||||
private static StepMask Fallthrough(CacheHitKind kind) =>
|
||||
new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, kind);
|
||||
|
||||
/// <summary>Bumps the telemetry counter matching a served (non-fallthrough) hit kind.</summary>
|
||||
private void RecordServed(CacheHitKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||
case CacheHitKind.Hit: { _hits++; break; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a multi covers (x, y) or any of its 8 neighbours. The halo matters because a
|
||||
/// cell's mask encodes the edges TO its neighbours, so a wall one cell over has to block those
|
||||
/// edges. Since a chunk is a sector, an interior cell only inspects its own sector's HasMultis
|
||||
/// flag; edge and corner cells additionally check whichever adjacent sectors the halo reaches.
|
||||
/// </summary>
|
||||
private static bool MultiInfluence(Map map, int x, int y)
|
||||
{
|
||||
|
|
@ -480,7 +491,7 @@ public sealed class StepCache
|
|||
var south = (y & 15) == 15;
|
||||
if (!(west || east || north || south))
|
||||
{
|
||||
return false; // interior cell — its whole halo is inside the (multi-free) own sector
|
||||
return false; // interior cell: its whole halo lies in this sector, which has no multis
|
||||
}
|
||||
|
||||
return west && map.GetRealSector(sx - 1, sy).HasMultis
|
||||
|
|
@ -494,49 +505,25 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind.
|
||||
/// Inspect <see cref="StepMask.IsHit"/> to decide whether to use the result or fall
|
||||
/// back to the slow path.
|
||||
/// The hot-path query: one lookup yields the cell's 8-direction mask, its 8 destination Zs,
|
||||
/// and the hit kind. Check <see cref="StepMask.IsHit"/> before trusting the payload — on any
|
||||
/// fallthrough it is all zeroes and the caller must resolve the cell through MovementImpl.
|
||||
/// </summary>
|
||||
public StepMask TryGetMask(Map map, int x, int y, sbyte sourceZ)
|
||||
{
|
||||
if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
||||
{
|
||||
_fallthroughOffMap++;
|
||||
return new StepMask(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CacheHitKind.Fallthrough_OffMap
|
||||
);
|
||||
return Fallthrough(CacheHitKind.Fallthrough_OffMap);
|
||||
}
|
||||
|
||||
// Multis (houses, boats) are not baked into the static chunk cache (they're dynamic
|
||||
// content). If a multi covers this cell or its 1-cell halo, route to the live movement
|
||||
// path, which is fully multi-aware. Gated on Sector.HasMultis, so the multi-free majority
|
||||
// of the map pays a single (interior) sector lookup.
|
||||
// Multis are dynamic, so they are never baked into a chunk. Cells they touch go to the
|
||||
// multi-aware path instead. The check is gated on Sector.HasMultis, so the multi-free
|
||||
// majority of the map pays one sector lookup for it.
|
||||
if (MultiInfluence(map, x, y))
|
||||
{
|
||||
_fallthroughMulti++;
|
||||
return new StepMask(
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
CacheHitKind.Fallthrough_Multi
|
||||
);
|
||||
return Fallthrough(CacheHitKind.Fallthrough_Multi);
|
||||
}
|
||||
|
||||
var chunkX = x >> 4;
|
||||
|
|
@ -546,8 +533,8 @@ public sealed class StepCache
|
|||
var hitKindResult = CacheHitKind.Hit;
|
||||
if (!_chunks.TryGetValue(key, out var chunk))
|
||||
{
|
||||
// Try lazy file first — file-loaded chunks bypass the miss tracker because
|
||||
// the .swb represents an explicit prior decision to keep this chunk warm.
|
||||
// The .swb is consulted before the promotion gate: a baked chunk is already an explicit
|
||||
// decision to keep this area warm, and loading it is far cheaper than building it.
|
||||
chunk = TryLoadFromLazyReader(map, chunkX, chunkY);
|
||||
if (chunk != null)
|
||||
{
|
||||
|
|
@ -565,94 +552,44 @@ public sealed class StepCache
|
|||
else
|
||||
{
|
||||
_fallthroughNotBuilt++;
|
||||
return new StepMask(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CacheHitKind.Fallthrough_NotBuilt
|
||||
);
|
||||
return Fallthrough(CacheHitKind.Fallthrough_NotBuilt);
|
||||
}
|
||||
}
|
||||
|
||||
// A resident chunk is static-only — it never goes stale from multis (multi-covered cells
|
||||
// fall through to the live path above).
|
||||
// No staleness check: a resident chunk holds only static terrain, and every cell a multi
|
||||
// could have changed already fell through above.
|
||||
chunk.LastTouchedTicks = Core.TickCount;
|
||||
|
||||
var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4));
|
||||
|
||||
if (chunk.IsCellMultiZ(cellIndex))
|
||||
{
|
||||
// Tier 4: try the per-cell strata. Each stratum is keyed by its bake-time
|
||||
// standing-Z; a query matches when |sourceZ - stratum.zCenter| <= StepHeight.
|
||||
// Stacked surfaces: pick the stratum baked nearest the query Z. Multi-Z cells are
|
||||
// served only from strata, never from the main mask.
|
||||
if (TryStratumHit(chunk, cellIndex, sourceZ, hitKindResult, out var stratumResult))
|
||||
{
|
||||
switch (hitKindResult)
|
||||
{
|
||||
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||
case CacheHitKind.Hit: { _hits++; break; }
|
||||
}
|
||||
RecordServed(hitKindResult);
|
||||
return stratumResult;
|
||||
}
|
||||
|
||||
_fallthroughMultiZ++;
|
||||
return new StepMask(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CacheHitKind.Fallthrough_MultiZ
|
||||
);
|
||||
return Fallthrough(CacheHitKind.Fallthrough_MultiZ);
|
||||
}
|
||||
|
||||
// Source-Z guard: the cache stores one answer per cell baked at SourceZ.
|
||||
// StepHeight tolerance accepts incremental Z jitter; loosening it breaks parity
|
||||
// because tile reachability shifts at step-height boundaries.
|
||||
// Source-Z guard. A cell holds one answer, baked at one standing Z, so a query from too far
|
||||
// above or below it would get an answer that doesn't apply. The StepHeight tolerance
|
||||
// absorbs ordinary Z jitter and cannot be widened: reachability flips at exactly that
|
||||
// boundary, so a looser guard would serve answers that disagree with MovementImpl.
|
||||
if (Math.Abs(sourceZ - chunk.SourceZ[cellIndex]) > StepHeight)
|
||||
{
|
||||
// Swim-layer fallback for shore cells: if the chunk has the layer and this
|
||||
// cell's water-surface Z is within StepHeight of the query, serve from the
|
||||
// swim layer (computed at swim-perspective Z). Walker queries on shore cells
|
||||
// fall through this branch via their Z mismatch with SwimSourceZ.
|
||||
// Unless this is a shore cell and the query is coming from the water, in which case the
|
||||
// swim layer holds the answer baked from the water surface.
|
||||
if (chunk.HasSwimLayer)
|
||||
{
|
||||
var swimSrc = chunk.SwimSourceZ[cellIndex];
|
||||
if (swimSrc != StepChunk.NoSwimLayerCell && Math.Abs(sourceZ - swimSrc) <= StepHeight)
|
||||
{
|
||||
switch (hitKindResult)
|
||||
{
|
||||
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||
case CacheHitKind.Hit: { _hits++; break; }
|
||||
}
|
||||
RecordServed(hitKindResult);
|
||||
return new StepMask(
|
||||
0, chunk.SwimMask[cellIndex],
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
|
|
@ -670,35 +607,10 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
_fallthroughSourceZMismatch++;
|
||||
return new StepMask(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CacheHitKind.Fallthrough_SourceZMismatch
|
||||
);
|
||||
return Fallthrough(CacheHitKind.Fallthrough_SourceZMismatch);
|
||||
}
|
||||
|
||||
switch (hitKindResult)
|
||||
{
|
||||
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||
case CacheHitKind.Hit: { _hits++; break; }
|
||||
}
|
||||
RecordServed(hitKindResult);
|
||||
|
||||
return new StepMask(
|
||||
chunk.WalkMask[cellIndex],
|
||||
|
|
@ -724,41 +636,32 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a fresh StepChunk loaded from the lazy file reader, or null if there's no
|
||||
/// open reader for the map / no record at (chunkX, chunkY) / the loaded snapshot is
|
||||
/// stale relative to the live sector's <see cref="Map.Sector.MultisVersion"/>. A null
|
||||
/// return means the caller should consult the miss tracker; a stale return means
|
||||
/// "rebuild, the .swb is out of date and a future SaveToFile will overwrite it."
|
||||
/// Loads a chunk from the map's .swb, or null if no reader is open or the file has no record at
|
||||
/// (chunkX, chunkY). No staleness check is needed here — the fingerprint was validated when the
|
||||
/// file was opened, and the chunks are static-only.
|
||||
/// </summary>
|
||||
private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY)
|
||||
{
|
||||
if (!_lazyReaders.TryGetValue(map.MapID, out var reader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// Static-only chunks are valid once the file fingerprint matched at open time; multi-covered
|
||||
// cells fall through before reaching here. Returns null when the file lacks this chunk.
|
||||
return reader.TryReadChunk(chunkX, chunkY);
|
||||
}
|
||||
private StepChunk TryLoadFromLazyReader(Map map, int chunkX, int chunkY) =>
|
||||
_lazyReaders.TryGetValue(map.MapID, out var reader) ? reader.TryReadChunk(chunkX, chunkY) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Records a miss for <paramref name="chunkKey"/> and decides whether to build now
|
||||
/// or defer to slow path. Counts distinct Find generations, not raw calls — multiple
|
||||
/// TryGetMask calls within one Find (BeginFindGeneration scope) count as one touch.
|
||||
/// Returns true when DISTINCT-FIND misses within the window cross
|
||||
/// <see cref="MissPromotionThreshold"/>; caller should run BuildChunk and serve.
|
||||
/// Returns false otherwise; caller should return Fallthrough_NotBuilt so the algorithm
|
||||
/// uses the slow path. Generation 0 ("no Find active") treats every call as distinct,
|
||||
/// preserving legacy semantics for callers that don't call BeginFindGeneration.
|
||||
/// Records a miss and answers whether the chunk has now earned a build. True means build and
|
||||
/// serve; false means return Fallthrough_NotBuilt and let the caller take the slow path.
|
||||
///
|
||||
/// A miss only counts once per Find (see <see cref="BeginFindGeneration"/>). With no Find open
|
||||
/// — a direct caller, or a bake — every call counts separately.
|
||||
/// </summary>
|
||||
private bool ShouldPromoteAfterMiss(long chunkKey)
|
||||
{
|
||||
// Environment.TickCount, not Core.TickCount: tests/bench fixtures may not advance
|
||||
// the game-loop tick. The promotion window is wall-clock anyway.
|
||||
// Environment.TickCount rather than Core.TickCount: the window is wall-clock, and test and
|
||||
// benchmark fixtures don't necessarily advance the game loop's tick.
|
||||
var now = (uint)Environment.TickCount;
|
||||
var gen = CurrentFindGeneration;
|
||||
|
||||
if (_chunkMissTracker.TryGetValue(chunkKey, out var state))
|
||||
// One hash lookup for the whole update — the entry is mutated through the ref instead
|
||||
// of being re-hashed and re-probed by an indexer assignment. Safe to hold across the
|
||||
// Remove below only because nothing reads it afterwards.
|
||||
ref var state = ref CollectionsMarshal.GetValueRefOrNullRef(_chunkMissTracker, chunkKey);
|
||||
if (!Unsafe.IsNullRef(ref state))
|
||||
{
|
||||
// Same Find generation as the last touch — A* expansion is probing this chunk
|
||||
// multiple times in one pathfind. Don't increment; the gate counts distinct
|
||||
|
|
@ -772,12 +675,11 @@ public sealed class StepCache
|
|||
var elapsed = now - state.LastMissTickStamp;
|
||||
if (elapsed > MissPromotionWindowMs)
|
||||
{
|
||||
_chunkMissTracker[chunkKey] = new ChunkMissState
|
||||
{
|
||||
MissCount = 1,
|
||||
LastMissTickStamp = now,
|
||||
LastFindGeneration = gen
|
||||
};
|
||||
// Outside the window — restart the count. Never promotes on this call, even at
|
||||
// threshold 1, matching the pre-existing gate semantics.
|
||||
state.MissCount = 1;
|
||||
state.LastMissTickStamp = now;
|
||||
state.LastFindGeneration = gen;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -788,12 +690,9 @@ public sealed class StepCache
|
|||
return true;
|
||||
}
|
||||
|
||||
_chunkMissTracker[chunkKey] = new ChunkMissState
|
||||
{
|
||||
MissCount = newCount,
|
||||
LastMissTickStamp = now,
|
||||
LastFindGeneration = gen
|
||||
};
|
||||
state.MissCount = newCount;
|
||||
state.LastMissTickStamp = now;
|
||||
state.LastFindGeneration = gen;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -817,27 +716,30 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop tracker entries older than the promotion window. Called when the tracker hits
|
||||
/// its capacity ceiling. If the prune doesn't reclaim anything (every entry is in
|
||||
/// window), the cap is enforced by clearing — the worst case is a few extra
|
||||
/// Fallthrough_NotBuilt returns until traffic re-establishes hot chunks.
|
||||
/// Drops tracker entries that have aged out of the promotion window, once the tracker hits its
|
||||
/// capacity ceiling. When nothing has aged out, the whole tracker is cleared to enforce the cap
|
||||
/// — that costs a few extra Fallthrough_NotBuilt returns while traffic re-establishes the hot
|
||||
/// chunks, which is cheaper than letting the tracker grow without bound.
|
||||
/// </summary>
|
||||
private void PruneMissTracker(uint now)
|
||||
{
|
||||
var window = MissPromotionWindowMs;
|
||||
var beforeCount = _chunkMissTracker.Count;
|
||||
var toRemove = new List<long>();
|
||||
|
||||
using var toRemove = PooledRefQueue<long>.Create();
|
||||
foreach (var kvp in _chunkMissTracker)
|
||||
{
|
||||
if (now - kvp.Value.LastMissTickStamp > window)
|
||||
{
|
||||
toRemove.Add(kvp.Key);
|
||||
toRemove.Enqueue(kvp.Key);
|
||||
}
|
||||
}
|
||||
foreach (var k in toRemove)
|
||||
|
||||
while (toRemove.Count > 0)
|
||||
{
|
||||
_chunkMissTracker.Remove(k);
|
||||
_chunkMissTracker.Remove(toRemove.Dequeue());
|
||||
}
|
||||
|
||||
if (_chunkMissTracker.Count == beforeCount)
|
||||
{
|
||||
_chunkMissTracker.Clear();
|
||||
|
|
@ -851,14 +753,15 @@ public sealed class StepCache
|
|||
var baseX = chunkX << 4;
|
||||
var baseY = chunkY << 4;
|
||||
|
||||
// Tier 4 strata accumulator. Lazily allocated when the first multi-Z cell
|
||||
// appears; otherwise the chunk has zero strata overhead.
|
||||
// Strata accumulator, created on the first multi-Z cell so single-Z chunks pay nothing.
|
||||
// strataData is rented scratch; the chunk receives an exact-size copy, so the pooled array
|
||||
// never escapes this method.
|
||||
ushort[] strataOffsetByCell = null;
|
||||
List<byte> strataData = null;
|
||||
byte[] strataData = null;
|
||||
var strataLen = 0;
|
||||
|
||||
// Reused per cell: the standable surface Zs (walkway / bridge / floor levels). 16 is
|
||||
// generous — clearance forces standable surfaces >= PersonHeight apart, so a 256-tall
|
||||
// Z range admits at most ~16 anyway.
|
||||
// Standable surface Zs for the current cell. 16 slots is generous: clearance forces
|
||||
// surfaces at least PersonHeight apart, so an sbyte Z range can't hold more than ~16.
|
||||
Span<sbyte> surfaceZs = stackalloc sbyte[16];
|
||||
|
||||
for (var dy = 0; dy < ChunkSize; dy++)
|
||||
|
|
@ -871,16 +774,13 @@ public sealed class StepCache
|
|||
|
||||
map.GetAverageZ(x, y, out _, out var avgZ, out _);
|
||||
|
||||
// Anchor the cell at the surface a creature actually STANDS on, not the land
|
||||
// average. For plain overworld that's the land; for static-over-land terrain
|
||||
// (sewer/dungeon walkways, bridges, stair treads, raised foundations, upper
|
||||
// building floors) it's the walkable static surface — which the old
|
||||
// ComputeStandingZ(avgZ) anchor missed, producing source-Z fallthroughs (or,
|
||||
// within the StepHeight tolerance band on stairs, a wrong vertical-neighbor
|
||||
// answer baked at the adjacent tread). ComputeStandableSurfaceZs returns the
|
||||
// standable surfaces ascending; the lowest is the primary anchor and A* tracks
|
||||
// newZ to match it. Cells with no standable walk surface (deep water, solid
|
||||
// rock) fall back to the land avg so the swim layer / wetMask still bake.
|
||||
// Anchor the cell at the surface a creature stands on, not the land average. On
|
||||
// open terrain those coincide, but on static-over-land geometry — walkways,
|
||||
// bridges, stair treads, upper floors — the walkable surface is the static, and
|
||||
// anchoring at the land below it would make every query fall through the source-Z
|
||||
// guard. Surfaces come back ascending and the lowest is the anchor; A* tracks its
|
||||
// per-cell Z to match. A cell with no standable surface at all (deep water, solid
|
||||
// rock) falls back to the land average so its swim data still bakes.
|
||||
var surfaceCount = StepProbe.ComputeStandableSurfaceZs(map, x, y, surfaceZs);
|
||||
var standingZ = surfaceCount > 0 ? surfaceZs[0] : (sbyte)Math.Clamp(avgZ, sbyte.MinValue, sbyte.MaxValue);
|
||||
|
||||
|
|
@ -906,15 +806,12 @@ public sealed class StepCache
|
|||
chunk.SwimZW[cell] = result.SwimZ_W;
|
||||
chunk.SwimZNW[cell] = result.SwimZ_NW;
|
||||
|
||||
// Shore-cell handling: if the cell has BOTH a walk surface (standing Z)
|
||||
// AND a water surface (Wet land tile or wet static) at a Z separated by
|
||||
// > StepHeight, populate the swim layer at swim-perspective Z. Only when
|
||||
// ComputeMaskAt produces a non-zero swim mask — bridges/docks/piers with
|
||||
// insufficient vertical clearance for a swim creature's body envelope
|
||||
// produce wetMask=0 (StaticsBlockAt rejects them), and we skip those cells
|
||||
// rather than baking a stratum that always answers "no movement." The
|
||||
// sentinel NoSwimLayerCell stays in SwimSourceZ for skipped cells; the
|
||||
// chunk only sets HasSwimLayer when at least one cell got a usable entry.
|
||||
// Shore cell: a walkable surface and a water surface more than StepHeight apart.
|
||||
// The main mask is baked at the walk surface, so a swimmer querying from the water
|
||||
// would fail the source-Z guard; bake it a second answer from the water surface.
|
||||
// An empty swim mask means the water is unreachable anyway — a dock or pier with
|
||||
// too little clearance for a swimmer's body — so leave those cells at the
|
||||
// NoSwimLayerCell sentinel rather than store an answer that always says "blocked".
|
||||
var swimZRaw = StepProbe.ComputeSwimStandingZ(map, x, y);
|
||||
if (swimZRaw != int.MinValue && Math.Abs(swimZRaw - standingZ) > StepHeight)
|
||||
{
|
||||
|
|
@ -939,36 +836,33 @@ public sealed class StepCache
|
|||
}
|
||||
}
|
||||
|
||||
// Stacked walkable surfaces at one cell (ground + 1st + 2nd building floors,
|
||||
// a bridge over a walkable path, etc.): bake a stratum per standable surface
|
||||
// so a query at any floor's Z hits. The primary (lowest) surface is also in
|
||||
// the main mask above, but multi-Z cells are served exclusively from strata,
|
||||
// so every standable surface — including the primary — must appear here.
|
||||
// Single-surface cells (the common case, incl. stair treads and sewer
|
||||
// walkways) skip this entirely and stay on the fast single-mask path.
|
||||
// Stacked walkable surfaces — a bridge over a path, the floors of a building —
|
||||
// need one stratum each so a query at any of their Zs finds an answer. Every
|
||||
// surface goes in, including the lowest, because a multi-Z cell is served only
|
||||
// from its strata and never from the main mask baked above.
|
||||
if (surfaceCount >= 2)
|
||||
{
|
||||
if (strataOffsetByCell == null)
|
||||
{
|
||||
strataOffsetByCell = new ushort[StepChunk.CellsPerChunk];
|
||||
for (var i = 0; i < strataOffsetByCell.Length; i++)
|
||||
{
|
||||
strataOffsetByCell[i] = StepChunk.NoStrata;
|
||||
}
|
||||
strataData = new List<byte>(256);
|
||||
strataOffsetByCell.AsSpan().Fill(StepChunk.NoStrata);
|
||||
// NoStrata bounds the packed data to NoStrata bytes (see StepChunk), so
|
||||
// renting that much up front leaves the record guard below as the only
|
||||
// bound the writes need.
|
||||
strataData = STArrayPool<byte>.Shared.Rent(StepChunk.NoStrata);
|
||||
}
|
||||
|
||||
// Cap at 65,535 byte offsets — well above realistic per-chunk strata
|
||||
// volume. If we ever blow past this we silently leave the cell single-Z
|
||||
// (it keeps the land-anchored main mask and falls through off-surface).
|
||||
if (strataData.Count <= ushort.MaxValue - StepChunk.StratumByteLength * 8)
|
||||
// One count byte plus a record per surface. A cell whose record won't fit stays
|
||||
// single-Z: it keeps the main mask and falls through off its anchor surface.
|
||||
var recordLength = 1 + surfaceCount * StepChunk.StratumByteLength;
|
||||
if (strataLen + recordLength <= StepChunk.NoStrata)
|
||||
{
|
||||
strataOffsetByCell[cell] = (ushort)strataData.Count;
|
||||
strataData.Add((byte)surfaceCount);
|
||||
strataOffsetByCell[cell] = (ushort)strataLen;
|
||||
strataData[strataLen++] = (byte)surfaceCount;
|
||||
for (var i = 0; i < surfaceCount; i++)
|
||||
{
|
||||
var sz = surfaceZs[i];
|
||||
AppendStratumBytes(strataData, new StepProbe.ComputedStratum(sz, StepProbe.ComputeMaskAt(map, x, y, sz)));
|
||||
WriteStratum(strataData, ref strataLen, sz, StepProbe.ComputeMaskAt(map, x, y, sz));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -977,7 +871,8 @@ public sealed class StepCache
|
|||
|
||||
if (strataOffsetByCell != null)
|
||||
{
|
||||
chunk.SetStrata(strataOffsetByCell, strataData.ToArray());
|
||||
chunk.SetStrata(strataOffsetByCell, strataData.AsSpan(0, strataLen).ToArray());
|
||||
STArrayPool<byte>.Shared.Return(strataData);
|
||||
}
|
||||
|
||||
_buildsTotal++;
|
||||
|
|
@ -985,10 +880,10 @@ public sealed class StepCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tier 4 strata lookup. Walks the cell's stratum list, returns true with the first
|
||||
/// stratum whose <c>zCenter</c> is within StepHeight of <paramref name="sourceZ"/>.
|
||||
/// Layout matches <see cref="StepChunk.StrataData"/>: u8 count, then count × 19-byte
|
||||
/// stratum (sbyte zCenter, byte walkMask, byte wetMask, 8 sbyte walkZ, 8 sbyte swimZ).
|
||||
/// Finds the cell's stratum matching <paramref name="sourceZ"/> — the first whose zCenter is
|
||||
/// within StepHeight — and builds its mask. False when the cell has no strata or none of them
|
||||
/// sit near enough, in which case the caller falls through. Reads the layout
|
||||
/// <see cref="WriteStratum"/> writes.
|
||||
/// </summary>
|
||||
private static bool TryStratumHit(
|
||||
StepChunk chunk, int cellIndex, sbyte sourceZ, CacheHitKind hitKind, out StepMask result
|
||||
|
|
@ -1050,29 +945,33 @@ public sealed class StepCache
|
|||
return false;
|
||||
}
|
||||
|
||||
private static void AppendStratumBytes(List<byte> dst, in StepProbe.ComputedStratum s)
|
||||
/// <summary>
|
||||
/// Packs one stratum into <paramref name="dst"/> at <paramref name="pos"/>, advancing it by
|
||||
/// <see cref="StepChunk.StratumByteLength"/>. Layout must stay in lockstep with
|
||||
/// <see cref="TryStratumHit"/> and <see cref="StepCacheFile"/>.
|
||||
/// </summary>
|
||||
private static void WriteStratum(Span<byte> dst, ref int pos, sbyte zCenter, in StepMask mask)
|
||||
{
|
||||
dst.Add((byte)s.ZCenter);
|
||||
dst.Add(s.Mask.WalkMask);
|
||||
dst.Add(s.Mask.WetMask);
|
||||
dst.Add((byte)s.Mask.WalkZ_N);
|
||||
dst.Add((byte)s.Mask.WalkZ_NE);
|
||||
dst.Add((byte)s.Mask.WalkZ_E);
|
||||
dst.Add((byte)s.Mask.WalkZ_SE);
|
||||
dst.Add((byte)s.Mask.WalkZ_S);
|
||||
dst.Add((byte)s.Mask.WalkZ_SW);
|
||||
dst.Add((byte)s.Mask.WalkZ_W);
|
||||
dst.Add((byte)s.Mask.WalkZ_NW);
|
||||
dst.Add((byte)s.Mask.SwimZ_N);
|
||||
dst.Add((byte)s.Mask.SwimZ_NE);
|
||||
dst.Add((byte)s.Mask.SwimZ_E);
|
||||
dst.Add((byte)s.Mask.SwimZ_SE);
|
||||
dst.Add((byte)s.Mask.SwimZ_S);
|
||||
dst.Add((byte)s.Mask.SwimZ_SW);
|
||||
dst.Add((byte)s.Mask.SwimZ_W);
|
||||
dst.Add((byte)s.Mask.SwimZ_NW);
|
||||
dst[pos++] = (byte)zCenter;
|
||||
dst[pos++] = mask.WalkMask;
|
||||
dst[pos++] = mask.WetMask;
|
||||
dst[pos++] = (byte)mask.WalkZ_N;
|
||||
dst[pos++] = (byte)mask.WalkZ_NE;
|
||||
dst[pos++] = (byte)mask.WalkZ_E;
|
||||
dst[pos++] = (byte)mask.WalkZ_SE;
|
||||
dst[pos++] = (byte)mask.WalkZ_S;
|
||||
dst[pos++] = (byte)mask.WalkZ_SW;
|
||||
dst[pos++] = (byte)mask.WalkZ_W;
|
||||
dst[pos++] = (byte)mask.WalkZ_NW;
|
||||
dst[pos++] = (byte)mask.SwimZ_N;
|
||||
dst[pos++] = (byte)mask.SwimZ_NE;
|
||||
dst[pos++] = (byte)mask.SwimZ_E;
|
||||
dst[pos++] = (byte)mask.SwimZ_SE;
|
||||
dst[pos++] = (byte)mask.SwimZ_S;
|
||||
dst[pos++] = (byte)mask.SwimZ_SW;
|
||||
dst[pos++] = (byte)mask.SwimZ_W;
|
||||
dst[pos++] = (byte)mask.SwimZ_NW;
|
||||
}
|
||||
|
||||
private const int PersonHeight = 16;
|
||||
private const int StepHeight = 2;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue