feat(pathfinding): .swb format v8 compact index (#3b) (#2471)

## Summary
Phase #3b (final roadmap item), stacked on #2470. Compacts the index trailer from 20 to 8 bytes/chunk.

Trammel: 19.2 MB → 17.9 MB. Roadmap total: 565 MB → 17.9 MB (−96.8%).

## Details
- Trailer stores `{ u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength }` per chunk, in record write order; the file offset is dropped and reconstructed by cumulative recordLength from HeaderSize.
- No record reordering, no varint; fixed-stride, TryReadChunk unchanged.
- Also simplifies the accumulated `.swb` code comments across the stack.
- Format v8; v7 files rejected and re-baked once.

## Tests
v8 multi-chunk round-trip (cumulative offset reconstruction) + the v6/v7 suite; full pathfinding suite green; Release build clean.
This commit is contained in:
Kamron Batman 2026-06-07 01:22:20 -07:00 • committed by GitHub
parent c265bcbb5e
commit c7aaf33de9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 960 additions and 868 deletions

View file

@ -22,7 +22,7 @@ public sealed class StepCache
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 List<long> _keysList = new();
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
@ -38,7 +38,6 @@ public sealed class StepCache
// Generation counter incremented by BeginFindGeneration(). Sentinel 0 = "no Find started
// yet"; treated as a distinct generation per call so callers that bypass BeginFindGeneration
// (single-call tests, BakeMap with threshold=1) get sensible behavior.
private uint _findGeneration;
private struct ChunkMissState
{
@ -93,12 +92,16 @@ public sealed class StepCache
/// </summary>
public void BeginFindGeneration()
{
unchecked { _findGeneration++; }
if (_findGeneration == 0) { _findGeneration = 1; }
unchecked { CurrentFindGeneration++; }
if (CurrentFindGeneration == 0)
{
CurrentFindGeneration = 1;
}
}
/// <summary>Test-only: read the current Find generation.</summary>
internal uint CurrentFindGeneration => _findGeneration;
internal uint CurrentFindGeneration { get; private set; }
/// <summary>
/// Pack (mapId, chunkX, chunkY) into a single long key.
@ -107,7 +110,7 @@ public sealed class StepCache
internal static long EncodeKey(int mapId, int chunkX, int chunkY) =>
((long)(mapId & 0xFFFF) << 32) | ((long)(chunkX & 0xFFFF) << 16) | (long)(chunkY & 0xFFFF);
public CacheStats GetStats() => new CacheStats(
public CacheStats GetStats() => new(
residentChunks: _chunks.Count,
hits: _hits,
missesNotBuilt: _missesNotBuilt,
@ -142,7 +145,7 @@ public sealed class StepCache
_chunks.Clear();
_keysList.Clear();
_chunkMissTracker.Clear();
_findGeneration = 0;
CurrentFindGeneration = 0;
_hits = 0;
_missesNotBuilt = 0;
_missesDirtyRebuild = 0;
@ -193,10 +196,17 @@ public sealed class StepCache
// and the bake would write an empty file. Force eager build for the duration.
var prevThreshold = MissPromotionThreshold;
MissPromotionThreshold = 1;
var startTick = Core.TickCount;
try
{
var chunkCols = (map.Width + ChunkSize - 1) / ChunkSize;
var chunkRows = (map.Height + ChunkSize - 1) / ChunkSize;
var logEvery = Math.Max(1, chunkRows / 32);
logger.Information(
"PathBake map {MapId}: walking {Cols}x{Rows} = {Total} chunks (synchronous; no eviction during the walk)...",
mapId, chunkCols, chunkRows, chunkCols * chunkRows
);
for (var cy = 0; cy < chunkRows; cy++)
{
@ -206,7 +216,21 @@ public sealed class StepCache
// whether the query returns Hit or Fallthrough_SourceZMismatch.
TryGetMask(map, cx * ChunkSize, cy * ChunkSize, sourceZ: 0);
}
if ((cy + 1) % logEvery == 0 || cy == chunkRows - 1)
{
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
);
}
}
logger.Information(
"PathBake map {MapId}: walk complete in {Elapsed:F1}s, writing {Resident} chunks to disk...",
mapId, (Core.TickCount - startTick) / 1000.0, _chunks.Count
);
}
finally
{
@ -610,7 +634,7 @@ public sealed class StepCache
// Environment.TickCount, not Core.TickCount: tests/bench fixtures may not advance
// the game-loop tick. The promotion window is wall-clock anyway.
var now = (uint)Environment.TickCount;
var gen = _findGeneration;
var gen = CurrentFindGeneration;
if (_chunkMissTracker.TryGetValue(chunkKey, out var state))
{