feat(pathfinding): lazy .swb backing store for the step cache (#2448)

## Summary

Adds a binary disk format + lazy reader so the step cache can warm-start from a precomputed file without paying chunk-build cost on the first pathfind through a region. **Resident memory stays bounded by `MaxResidentChunks` regardless of file size** — opening a `.swb` reads only the header + chunk-offset index (~16 bytes per indexed chunk), and individual chunks are seeked + deserialized only when `ResolveMissingChunk` asks for them.

The lazy design (vs. an eager bulk load): a 250 MB bake on a RAM-constrained shard never materializes more than the LRU cap (~40 MB at the default 8192-chunk cap), and unwanted regions never enter memory at all.

Builds on PR #2447.

## What changed

- **`StepCacheFile`** — binary reader/writer module. Writer emits header → chunks (offsets recorded) → index trailer, then patches the header's `IndexOffset` field. Reader is `OpenForLazy(path)` returning a `LazyReader` that holds an open `FileStream` + offset dictionary.
- **`StepCacheFile.LazyReader`** — `TryReadChunk(chunkX, chunkY)` does a single seek + bulk read for one record. `Dispose` releases the underlying stream. Files are opened with `FileShare.Read | FileShare.Delete` so admin tooling can replace them.
- **TileData fingerprint via XxHash3.** The `.swb` header carries a hash of `LandTable + ItemTable` flags. Load rejects any file whose hash doesn't match the running server. Computed via `HashUtility.ComputeHash64` (engine-blessed hasher) — adds a `ReadOnlySpan<byte>` overload alongside the existing `ReadOnlySpan<char>` one for parity.
- **`StepCache.SaveToFile(path, mapId)`** — writes resident chunks for the given map.
- **`StepCache.TryOpenLazyReader(path, mapId)`** — opens the file, validates header, holds the reader for the map's lifetime.
- **`StepCache.ResolveMissingChunk`** — now consults the lazy reader before invoking the runtime baker. A loaded chunk whose `BuiltMultisVersion` doesn't match the live sector falls through to the baker (snapshot was made before a multi was added/removed in that sector).
- **`StepCache.Clear` closes lazy readers.** Test cleanup can delete `.swb` files cleanly.
- **Auto-load at startup.** `PathCacheCommands.Configure()` opens `Data/Pathfinding/<mapId>.swb` as a lazy reader for every map.
- **`[PathCacheSave`** / **`[PathCacheLoad`** — admin commands for the same workflow.
- **`pathfinding.maxResidentChunks` shard-tunable.** Read from `server.cfg` at boot via `ServerConfiguration.GetOrUpdateSetting` (default 8192 ≈ 40 MB). Small shards can tune down; large shards with substantial bakes can tune up to reduce eviction churn. Default is written back to `server.cfg` on first boot, matching the engine pattern used by other settings.

## File layout (v1)

```
Header (48 bytes):
  u32  Magic           = 0x42575300 ('SWB\0')
  u32  Version         = 1
  u32  MapId
  u64  TileDataHash    XxHash3 over LandTable + ItemTable flags (HashUtility)
  u64  BakeTimestamp   informational
  u32  ChunkCount
  u64  IndexOffset     file position where the chunk index begins

Chunk records (fixed size, ~5,393 bytes each, +32 if multi-Z):
  u16  ChunkX
  u16  ChunkY
  u32  BuiltMultisVersion
  u8   HasMultiZ
  byte WalkMask[256], WetMask[256]
  sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256]
  [byte MultiZCells[32] when HasMultiZ == 1]

Index trailer (16 × ChunkCount bytes):
  (u64 chunkKey, u64 fileOffset)
```

## Memory math

| Scenario | Disk file | RAM at boot | Notes |
|---|---|---|---|
| Empty / no `.swb` files | — | 0 | Silent; cache builds on demand. |
| Admin-curated towns (5K chunks) | 25 MB | 0 + per-query | Index ≈ 80 KB. Resident grows to the configured cap under steady-state queries. |
| Full-map bake (50K chunks) | 250 MB | 0 + per-query | Index ≈ 800 KB. Same configured cap. Cold areas never load. |
| All 5 maps fully baked | 1.25 GB | 0 + per-query | Index ≈ 4 MB total. Same configured cap. |

## Hash choice (FNV-1a → XxHash3)

The original draft used inlined FNV-1a-64. Switched to XxHash3 via `HashUtility`:

- ~30× faster on this workload (~30 GB/s SIMD vs ~2 GB/s byte-by-byte). Boot-time only, so absolute saving is microseconds — the real wins are elsewhere.
- Stronger collision resistance and distribution.
- Drops ~25 lines of inlined hash code; matches the rest of the codebase's hashing pattern.
- Hash is stable as long as `HashUtility`'s `xxHash3Seed` constant doesn't change (already marked `// DO NOT CHANGE THIS NUMBER`).
This commit is contained in:
Kamron Batman 2026-05-06 01:32:44 -07:00 • committed by GitHub
parent 9066e8fd00
commit 7c9215d97c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 861 additions and 4 deletions

View file

@ -67,6 +67,7 @@ public sealed class StepCache
{
_chunks.Clear();
_keysList.Clear();
CloseLazyReaders();
_hits = 0;
_missesNotBuilt = 0;
_missesDirtyRebuild = 0;
@ -77,6 +78,111 @@ 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();
/// <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.
/// </summary>
public int SaveToFile(string path, int mapId)
{
var matching = 0;
foreach (var key in _keysList)
{
DecodeKey(key, out var keyMapId, out _, out _);
if (keyMapId == mapId)
{
matching++;
}
}
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;
}
}
/// <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).
/// </summary>
public bool TryOpenLazyReader(string path, int mapId)
{
var reader = StepCacheFile.OpenForLazy(path);
if (reader == null)
{
return false;
}
if (reader.MapId != (uint)mapId)
{
logger.Warning(
"StepCache: {Path} declares mapId {FileMapId} but caller requested {RequestedMapId}; ignoring",
path, reader.MapId, mapId
);
reader.Dispose();
return false;
}
if (_lazyReaders.TryGetValue(mapId, out var existing))
{
existing.Dispose();
}
_lazyReaders[mapId] = reader;
logger.Information(
"StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}",
path, reader.IndexedChunkCount, mapId
);
return true;
}
/// <summary>
/// Number of .swb readers currently open. Mostly for tests / telemetry.
/// </summary>
public int OpenLazyReaderCount => _lazyReaders.Count;
/// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset 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).
/// </summary>
public void CloseLazyReaders()
{
foreach (var reader in _lazyReaders.Values)
{
reader.Dispose();
}
_lazyReaders.Clear();
}
/// <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
@ -230,10 +336,29 @@ public sealed class StepCache
}
/// <summary>
/// Chunk-miss resolution: build the chunk via the runtime baker.
/// Chunk-miss resolution: try the lazy file reader for this map first; if there's no
/// file or no record at this (chunkX, chunkY), fall back to the runtime baker. The
/// file path validates each loaded chunk's MultisVersion against the live sector — a
/// stale snapshot triggers a rebuild rather than serving a wrong answer.
/// </summary>
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) =>
BuildChunk(map, chunkX, chunkY);
private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY)
{
if (_lazyReaders.TryGetValue(map.MapID, out var reader))
{
var loaded = reader.TryReadChunk(chunkX, chunkY);
if (loaded != null)
{
var sector = map.GetRealSector(chunkX, chunkY);
if (loaded.BuiltMultisVersion == sector.MultisVersion)
{
return loaded;
}
// Snapshot is stale (multis added/removed since the bake). Fall through
// to the runtime baker; a future SaveToFile will overwrite the entry.
}
}
return BuildChunk(map, chunkX, chunkY);
}
private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
{