using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Runtime.InteropServices;
using Server.Compression;
namespace Server.Engines.Pathing.Cache;
///
/// 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 v8 (little-endian, BufferWriter / BufferReader convention):
///
/// Header (40 bytes):
/// u32 Magic = 0x42575300 ('SWB\0')
/// u32 Version = current FormatVersion (9)
/// 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):
/// u32 UncompressedLen Size of the inflated record body below.
/// byte[] Payload The record body (the v6 layout that follows), libdeflate-
/// compressed. If the on-disk payload length (index recordLength −
/// 4) equals UncompressedLen, the body was stored raw because
/// compression did not shrink it (tiny Uniform records).
///
/// Record body (after inflate — the v6 layout):
/// u16 ChunkX
/// u16 ChunkY
/// u32 BuiltMultisVersion (reserved since v9 — always 0; chunks are static-only)
/// u8 Kind 0 = Full; 2 = Uniform
/// // Uniform (Kind == 2): ~28-byte record — all 256 cells share these single values:
/// byte walkMask, wetMask; sbyte sourceZ; sbyte walkZ_N..NW (8); sbyte swimZ_N..NW (8)
/// // Full (Kind == 0) body:
/// 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
/// u16 ZArrayMask bit d set => base directional Z array d is present below as a
/// residual[256] block; cleared => array equals its prediction and is
/// omitted (synthesized at read). bits 0-7 = WalkZ N..NW (predicted via
/// WalkMask), bits 8-15 = SwimZ N..NW (predicted via WetMask).
/// byte WalkMask[256]
/// byte WetMask[256]
/// sbyte SourceZ[256]
/// // For each d in 0..15 with ZArrayMask bit d set, in N,NE,E,SE,S,SW,W,NW order
/// // (walk arrays first, then swim):
/// sbyte residual_d[256] reconstruct: Z_d[c] = (mask bit set ? SourceZ[c] : 0) + residual_d[c]
/// // 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 (8 × ChunkCount bytes), in record write order:
/// For each chunk: { u32 packedKey = (ChunkX << 16) | ChunkY, u32 recordLength }
/// The file offset is not stored — reconstructed as a cumulative sum of recordLength
/// starting at HeaderSize (the first record sits immediately after the header).
///
/// Per-chunk fixed portion (Kind + flags + ZArrayMask + WalkMask + WetMask + SourceZ):
/// ~783 bytes; each present base Z array adds 256 bytes (0..16 present, so up to ~4 KB).
/// A fully-flat Full chunk stores no residual blocks. 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 < are silently rejected
/// at open time (treated as missing) and overwritten on the next save.
///
internal static class StepCacheFile
{
public const uint Magic = 0x42575300; // 'SWB\0'
// v9: chunks are STATIC-ONLY (land + statics.mul, no multis). v8 and earlier baked multis
// (houses/boats) into chunks, which is unsafe to persist — multis are dynamic, and the
// BuiltMultisVersion they were tagged with is a non-persisted session counter. Bumping the
// version rejects those old files so they re-bake static-only. The BuiltMultisVersion record
// field is retained as a reserved (always-0) u32 to avoid a layout change.
public const uint FormatVersion = 9;
///
/// Lowest format version this binary can load. Files below it are treated as missing
/// (silently rejected) and overwritten by the next SaveToFile / BakeMap. The cache is
/// fully regenerable, so a format bump just forces a one-time re-bake of stale files.
///
public const uint MinSupportedVersion = 9;
// Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved.
private const byte KindFull = 0;
private const byte KindUniform = 2;
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 (v8 compact): u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength.
// The file offset is NOT stored — entries are in record write order, so the reader
// reconstructs each offset by cumulative sum of record lengths starting at HeaderSize.
private const int IndexEntryBytes = sizeof(uint) + sizeof(uint);
/// Fixed-size portion of a chunk record (everything except the optional strata + swim trailers).
private const int BytesPerChunkBase =
sizeof(ushort) + sizeof(ushort) + sizeof(uint)
+ sizeof(byte) + sizeof(byte) + sizeof(byte) // Kind + HasStrata + HasSwimLayer
+ sizeof(ushort) // ZArrayMask
+ StepChunk.CellsPerChunk // WalkMask
+ StepChunk.CellsPerChunk // WetMask
+ StepChunk.CellsPerChunk // SourceZ
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
///
/// Byte offset of the IndexOffset u64 within the header
/// (Magic+Version+MapId+Fingerprint+BakeTimestamp+ChunkCount = 32). Patched after chunks land.
///
private const int IndexOffsetFieldPosition = 32;
public delegate bool ChunkEnumerator(out int chunkX, out int chunkY, out StepChunk chunk);
///
/// 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.
///
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 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;
}
}
///
/// Combined XxHash3 fingerprint over (1) the on-disk tiledata.mul file and (2) the
/// per-map .mul / .uop file contents (via ).
/// Bake files carry this hash so a load can refuse to populate the cache when EITHER the
/// tile data 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.
///
/// IMPORTANT: hash the FILES, never the in-memory /
/// . The server patches those tables at runtime (ItemFixes,
/// LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a fingerprint over
/// the live tables varies with WHEN it is taken; the file hash is the only lifecycle-stable
/// "did the client's tile data change?" signal. Server-side tile patches are applied identically
/// every boot and intentionally do NOT invalidate the cache — change one and you must
/// [PathCacheClear or bump the format.
///
public static ulong ComputeFingerprint(int mapId)
{
var hasher = HashUtility.CreateXxHash3();
// (1) tiledata.mul — hashed once, cached. The authoritative source for tile flags/heights.
Span tileDataBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(tileDataBytes, TileDataFileFingerprint());
hasher.Append(tileDataBytes);
// (2) 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 mapHashBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(mapHashBytes, map.Tiles.MapFilesFingerprint);
hasher.Append(mapHashBytes);
}
return hasher.GetCurrentHashAsUInt64();
}
private static ulong _tileDataFileFingerprint;
private static bool _tileDataFileFingerprintComputed;
///
/// XxHash3 over the raw tiledata.mul bytes, computed once and cached — the file never
/// changes during a run. Mirrors for the map
/// files. Returns 0 if the file can't be found (the server can't run without it anyway, so
/// this only matters in stripped test hosts, where 0 is a fine deterministic constant).
///
private static ulong TileDataFileFingerprint()
{
if (_tileDataFileFingerprintComputed)
{
return _tileDataFileFingerprint;
}
var path = Core.FindDataFile("tiledata.mul", false);
if (path != null)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var hasher = HashUtility.CreateXxHash3();
hasher.Append(fs);
_tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64();
}
_tileDataFileFingerprintComputed = true;
return _tileDataFileFingerprint;
}
///
/// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded)
/// → index trailer → patches the header IndexOffset. must
/// equal the actual number of chunks will yield.
///
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
// Each record is built uncompressed into recordScratch, then libdeflate-compressed into
// compScratch and framed as [u32 uncompressedLen][payload].
var packer = Deflate.Maximum;
var recordScratch = new byte[BytesPerChunkBase + 1024];
var compScratch = new byte[packer.MaxPackSize(recordScratch.Length)];
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, packer, ref recordScratch, ref compScratch);
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++)
{
// v8 compact entry: u32 packedKey ((chunkX << 16) | chunkY) + u32 recordLength.
// Offset is omitted; entries are in record write order so the reader derives it.
var key = indexEntries[i].key;
var packedKey = ((uint)(key >> 32) << 16) | (uint)(key & 0xFFFF);
w.Write(packedKey);
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());
}
///
/// 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.
///
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 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;
}
// v8 compact index: { u32 packedKey, u32 length } per chunk, in record write order.
// The file offset is not stored — reconstruct it by cumulative record length starting
// at the first record (immediately after the header).
var offsets = new Dictionary((int)chunkCount);
var runningOffset = (ulong)HeaderSize;
for (var i = 0; i < chunkCount; i++)
{
var entry = indexBuf.AsSpan(i * IndexEntryBytes);
var packedKey = BinaryPrimitives.ReadUInt32LittleEndian(entry);
var len = BinaryPrimitives.ReadUInt32LittleEndian(entry[4..]);
var key = PackChunkKey((int)(packedKey >> 16), (int)(packedKey & 0xFFFF));
offsets[key] = (runningOffset, len);
runningOffset += 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;
///
/// Predicted directional-Z for one cell/direction: the cell's own SourceZ when the
/// direction is walkable/wet (mask bit set), else 0 — matching the baker, which leaves
/// non-walkable directional slots at their zero-initialized default
/// (StepProbe.ComputeMaskAt clears walkZs/swimZs and writes only on a successful step).
///
internal static sbyte Predict(byte dirMaskByte, int bit, sbyte sourceZ) =>
(dirMaskByte >> bit & 1) != 0 ? sourceZ : (sbyte)0;
///
/// Residual of an absolute directional-Z against its prediction. Unchecked two's-complement
/// so the transform is byte-exact for ALL sbyte inputs (no value-range constraint).
///
internal static sbyte EncodeResidual(sbyte z, sbyte predict) => unchecked((sbyte)(z - predict));
/// Inverse of : absolute directional-Z = predict + residual.
internal static sbyte DecodeZ(sbyte predict, sbyte residual) => unchecked((sbyte)(predict + residual));
///
/// The base directional-Z array for direction index d in canonical order: walk N..NW (0-7),
/// then swim N..NW (8-15). Index d uses WalkMask (d < 8) or WetMask (d >= 8) with
/// direction bit (d & 7).
///
private static sbyte[] GetBaseZArray(StepChunk c, int d) => d switch
{
0 => c.WalkZN, 1 => c.WalkZNE, 2 => c.WalkZE, 3 => c.WalkZSE,
4 => c.WalkZS, 5 => c.WalkZSW, 6 => c.WalkZW, 7 => c.WalkZNW,
8 => c.SwimZN, 9 => c.SwimZNE, 10 => c.SwimZE, 11 => c.SwimZSE,
12 => c.SwimZS, 13 => c.SwimZSW, 14 => c.SwimZW, 15 => c.SwimZNW,
_ => throw new ArgumentOutOfRangeException(nameof(d))
};
///
/// Builds the uncompressed v6 record for one chunk into , libdeflate-
/// compresses it, and writes it framed as [u32 uncompressedLen][payload]. The payload is the
/// compressed bytes, or — when compression does not shrink the record (tiny Uniform records) —
/// the raw record itself; the reader distinguishes the two by payload length vs uncompressedLen.
///
private static void WriteChunk(
BufferWriter w, int chunkX, int chunkY, StepChunk chunk,
LibDeflateBinding packer, ref byte[] recordScratch, ref byte[] compScratch
)
{
var rw = new BufferWriter(recordScratch, prefixStr: false);
BuildRecord(rw, chunkX, chunkY, chunk);
recordScratch = rw.Buffer; // may have grown; keep the larger buffer for reuse
var recordLen = (int)rw.Position;
var bound = packer.MaxPackSize(recordLen);
if (compScratch.Length < bound)
{
compScratch = new byte[bound];
}
var compLen = packer.Pack(compScratch, recordScratch.AsSpan(0, recordLen));
w.Write((uint)recordLen);
if (compLen > 0 && compLen < recordLen)
{
w.Write(compScratch.AsSpan(0, compLen));
}
else
{
// Incompressible (or expanded): store the record raw. The reader detects this when
// the on-disk payload length equals the uncompressed length.
w.Write(recordScratch.AsSpan(0, recordLen));
}
}
private static void BuildRecord(BufferWriter w, int chunkX, int chunkY, StepChunk chunk)
{
w.Write((ushort)chunkX);
w.Write((ushort)chunkY);
w.Write((uint)chunk.BuiltMultisVersion);
// Kind: 0 = Full, 2 = Uniform. A uniform chunk (no strata, no swim layer, all 19 base
// arrays constant) stores one cell's worth of data (~28-byte record total).
if (chunk.IsUniform())
{
w.Write(KindUniform);
w.Write(chunk.WalkMask[0]);
w.Write(chunk.WetMask[0]);
w.Write((byte)chunk.SourceZ[0]);
w.Write((byte)chunk.WalkZN[0]);
w.Write((byte)chunk.WalkZNE[0]);
w.Write((byte)chunk.WalkZE[0]);
w.Write((byte)chunk.WalkZSE[0]);
w.Write((byte)chunk.WalkZS[0]);
w.Write((byte)chunk.WalkZSW[0]);
w.Write((byte)chunk.WalkZW[0]);
w.Write((byte)chunk.WalkZNW[0]);
w.Write((byte)chunk.SwimZN[0]);
w.Write((byte)chunk.SwimZNE[0]);
w.Write((byte)chunk.SwimZE[0]);
w.Write((byte)chunk.SwimZSE[0]);
w.Write((byte)chunk.SwimZS[0]);
w.Write((byte)chunk.SwimZSW[0]);
w.Write((byte)chunk.SwimZW[0]);
w.Write((byte)chunk.SwimZNW[0]);
return;
}
w.Write(KindFull); // Full
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));
// Predictive-Z: each base directional Z array is stored as a masked residual against
// SourceZ. Bit d of ZArrayMask is set only when array d differs from its prediction
// somewhere; cleared arrays are omitted and rebuilt from mask+SourceZ at read.
ushort zArrayMask = 0;
for (var d = 0; d < 16; d++)
{
var z = GetBaseZArray(chunk, d);
var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask;
var bit = d & 7;
for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++)
{
if (z[cell] != Predict(dirMask[cell], bit, chunk.SourceZ[cell]))
{
zArrayMask |= (ushort)(1 << d);
break;
}
}
}
w.Write(zArrayMask);
w.Write(chunk.WalkMask);
w.Write(chunk.WetMask);
WriteSBytes(w, chunk.SourceZ);
Span residual = stackalloc sbyte[StepChunk.CellsPerChunk];
for (var d = 0; d < 16; d++)
{
if ((zArrayMask >> d & 1) == 0)
{
continue;
}
var z = GetBaseZArray(chunk, d);
var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask;
var bit = d & 7;
for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++)
{
residual[cell] = EncodeResidual(z[cell], Predict(dirMask[cell], bit, chunk.SourceZ[cell]));
}
w.Write(MemoryMarshal.Cast(residual));
}
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 kind = r.ReadByte();
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
if (kind == KindUniform) // Uniform — one cell's worth of the 19 base arrays, fill all 256 cells.
{
Array.Fill(chunk.WalkMask, r.ReadByte());
Array.Fill(chunk.WetMask, r.ReadByte());
Array.Fill(chunk.SourceZ, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZN, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZNE, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZE, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZSE, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZS, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZSW, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZW, (sbyte)r.ReadByte());
Array.Fill(chunk.WalkZNW, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZN, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZNE, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZE, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZSE, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZS, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZSW, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZW, (sbyte)r.ReadByte());
Array.Fill(chunk.SwimZNW, (sbyte)r.ReadByte());
return chunk;
}
var hasStrata = r.ReadByte() != 0;
var hasSwimLayer = r.ReadByte() != 0;
var zArrayMask = r.ReadUShort();
r.Read(chunk.WalkMask);
r.Read(chunk.WetMask);
ReadSBytes(r, chunk.SourceZ);
// Predictive-Z reconstruction: present arrays carry residuals (z = predict + residual);
// absent arrays are synthesized from mask+SourceZ (z = predict, residual implicitly 0).
Span residual = stackalloc sbyte[StepChunk.CellsPerChunk];
for (var d = 0; d < 16; d++)
{
var z = GetBaseZArray(chunk, d);
var dirMask = d < 8 ? chunk.WalkMask : chunk.WetMask;
var bit = d & 7;
if ((zArrayMask >> d & 1) != 0)
{
r.Read(MemoryMarshal.Cast(residual));
for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++)
{
z[cell] = DecodeZ(Predict(dirMask[cell], bit, chunk.SourceZ[cell]), residual[cell]);
}
}
else
{
for (var cell = 0; cell < StepChunk.CellsPerChunk; cell++)
{
z[cell] = Predict(dirMask[cell], bit, chunk.SourceZ[cell]);
}
}
}
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(arr.AsSpan()));
private static void ReadSBytes(BufferReader r, sbyte[] arr) =>
r.Read(MemoryMarshal.Cast(arr.AsSpan()));
///
/// Open handle on a .swb file. Holds the FileStream + chunk-offset index. Chunks are
/// fetched on demand via ; only the records actually queried
/// are ever materialized. Dispose releases the underlying stream.
///
internal sealed class LazyReader : IDisposable
{
private FileStream _stream;
private readonly Dictionary _offsets;
private byte[] _buffer; // raw on-disk record: [u32 uncompressedLen][payload]
private byte[] _bodyBuffer; // decompressed v6 record, parsed by ReadChunk
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));
///
/// Enumerates every (chunkX, chunkY) coordinate the file holds. Used by
/// when preload is enabled to materialize all chunks
/// upfront instead of on first query.
///
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 offsets
)
{
_stream = stream;
MapId = mapId;
Fingerprint = fingerprint;
BakeTimestamp = bakeTimestamp;
ChunkCount = chunkCount;
_offsets = offsets;
_buffer = new byte[BytesPerChunkBase];
_bodyBuffer = new byte[BytesPerChunkBase];
}
///
/// Returns the chunk record at (, )
/// 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).
///
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 on-disk scratch buffer if this chunk's record is larger than what we have.
if (entry.length > _buffer.Length)
{
_buffer = new byte[entry.length];
}
_stream.Position = (long)entry.offset;
var read = _stream.Read(_buffer, 0, (int)entry.length);
if (read < (int)entry.length || entry.length < sizeof(uint))
{
return null;
}
// Frame: [u32 uncompressedLen][payload]. payload is libdeflate-compressed, unless its
// length equals uncompressedLen, in which case it was stored raw (incompressible).
var uncompressedLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(_buffer);
var payloadLen = (int)entry.length - sizeof(uint);
if (_bodyBuffer.Length < uncompressedLen)
{
_bodyBuffer = new byte[uncompressedLen];
}
if (payloadLen == uncompressedLen)
{
Array.Copy(_buffer, sizeof(uint), _bodyBuffer, 0, uncompressedLen);
}
else
{
// Decompression is level-independent, so reuse the shared per-thread binding.
var result = Deflate.Standard.Unpack(
_bodyBuffer.AsSpan(0, uncompressedLen),
_buffer.AsSpan(sizeof(uint), payloadLen),
out var produced
);
if (result != LibDeflateResult.Success || produced != uncompressedLen)
{
return null;
}
}
return ReadChunk(_bodyBuffer);
}
public void Dispose()
{
_stream?.Dispose();
_stream = null;
_buffer = null;
_bodyBuffer = null;
}
}
}