feat(pathfinding): Tier 4 multi-Z strata (file format v2) (#2450)

## Summary

Multi-Z cells (bridges, stairs, paver-over-ground, multi-floor structures) now carry **per-stratum walkability data** in the cache instead of falling through to the slow path. The data is computed at chunk-build time, persisted in the `.swb` file, and selected at query time by matching the request's `sourceZ` against each stratum's `zCenter` (within `StepHeight` tolerance).

This is the Tier 4 strata feature, deferred from PR #2447 / PR #2448 / PR #2449. Builds on PR #2449's lazy backing store and public bake helpers.

## Wire format change (v1 → v2)

`StepCacheFile.FormatVersion = 2`. `MinSupportedVersion = 2`. v1 `.swb` files are silently rejected at open time (treated as missing) and overwritten on the next `SaveToFile` / `BakeMap`. **No migration** — older files just get re-baked.

The `MinSupportedVersion` sentinel is the model going forward: bump the constant when an incompatible change lands; admins re-bake on the next deploy. No matrix of v1↔v2↔v3 migration logic to maintain.

## What changed

- **`StepProbe.ComputeStrataAt(map, x, y)`** — enumerates walkable standing-Zs at the cell (one per land surface plus one per walkable static), collapses Zs within `2*StepHeight`, runs `ComputeMaskAt` at each surviving Z. Returns `null` for single-Z cells (caller uses the chunk's main mask).
- **`StepChunk`** — replaces the old `MultiZCells` bitmap with a **strata storage pair**:
  - `ushort[256] StrataOffsetByCell` (sentinel `NoStrata = 0xFFFF` = "no strata for that cell")
  - `byte[] StrataData` packed: `u8 stratumCount`, then `count × 19-byte stratum`
    - `sbyte zCenter, byte walkMask, byte wetMask, sbyte walkZ_N..NW (8), sbyte swimZ_N..NW (8)`
  - `IsCellMultiZ` derives from `StrataOffsetByCell[cell] != NoStrata` — same semantics, single source of truth.
- **`StepCache.BuildChunk`** — populates strata for cells flagged multi-Z via `SetStrata`. Chunks with zero multi-Z cells pay zero strata overhead (offset array + data array stay null).
- **`StepCache.TryGetMask`** — for multi-Z cells, scans strata with `TryStratumHit`; returns the matching one with `HitKind=Hit`. Falls through to slow path only when no stratum matches the query `sourceZ`.
- **`StepCacheFile`** — v2 serialization with strata trailer per chunk + `recordLength` in index entry. Lazy reader sizes scratch per-chunk-record using the recorded length, growing on demand for multi-Z-heavy chunks. Patches `IndexOffset` on `w.Buffer` (BufferWriter's current backing array) since it grows during variable-size chunk writes.

## File layout v2

```
Header (48 bytes):
  u32  Magic           = 0x42575300 ('SWB\0')
  u32  Version         = 2
  u32  MapId
  u64  Fingerprint     XxHash3 over LandTable + ItemTable flags + map files (mapX.mul/.uop, staidxX.mul, staticsX.mul)
  u64  BakeTimestamp   informational
  u32  ChunkCount
  u64  IndexOffset     position where chunk index begins

Per chunk (variable size):
  u16  ChunkX, ChunkY
  u32  BuiltMultisVersion
  u8   HasStrata       0 = no strata trailer; 1 = strata trailer follows
  byte WalkMask[256], WetMask[256]
  sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256]
  // Strata trailer (only when HasStrata == 1):
  u16  StrataOffsetByCell[256]    // NoStrata sentinel = 0xFFFF
  u32  StrataDataLength
  byte StrataData[StrataDataLength]
       Per multi-Z cell: u8 count, then count × Stratum (19 bytes)

Index trailer (20 × ChunkCount bytes):
  per chunk: { u64 chunkKey, u64 fileOffset, u32 recordLength }
```
This commit is contained in:
Kamron Batman 2026-05-06 22:55:42 -07:00 committed by GitHub
parent a8ca82738d
commit 7bd2cb6a2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 471 additions and 73 deletions

View file

@ -139,9 +139,17 @@ public class StepCacheLifecycleTests
Assert.True(chunks.ContainsKey(key));
var chunk = chunks[key];
// Mark cell at (1500, 1600) within the chunk as multi-Z.
// 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));
chunk.MarkCellMultiZ(cellIndex);
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);
@ -152,6 +160,86 @@ public class StepCacheLifecycleTests
Assert.Equal(preInjectionFallthroughMultiZ + 1L, stats.FallthroughMultiZ);
}
[Fact]
public void Tier4Strata_MatchingZ_ReturnsHitFromStratum()
{
var cache = StepCache.Instance;
cache.Clear();
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];
// 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);
Assert.Equal((sbyte)42, lookup.WalkZ_NE);
}
[Fact]
public void Tier4Strata_NonMatchingZ_FallsThrough()
{
var cache = StepCache.Instance;
cache.Clear();
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);
}
[Fact]
public void LruCap_OverflowEvictsToCap()
{