ModernUO/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs
Kamron Batman 9a3d88988c
feat(pathfinding): non-eager TryGetMask + second-touch promotion (#2451)
## Summary

Closes the Cold-cache regression flagged in PR #2450. `StepCache.TryGetMask` no longer eagerly runs `BuildChunk` on the first miss for a chunk that isn't in a `.swb` lazy reader. Instead it returns `Fallthrough_NotBuilt` and the caller (`BitmapAStarAlgorithm`) takes the per-cell slow path. The chunk is only promoted to the bitmap fast path after the **second** miss within a 30-second window, filtering single-touch pass-throughs.

This makes BitmapAStar's worst-case (cold cache + short hops) collapse from **12–47× slower** than FastAStar to **roughly the same**, which is the floor the slow path can deliver. Steady-state warm performance (the actual deliverable) is unchanged from PR-5 — it was always the cache fast path.

## The pet-follow scenario this fixes

A mounted player at ~4 tiles/sec with a pet/hireable following will trigger an NPC pathfind every 100–300 ms. Each pathfind is 1–6 tiles. As the player crosses chunk boundaries (~4 sec/chunk), the pet's first pathfind in the new chunk under the previous behavior triggered a full ~700 µs `BuildChunk` for a chunk the player would leave shortly after. At 50–100 mobiles per shard, this exceeded the 8 ms tick budget. PR-5 BDN data showed scenarios 6–9 (2–8 tile NPC perception) at 2,300–3,700 µs Cold vs FastAStar's 80–200 µs.

Under the new gate:

- First miss → `Fallthrough_NotBuilt` → caller uses slow path (~30–50 µs short path). No `BuildChunk`. No allocation.
- Player keeps moving → chunk never gets a second touch within window → never promoted, no rot.
- NPC patrolling a fixed territory → repeatedly hits the same chunks → second touch within window → promote → cache fast path on subsequent calls.

## What changed

- **`CacheHitKind.Fallthrough_NotBuilt = 6`** + **`CacheStats.FallthroughNotBuilt`** counter. `IsHit=false`, so the caller routes to slow path.
- **`StepCache._chunkMissTracker`** — `Dictionary<long, ChunkMissState>` capped at 4096 entries. State is `(byte missCount, uint lastMissTickStamp)` keyed by chunk key. Window-expired entries reset count to 1; capacity overflow prunes window-old entries first.
- **`StepCache.MissPromotionThreshold`** (default `2`) and **`StepCache.MissPromotionWindowMs`** (default `30_000`) — tunable, can be wired through `ServerConfiguration` if shards want different policy. Setting threshold to `1` restores legacy eager-build behavior (used by tests that prime chunks via single `TryGetMask` call).
- **`StepCache.TryGetMask` miss branch** — try lazy reader first (file-loaded chunks bypass the tracker entirely; an `.swb` represents an explicit prior decision to keep the chunk warm). Otherwise consult the tracker.
- **`BitmapAStarAlgorithm.GetSuccessorsSlowPath`** now layers `IsBlockedByDynamic` on top of `CalcMoves.CheckMovement`. Previously the slow path only ran for `CanFly` creatures and rare cache fallthroughs — `CheckMovement` doesn't iterate same-cell mobiles, so the bitmap fast path's `IsBlockedByDynamic` was the only mobile-blocking check. Now first-touch pathfinds run through the slow path, so the gap had to close.

## Tests

50 pathfinding tests pass (was 47). New / updated:

- **`TryGetMask_FirstTouchOnUnbuiltChunk_DefersBuildAndReturnsFallthrough`** — single TryGetMask call returns `Fallthrough_NotBuilt`, no chunk built, no allocation.
- **`TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds`** — second call inside the 30s window builds + serves.
- **`TryGetMask_SecondTouchAfterWindow_RestartsCounterAndDefers`** — second call outside the window restarts the count, returns Fallthrough again.
- **`TryGetMask_DistinctChunks_TrackedIndependently`** — counters are per-chunk; one touch on each of two adjacent chunks both stay in fallthrough.
- **`LazyReaderHit_BypassesMissTrackerOnFirstTouch`** — open `.swb` + first touch hits without consulting the tracker. Production with `.swb` loaded skips the gate entirely.
- **`MultisVersion_Bump_TriggersDirtyRebuild`** — updated to reflect the new 3-step flow (Fallthrough → Miss_NotBuilt → Miss_DirtyRebuild).
- Tests that prime chunks via a single `TryGetMask` call (multi-Z, Tier4, lifecycle, parity, BitmapAStar uses-cache) set `MissPromotionThreshold = 1` to opt into eager behavior.

## Expected BDN impact

The Cold column from PR-5's BDN should change as follows once the bench's submodule pointer is updated to this branch:

| # | Scenario        | Cold (PR-5)  | Cold (PR-6 expected) | FastAStar Cold |
|--:|-----------------|-------------:|---------------------:|---------------:|
| 2 | sewer corridor  | 1,627 µs     | ~36 µs               | 36 µs          |
| 4 | causeway        | 1,533 µs     | ~39 µs               | 39 µs          |
| 6 | pet 2-tile      | 2,364 µs     | ~80 µs               | 81 µs          |
| 8 | npc 5-tile      | 3,708 µs     | ~140 µs              | 141 µs         |
| 9 | npc 8-tile      | 2,386 µs     | ~200 µs              | 197 µs         |

WarmNoFile and LazyWarm rows should be unchanged — they were always cache-warm. The miss tracker only fires when neither resident chunks nor the lazy reader can satisfy the request.

## Future work (not in this PR)

- **Background-thread bake**: builds outside the game thread so even promoted chunks don't pay the 700 µs build cost on the main thread. Rule 10 (no Task.Run) applies, so this needs careful design — the bake is a pure data transform but main-thread synchronization on chunk-state transitions has to be threaded through. Defer to a follow-up.
- **Long-traverse BDN scenario**: a multi-Find benchmark simulating 50 pet repaths across chunk transitions. Requires restructuring the bench harness; the existing 10-scenario corpus + Cold provider already exercises the gate.
- **Swim sourceZ bake**: scenario 5 (sea serpent) shows 56 B alloc on warm paths because the cache's SourceZ is computed under default-walker rules. Swim creatures fall through to slow path. Independent of this PR.
2026-06-06 13:11:53 -07:00

586 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
namespace Server.Engines.Pathing.Cache;
/// <summary>
/// Binary serializer + lazy reader for the step cache. Persists chunk records to disk
/// so a server warm-starts without paying chunk-build cost on the first pathfind through
/// a region. Lazy: opening a file reads only the header + chunk-offset index (~few KB
/// for tens of thousands of chunks), then individual chunks are seeked + deserialized
/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
/// of file size.
///
/// File layout v3 (little-endian, BufferWriter / BufferReader convention):
///
/// Header (48 bytes):
/// u32 Magic = 0x42575300 ('SWB\0')
/// u32 Version = current FormatVersion (3)
/// u32 MapId
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
/// Rejects a load when EITHER tile flags shifted (client patch)
/// OR the map data was rewritten (CentredSharp / UOFiddler edit).
/// The .mul format has no built-in CRC; this is the only way
/// to detect those mutations.
/// u64 BakeTimestamp DateTime.UtcNow.Ticks at write time (informational).
/// u32 ChunkCount
/// u64 IndexOffset File position where the chunk index begins.
///
/// Per chunk (ChunkCount times, variable size):
/// u16 ChunkX
/// u16 ChunkY
/// u32 BuiltMultisVersion
/// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata trailer follows
/// u8 HasSwimLayer 0 = no shore cells (no swim trailer); 1 = swim trailer follows
/// byte WalkMask[256]
/// byte WetMask[256]
/// sbyte SourceZ[256]
/// sbyte WalkZN[256]..WalkZNW[256] (8 arrays in N,NE,E,SE,S,SW,W,NW order)
/// sbyte SwimZN[256]..SwimZNW[256] (8 arrays in same order; baked at WALK source-Z)
/// // Swim layer trailer — only when HasSwimLayer == 1 (chunks containing shore cells):
/// sbyte SwimSourceZ[256] (NoSwimLayerCell sentinel = sbyte.MinValue)
/// byte SwimMask[256] (per-cell swim mask baked at SwimSourceZ)
/// sbyte SwimZN_Layer[256]..SwimZNW_Layer[256] (8 arrays, dest-Z at swim perspective)
/// // Strata trailer — only when HasStrata == 1:
/// u16 StrataOffsetByCell[256] (NoStrata sentinel = 0xFFFF)
/// u32 StrataDataLength
/// byte StrataData[StrataDataLength]
/// For each multi-Z cell: u8 stratumCount, then stratumCount × Stratum (19 bytes):
/// sbyte zCenter
/// byte walkMask, wetMask
/// sbyte walkZ_N..NW (8)
/// sbyte swimZ_N..NW (8)
///
/// Index trailer (20 × ChunkCount bytes):
/// For each chunk: { u64 chunkKey, u64 fileOffset, u32 recordLength }
///
/// Per-chunk fixed portion: ~5,393 bytes. Strata trailer: 516 + N × ~30 bytes for a
/// chunk with N multi-Z cells averaging ~2 strata each. LRU bookkeeping
/// (LastTouchedTicks) is intentionally not persisted.
///
/// Files with version &lt; <see cref="MinSupportedVersion"/> are silently rejected
/// at open time (treated as missing) and overwritten on the next save.
/// </summary>
internal static class StepCacheFile
{
public const uint Magic = 0x42575300; // 'SWB\0'
public const uint FormatVersion = 4;
/// <summary>
/// Lowest format version this binary can load. Files below this version are treated as
/// missing (silently rejected) — a subsequent SaveToFile / BakeMap overwrites them with
/// the current FormatVersion. Bumped to 3 when the swim layer landed (v2 had no swim
/// layer). Bumped to 4 when the baker switched to clearance-aware standable-surface
/// strata: v3 bakes anchored every cell at the land average and so missed walkable
/// static-over-land surfaces (sewer/dungeon walkways, bridges, upper building floors),
/// producing ~98% source-Z fallthroughs on those routes. The on-disk layout is
/// unchanged; only the strata population differs, so the bump exists purely to force a
/// one-time re-bake of stale v3 files on first boot under the new binary.
/// </summary>
public const uint MinSupportedVersion = 4;
private const int HeaderSize =
sizeof(uint) // Magic
+ sizeof(uint) // Version
+ sizeof(uint) // MapId
+ sizeof(ulong) // Fingerprint
+ sizeof(ulong) // BakeTimestamp
+ sizeof(uint) // ChunkCount
+ sizeof(ulong); // IndexOffset
// Index entry: chunkKey + fileOffset + recordLength. Bumped to include length when
// strata made chunk records variable-size; the lazy reader uses length to do a
// single bulk read per chunk without consulting the next offset.
private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong) + sizeof(uint);
/// <summary>Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).</summary>
private const int BytesPerChunkBase =
sizeof(ushort) + sizeof(ushort) + sizeof(uint)
+ sizeof(byte) + sizeof(byte) // HasStrata + HasSwimLayer
+ StepChunk.CellsPerChunk // WalkMask
+ StepChunk.CellsPerChunk // WetMask
+ StepChunk.CellsPerChunk // SourceZ
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
/// <summary>Swim-layer trailer overhead when present: per-cell SourceZ + Mask + 8×Z arrays.</summary>
private const int SwimLayerOverhead = 10 * StepChunk.CellsPerChunk;
/// <summary>Strata trailer overhead when present: 256×u16 offset table + u32 data length.</summary>
private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint);
/// <summary>
/// Byte offset of the IndexOffset u64 within the header
/// (Magic+Version+MapId+Fingerprint+BakeTimestamp+ChunkCount = 32). Patched after chunks land.
/// </summary>
private const int IndexOffsetFieldPosition = 32;
public delegate bool ChunkEnumerator(out int chunkX, out int chunkY, out StepChunk chunk);
/// <summary>
/// Peek at a .swb file's Fingerprint field (header byte offset 12) without
/// reading any chunk data. Returns false on missing file, bad magic, or wrong
/// version. Cheap — reads 20 bytes total.
/// </summary>
public static bool TryReadFingerprint(string path, out ulong fingerprint)
{
fingerprint = 0;
if (!File.Exists(path))
{
return false;
}
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
Span<byte> buf = stackalloc byte[20];
if (stream.Read(buf) < 20)
{
return false;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(buf) != Magic)
{
return false;
}
var version = BinaryPrimitives.ReadUInt32LittleEndian(buf[4..]);
if (version < MinSupportedVersion || version > FormatVersion)
{
return false;
}
// mapId is at buf[8..12], we skip; hash is at buf[12..20].
fingerprint = BinaryPrimitives.ReadUInt64LittleEndian(buf[12..]);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the
/// per-map .mul / .uop file contents (via <see cref="TileMatrix.MapFilesFingerprint"/>).
/// Bake files carry this hash so a load can refuse to populate the cache when EITHER
/// tile flags shifted (client patch) OR the map data was rewritten (CentredSharp /
/// UOFiddler edit). The .mul format has no built-in CRC; this is the only way to
/// detect those mutations.
/// </summary>
public static ulong ComputeFingerprint(int mapId)
{
var hasher = HashUtility.CreateXxHash3();
// TileData flag tables — same projection trick as before: just the Flags ulong
// from each entry, written little-endian into a contiguous byte buffer. The
// struct itself has a string Name (reference) whose object identity isn't
// stable across runs, so MemoryMarshal.Cast over the whole struct would drift.
var landTable = TileData.LandTable;
var itemTable = TileData.ItemTable;
var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)];
var span = bytes.AsSpan();
for (var i = 0; i < landTable.Length; i++)
{
BinaryPrimitives.WriteUInt64LittleEndian(span[(i * 8)..], (ulong)landTable[i].Flags);
}
var itemOffset = landTable.Length * 8;
for (var i = 0; i < itemTable.Length; i++)
{
BinaryPrimitives.WriteUInt64LittleEndian(span[(itemOffset + i * 8)..], (ulong)itemTable[i].Flags);
}
hasher.Append(bytes);
// Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already
// streamed them through XxHash3 once at construction; mix the result in.
var map = Map.Maps[mapId];
if (map != null && map != Map.Internal && map.Tiles != null)
{
Span<byte> mapHashBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(mapHashBytes, map.Tiles.MapFilesFingerprint);
hasher.Append(mapHashBytes);
}
return hasher.GetCurrentHashAsUInt64();
}
/// <summary>
/// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded)
/// → index trailer → patches the header IndexOffset. <paramref name="chunkCount"/> must
/// equal the actual number of chunks <paramref name="next"/> will yield.
/// </summary>
public static void Write(string path, uint mapId, uint chunkCount, ChunkEnumerator next)
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
// Initial estimate: base record + a modest strata budget per chunk. Coastline
// chunks add another ~2.5 KB (swim layer) but they're a small fraction of any
// map; the writer grows on overflow so under-estimating just causes a few
// realloc/copy cycles during the bake — not a correctness issue.
var capacity = HeaderSize
+ (BytesPerChunkBase + 256) * (int)chunkCount
+ IndexEntryBytes * (int)chunkCount;
var buffer = new byte[capacity];
var w = new BufferWriter(buffer, prefixStr: false);
w.Write(Magic);
w.Write(FormatVersion);
w.Write(mapId);
w.Write(ComputeFingerprint((int)mapId));
w.Write((ulong)DateTime.UtcNow.Ticks);
w.Write(chunkCount);
w.Write(0UL); // IndexOffset placeholder, patched after chunks
var indexEntries = new (ulong key, ulong offset, uint length)[chunkCount];
var written = 0u;
while (next(out var chunkX, out var chunkY, out var chunk))
{
if (written >= chunkCount)
{
throw new InvalidOperationException(
$"StepCacheFile.Write: enumerator yielded more than the declared {chunkCount} chunks"
);
}
var chunkOffset = (ulong)w.Position;
WriteChunk(w, chunkX, chunkY, chunk);
var chunkLength = (uint)((ulong)w.Position - chunkOffset);
indexEntries[written] = (PackChunkKey(chunkX, chunkY), chunkOffset, chunkLength);
written++;
}
if (written != chunkCount)
{
throw new InvalidOperationException(
$"StepCacheFile.Write: declared {chunkCount} chunks but enumerator yielded {written}"
);
}
var indexOffset = (ulong)w.Position;
for (var i = 0u; i < chunkCount; i++)
{
w.Write(indexEntries[i].key);
w.Write(indexEntries[i].offset);
w.Write(indexEntries[i].length);
}
// Patch IndexOffset on the writer's current backing buffer (BufferWriter may
// have grown during chunk writes; the original `buffer` ref is stale after grow).
var liveBuffer = w.Buffer;
BinaryPrimitives.WriteUInt64LittleEndian(liveBuffer.AsSpan(IndexOffsetFieldPosition, 8), indexOffset);
var totalBytes = (int)w.Position;
File.WriteAllBytes(path, liveBuffer.AsSpan(0, totalBytes).ToArray());
}
/// <summary>
/// Opens a .swb file and reads only its header + chunk-offset index. Returns null on
/// missing file, magic / version mismatch, or Fingerprint mismatch (a stale bake
/// against a freshly patched client). Callers own disposal of the returned reader.
/// </summary>
public static LazyReader OpenForLazy(string path)
{
if (!File.Exists(path))
{
return null;
}
FileStream stream = null;
try
{
stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read | FileShare.Delete
);
Span<byte> headerBuf = stackalloc byte[HeaderSize];
if (stream.Read(headerBuf) != HeaderSize)
{
stream.Dispose();
return null;
}
var magic = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf);
if (magic != Magic)
{
stream.Dispose();
return null;
}
var version = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[4..]);
if (version < MinSupportedVersion || version > FormatVersion)
{
// Below the minimum supported version: treat as missing. Older files
// get silently overwritten on the next SaveToFile / BakeMap.
stream.Dispose();
return null;
}
var mapId = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[8..]);
var fingerprint = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[12..]);
var bakeTimestamp = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[20..]);
var chunkCount = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[28..]);
var indexOffset = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[32..]);
if (fingerprint != ComputeFingerprint((int)mapId))
{
stream.Dispose();
return null;
}
// Read the chunk-offset index in one shot.
var indexBytes = (int)chunkCount * IndexEntryBytes;
var indexBuf = new byte[indexBytes];
stream.Position = (long)indexOffset;
if (stream.Read(indexBuf, 0, indexBytes) != indexBytes)
{
stream.Dispose();
return null;
}
var offsets = new Dictionary<ulong, (ulong offset, uint length)>((int)chunkCount);
for (var i = 0; i < chunkCount; i++)
{
var entry = indexBuf.AsSpan(i * IndexEntryBytes);
var key = BinaryPrimitives.ReadUInt64LittleEndian(entry);
var off = BinaryPrimitives.ReadUInt64LittleEndian(entry[8..]);
var len = BinaryPrimitives.ReadUInt32LittleEndian(entry[16..]);
offsets[key] = (off, len);
}
return new LazyReader(stream, mapId, fingerprint, bakeTimestamp, chunkCount, offsets);
}
catch
{
stream?.Dispose();
return null;
}
}
private static ulong PackChunkKey(int chunkX, int chunkY) => ((ulong)(uint)chunkX << 32) | (uint)chunkY;
private static void WriteChunk(BufferWriter w, int chunkX, int chunkY, StepChunk chunk)
{
w.Write((ushort)chunkX);
w.Write((ushort)chunkY);
w.Write((uint)chunk.BuiltMultisVersion);
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
var strataData = chunk.GetStrataDataForSerialization();
var hasStrata = strataOffsetByCell != null;
var hasSwimLayer = chunk.HasSwimLayer;
w.Write((byte)(hasStrata ? 1 : 0));
w.Write((byte)(hasSwimLayer ? 1 : 0));
w.Write(chunk.WalkMask);
w.Write(chunk.WetMask);
WriteSBytes(w, chunk.SourceZ);
WriteSBytes(w, chunk.WalkZN);
WriteSBytes(w, chunk.WalkZNE);
WriteSBytes(w, chunk.WalkZE);
WriteSBytes(w, chunk.WalkZSE);
WriteSBytes(w, chunk.WalkZS);
WriteSBytes(w, chunk.WalkZSW);
WriteSBytes(w, chunk.WalkZW);
WriteSBytes(w, chunk.WalkZNW);
WriteSBytes(w, chunk.SwimZN);
WriteSBytes(w, chunk.SwimZNE);
WriteSBytes(w, chunk.SwimZE);
WriteSBytes(w, chunk.SwimZSE);
WriteSBytes(w, chunk.SwimZS);
WriteSBytes(w, chunk.SwimZSW);
WriteSBytes(w, chunk.SwimZW);
WriteSBytes(w, chunk.SwimZNW);
if (hasSwimLayer)
{
WriteSBytes(w, chunk.SwimSourceZ);
w.Write(chunk.SwimMask);
WriteSBytes(w, chunk.SwimZN_Layer);
WriteSBytes(w, chunk.SwimZNE_Layer);
WriteSBytes(w, chunk.SwimZE_Layer);
WriteSBytes(w, chunk.SwimZSE_Layer);
WriteSBytes(w, chunk.SwimZS_Layer);
WriteSBytes(w, chunk.SwimZSW_Layer);
WriteSBytes(w, chunk.SwimZW_Layer);
WriteSBytes(w, chunk.SwimZNW_Layer);
}
if (hasStrata)
{
// 256 × u16 offsets, then u32 length-prefixed strata byte array.
for (var i = 0; i < StepChunk.CellsPerChunk; i++)
{
w.Write(strataOffsetByCell[i]);
}
var dataLen = (uint)(strataData?.Length ?? 0);
w.Write(dataLen);
if (dataLen > 0)
{
w.Write(strataData);
}
}
}
private static StepChunk ReadChunk(byte[] buffer)
{
var r = new BufferReader(buffer);
// Skip ChunkX + ChunkY (already known via the index lookup).
r.ReadUShort();
r.ReadUShort();
var multisVersion = (int)r.ReadUInt();
var hasStrata = r.ReadByte() != 0;
var hasSwimLayer = r.ReadByte() != 0;
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
r.Read(chunk.WalkMask);
r.Read(chunk.WetMask);
ReadSBytes(r, chunk.SourceZ);
ReadSBytes(r, chunk.WalkZN);
ReadSBytes(r, chunk.WalkZNE);
ReadSBytes(r, chunk.WalkZE);
ReadSBytes(r, chunk.WalkZSE);
ReadSBytes(r, chunk.WalkZS);
ReadSBytes(r, chunk.WalkZSW);
ReadSBytes(r, chunk.WalkZW);
ReadSBytes(r, chunk.WalkZNW);
ReadSBytes(r, chunk.SwimZN);
ReadSBytes(r, chunk.SwimZNE);
ReadSBytes(r, chunk.SwimZE);
ReadSBytes(r, chunk.SwimZSE);
ReadSBytes(r, chunk.SwimZS);
ReadSBytes(r, chunk.SwimZSW);
ReadSBytes(r, chunk.SwimZW);
ReadSBytes(r, chunk.SwimZNW);
if (hasSwimLayer)
{
chunk.AllocateSwimLayer();
ReadSBytes(r, chunk.SwimSourceZ);
r.Read(chunk.SwimMask);
ReadSBytes(r, chunk.SwimZN_Layer);
ReadSBytes(r, chunk.SwimZNE_Layer);
ReadSBytes(r, chunk.SwimZE_Layer);
ReadSBytes(r, chunk.SwimZSE_Layer);
ReadSBytes(r, chunk.SwimZS_Layer);
ReadSBytes(r, chunk.SwimZSW_Layer);
ReadSBytes(r, chunk.SwimZW_Layer);
ReadSBytes(r, chunk.SwimZNW_Layer);
}
if (hasStrata)
{
var offsets = new ushort[StepChunk.CellsPerChunk];
for (var i = 0; i < offsets.Length; i++)
{
offsets[i] = r.ReadUShort();
}
var dataLen = (int)r.ReadUInt();
var data = new byte[dataLen];
if (dataLen > 0)
{
r.Read(data);
}
chunk.SetStrata(offsets, data);
}
return chunk;
}
private static void WriteSBytes(BufferWriter w, sbyte[] arr) =>
w.Write(MemoryMarshal.Cast<sbyte, byte>(arr.AsSpan()));
private static void ReadSBytes(BufferReader r, sbyte[] arr) =>
r.Read(MemoryMarshal.Cast<sbyte, byte>(arr.AsSpan()));
/// <summary>
/// Open handle on a .swb file. Holds the FileStream + chunk-offset index. Chunks are
/// fetched on demand via <see cref="TryReadChunk"/>; only the records actually queried
/// are ever materialized. Dispose releases the underlying stream.
/// </summary>
internal sealed class LazyReader : IDisposable
{
private FileStream _stream;
private readonly Dictionary<ulong, (ulong offset, uint length)> _offsets;
private byte[] _buffer;
public uint MapId { get; }
public ulong Fingerprint { get; }
public ulong BakeTimestamp { get; }
public uint ChunkCount { get; }
public int IndexedChunkCount => _offsets.Count;
public bool Has(int chunkX, int chunkY) => _offsets.ContainsKey(PackChunkKey(chunkX, chunkY));
/// <summary>
/// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by
/// <see cref="StepCache"/> when preload is enabled to materialize all chunks
/// upfront instead of on first query.
/// </summary>
public IEnumerable<(int chunkX, int chunkY)> EnumerateChunkCoords()
{
foreach (var key in _offsets.Keys)
{
yield return ((int)(key >> 32), (int)(key & 0xFFFFFFFF));
}
}
internal LazyReader(
FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
uint chunkCount, Dictionary<ulong, (ulong offset, uint length)> offsets
)
{
_stream = stream;
MapId = mapId;
Fingerprint = fingerprint;
BakeTimestamp = bakeTimestamp;
ChunkCount = chunkCount;
_offsets = offsets;
_buffer = new byte[BytesPerChunkBase];
}
/// <summary>
/// Returns the chunk record at (<paramref name="chunkX"/>, <paramref name="chunkY"/>)
/// from the file, or null if the file doesn't contain it. Single seek + bulk read,
/// sized exactly to the chunk's recorded length (which varies with strata size).
/// </summary>
public StepChunk TryReadChunk(int chunkX, int chunkY)
{
if (_stream == null)
{
return null;
}
var key = PackChunkKey(chunkX, chunkY);
if (!_offsets.TryGetValue(key, out var entry))
{
return null;
}
// Grow the scratch buffer if this chunk's record is larger than what we have.
// Common case: chunks fit in BytesPerChunkBase; only multi-Z-heavy chunks grow.
if (entry.length > _buffer.Length)
{
_buffer = new byte[entry.length];
}
_stream.Position = (long)entry.offset;
var read = _stream.Read(_buffer, 0, (int)entry.length);
return read < (int)entry.length ? null : ReadChunk(_buffer);
}
public void Dispose()
{
_stream?.Dispose();
_stream = null;
_buffer = null;
}
}
}