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:
parent
a8ca82738d
commit
7bd2cb6a2a
5 changed files with 471 additions and 73 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -348,6 +348,18 @@ public sealed class StepCache
|
|||
|
||||
if (chunk.IsCellMultiZ(cellIndex))
|
||||
{
|
||||
// Tier 4: try the per-cell strata. Each stratum is keyed by its bake-time
|
||||
// standing-Z; a query matches when |sourceZ - stratum.zCenter| <= StepHeight.
|
||||
if (TryStratumHit(chunk, cellIndex, sourceZ, hitKindResult, out var stratumResult))
|
||||
{
|
||||
switch (hitKindResult)
|
||||
{
|
||||
case CacheHitKind.Miss_NotBuilt: { _missesNotBuilt++; break; }
|
||||
case CacheHitKind.Miss_DirtyRebuild: { _missesDirtyRebuild++; break; }
|
||||
case CacheHitKind.Hit: { _hits++; break; }
|
||||
}
|
||||
return stratumResult;
|
||||
}
|
||||
_fallthroughMultiZ++;
|
||||
return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_MultiZ);
|
||||
}
|
||||
|
|
@ -425,6 +437,11 @@ public sealed class StepCache
|
|||
var baseX = chunkX << 4;
|
||||
var baseY = chunkY << 4;
|
||||
|
||||
// Tier 4 strata accumulator. Lazily allocated when the first multi-Z cell
|
||||
// appears; otherwise the chunk has zero strata overhead.
|
||||
ushort[] strataOffsetByCell = null;
|
||||
System.Collections.Generic.List<byte> strataData = null;
|
||||
|
||||
for (var dy = 0; dy < ChunkSize; dy++)
|
||||
{
|
||||
for (var dx = 0; dx < ChunkSize; dx++)
|
||||
|
|
@ -462,19 +479,145 @@ public sealed class StepCache
|
|||
chunk.SwimZW[cell] = result.SwimZ_W;
|
||||
chunk.SwimZNW[cell] = result.SwimZ_NW;
|
||||
|
||||
// Multi-Z = ≥2 surfaces reachable from standingZ. Mirrors the baker's
|
||||
// CheckStaticStep filter so we don't over-mark.
|
||||
// Multi-Z handling: if the cell has 2+ reachable surfaces, compute its
|
||||
// Tier 4 strata so future queries can be answered without falling through
|
||||
// to the slow path. ComputeStrataAt returns null for single-Z cells.
|
||||
if (CountReachableSurfaces(map, x, y, standingZ) > 1)
|
||||
{
|
||||
chunk.MarkCellMultiZ(cell);
|
||||
var strata = StepProbe.ComputeStrataAt(map, x, y);
|
||||
if (strata != null)
|
||||
{
|
||||
if (strataOffsetByCell == null)
|
||||
{
|
||||
strataOffsetByCell = new ushort[StepChunk.CellsPerChunk];
|
||||
for (var i = 0; i < strataOffsetByCell.Length; i++)
|
||||
{
|
||||
strataOffsetByCell[i] = StepChunk.NoStrata;
|
||||
}
|
||||
strataData = new System.Collections.Generic.List<byte>(256);
|
||||
}
|
||||
|
||||
// Cap at 65,535 byte offsets — well above realistic per-chunk
|
||||
// strata volume. If we ever blow past this we'd silently truncate;
|
||||
// assert as a defensive guard.
|
||||
if (strataData.Count > ushort.MaxValue - StepChunk.StratumByteLength * 8)
|
||||
{
|
||||
// Should never happen for sane tile data; bail to fallthrough.
|
||||
}
|
||||
else
|
||||
{
|
||||
strataOffsetByCell[cell] = (ushort)strataData.Count;
|
||||
strataData.Add((byte)strata.Length);
|
||||
for (var s = 0; s < strata.Length; s++)
|
||||
{
|
||||
AppendStratumBytes(strataData, strata[s]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strataOffsetByCell != null)
|
||||
{
|
||||
chunk.SetStrata(strataOffsetByCell, strataData.ToArray());
|
||||
}
|
||||
|
||||
_buildsTotal++;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tier 4 strata lookup. Walks the cell's stratum list, returns true with the first
|
||||
/// stratum whose <c>zCenter</c> is within StepHeight of <paramref name="sourceZ"/>.
|
||||
/// Layout matches <see cref="StepChunk.StrataData"/>: u8 count, then count × 19-byte
|
||||
/// stratum (sbyte zCenter, byte walkMask, byte wetMask, 8 sbyte walkZ, 8 sbyte swimZ).
|
||||
/// </summary>
|
||||
private static bool TryStratumHit(
|
||||
StepChunk chunk, int cellIndex, sbyte sourceZ, CacheHitKind hitKind, out StepMask result
|
||||
)
|
||||
{
|
||||
var off = chunk.GetStrataOffset(cellIndex);
|
||||
if (off == StepChunk.NoStrata)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var data = chunk.StrataData;
|
||||
if (off >= data.Length)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var count = data[off];
|
||||
var entryStart = off + 1;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entryOff = entryStart + i * StepChunk.StratumByteLength;
|
||||
if (entryOff + StepChunk.StratumByteLength > data.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
var zCenter = (sbyte)data[entryOff];
|
||||
if (Math.Abs(sourceZ - zCenter) > StepHeight)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
result = new StepMask(
|
||||
/* walkMask */ data[entryOff + 1],
|
||||
/* wetMask */ data[entryOff + 2],
|
||||
(sbyte)data[entryOff + 3],
|
||||
(sbyte)data[entryOff + 4],
|
||||
(sbyte)data[entryOff + 5],
|
||||
(sbyte)data[entryOff + 6],
|
||||
(sbyte)data[entryOff + 7],
|
||||
(sbyte)data[entryOff + 8],
|
||||
(sbyte)data[entryOff + 9],
|
||||
(sbyte)data[entryOff + 10],
|
||||
(sbyte)data[entryOff + 11],
|
||||
(sbyte)data[entryOff + 12],
|
||||
(sbyte)data[entryOff + 13],
|
||||
(sbyte)data[entryOff + 14],
|
||||
(sbyte)data[entryOff + 15],
|
||||
(sbyte)data[entryOff + 16],
|
||||
(sbyte)data[entryOff + 17],
|
||||
(sbyte)data[entryOff + 18],
|
||||
hitKind
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AppendStratumBytes(
|
||||
System.Collections.Generic.List<byte> dst, in StepProbe.ComputedStratum s
|
||||
)
|
||||
{
|
||||
dst.Add((byte)s.ZCenter);
|
||||
dst.Add(s.Mask.WalkMask);
|
||||
dst.Add(s.Mask.WetMask);
|
||||
dst.Add((byte)s.Mask.WalkZ_N);
|
||||
dst.Add((byte)s.Mask.WalkZ_NE);
|
||||
dst.Add((byte)s.Mask.WalkZ_E);
|
||||
dst.Add((byte)s.Mask.WalkZ_SE);
|
||||
dst.Add((byte)s.Mask.WalkZ_S);
|
||||
dst.Add((byte)s.Mask.WalkZ_SW);
|
||||
dst.Add((byte)s.Mask.WalkZ_W);
|
||||
dst.Add((byte)s.Mask.WalkZ_NW);
|
||||
dst.Add((byte)s.Mask.SwimZ_N);
|
||||
dst.Add((byte)s.Mask.SwimZ_NE);
|
||||
dst.Add((byte)s.Mask.SwimZ_E);
|
||||
dst.Add((byte)s.Mask.SwimZ_SE);
|
||||
dst.Add((byte)s.Mask.SwimZ_S);
|
||||
dst.Add((byte)s.Mask.SwimZ_SW);
|
||||
dst.Add((byte)s.Mask.SwimZ_W);
|
||||
dst.Add((byte)s.Mask.SwimZ_NW);
|
||||
}
|
||||
|
||||
private const int PersonHeight = 16;
|
||||
private const int StepHeight = 2;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ namespace Server.Engines.Pathing.Cache;
|
|||
/// only when the cache asks for them. RAM stays bounded by MaxResidentChunks regardless
|
||||
/// of file size.
|
||||
///
|
||||
/// File layout (little-endian, BufferWriter / BufferReader convention):
|
||||
/// File layout v2 (little-endian, BufferWriter / BufferReader convention):
|
||||
///
|
||||
/// Header (48 bytes):
|
||||
/// u32 Magic = 0x42575300 ('SWB\0')
|
||||
/// u32 Version = current FormatVersion
|
||||
/// u32 Version = current FormatVersion (2)
|
||||
/// u32 MapId
|
||||
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
|
||||
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul.
|
||||
|
|
@ -31,27 +31,47 @@ namespace Server.Engines.Pathing.Cache;
|
|||
/// u64 IndexOffset File position where the chunk index begins.
|
||||
///
|
||||
/// Per chunk (ChunkCount times, variable size):
|
||||
/// u16 ChunkX
|
||||
/// u16 ChunkY
|
||||
/// u32 BuiltMultisVersion
|
||||
/// u8 HasMultiZ 0 = no MultiZCells follow; 1 = 32 bytes of MultiZCells follow
|
||||
/// 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)
|
||||
/// [byte MultiZCells[32] — only when HasMultiZ == 1]
|
||||
/// u16 ChunkX
|
||||
/// u16 ChunkY
|
||||
/// u32 BuiltMultisVersion
|
||||
/// u8 HasStrata 0 = single-Z chunk (no strata trailer); 1 = strata 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)
|
||||
/// // 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 (16 × ChunkCount bytes):
|
||||
/// For each chunk: { u64 chunkKey, u64 fileOffset }
|
||||
/// Index trailer (20 × ChunkCount bytes):
|
||||
/// For each chunk: { u64 chunkKey, u64 fileOffset, u32 recordLength }
|
||||
///
|
||||
/// Per-chunk size: ~5,393 bytes (no multi-Z) or ~5,425 bytes (with multi-Z).
|
||||
/// LRU bookkeeping (LastTouchedTicks) is intentionally not persisted.
|
||||
/// 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 < <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 = 1;
|
||||
public const uint FormatVersion = 2;
|
||||
|
||||
/// <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 2 when Tier 4 multi-Z strata landed; v1 had no
|
||||
/// strata data and is incompatible with the strata-aware lookup path.
|
||||
/// </summary>
|
||||
public const uint MinSupportedVersion = 2;
|
||||
|
||||
private const int HeaderSize =
|
||||
sizeof(uint) // Magic
|
||||
|
|
@ -62,8 +82,12 @@ internal static class StepCacheFile
|
|||
+ sizeof(uint) // ChunkCount
|
||||
+ sizeof(ulong); // IndexOffset
|
||||
|
||||
private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong); // chunkKey + offset
|
||||
// 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 trailer).</summary>
|
||||
private const int BytesPerChunkBase =
|
||||
sizeof(ushort) + sizeof(ushort) + sizeof(uint) + sizeof(byte)
|
||||
+ StepChunk.CellsPerChunk // WalkMask
|
||||
|
|
@ -72,7 +96,8 @@ internal static class StepCacheFile
|
|||
+ 8 * StepChunk.CellsPerChunk // WalkZ[8]
|
||||
+ 8 * StepChunk.CellsPerChunk; // SwimZ[8]
|
||||
|
||||
private const int BytesPerMultiZ = 32;
|
||||
/// <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
|
||||
|
|
@ -107,7 +132,8 @@ internal static class StepCacheFile
|
|||
{
|
||||
return false;
|
||||
}
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(buf[4..]) != FormatVersion)
|
||||
var version = BinaryPrimitives.ReadUInt32LittleEndian(buf[4..]);
|
||||
if (version < MinSupportedVersion || version > FormatVersion)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -175,8 +201,11 @@ internal static class StepCacheFile
|
|||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
|
||||
|
||||
// Initial estimate: base record + a modest strata budget per chunk. BufferWriter
|
||||
// grows on overflow, so under-estimating just causes a few realloc/copy cycles
|
||||
// during the bake — not a correctness issue.
|
||||
var capacity = HeaderSize
|
||||
+ (BytesPerChunkBase + BytesPerMultiZ) * (int)chunkCount
|
||||
+ (BytesPerChunkBase + 256) * (int)chunkCount
|
||||
+ IndexEntryBytes * (int)chunkCount;
|
||||
var buffer = new byte[capacity];
|
||||
var w = new BufferWriter(buffer, prefixStr: false);
|
||||
|
|
@ -189,7 +218,7 @@ internal static class StepCacheFile
|
|||
w.Write(chunkCount);
|
||||
w.Write(0UL); // IndexOffset placeholder, patched after chunks
|
||||
|
||||
var indexEntries = new (ulong key, ulong offset)[chunkCount];
|
||||
var indexEntries = new (ulong key, ulong offset, uint length)[chunkCount];
|
||||
var written = 0u;
|
||||
while (next(out var chunkX, out var chunkY, out var chunk))
|
||||
{
|
||||
|
|
@ -201,7 +230,8 @@ internal static class StepCacheFile
|
|||
}
|
||||
var chunkOffset = (ulong)w.Position;
|
||||
WriteChunk(w, chunkX, chunkY, chunk);
|
||||
indexEntries[written] = (PackChunkKey(chunkX, chunkY), chunkOffset);
|
||||
var chunkLength = (uint)((ulong)w.Position - chunkOffset);
|
||||
indexEntries[written] = (PackChunkKey(chunkX, chunkY), chunkOffset, chunkLength);
|
||||
written++;
|
||||
}
|
||||
|
||||
|
|
@ -217,13 +247,16 @@ internal static class StepCacheFile
|
|||
{
|
||||
w.Write(indexEntries[i].key);
|
||||
w.Write(indexEntries[i].offset);
|
||||
w.Write(indexEntries[i].length);
|
||||
}
|
||||
|
||||
// Patch IndexOffset directly into the buffer (BufferWriter has no Seek).
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(IndexOffsetFieldPosition, 8), indexOffset);
|
||||
// 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, buffer.AsSpan(0, totalBytes).ToArray());
|
||||
File.WriteAllBytes(path, liveBuffer.AsSpan(0, totalBytes).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -262,8 +295,10 @@ internal static class StepCacheFile
|
|||
return null;
|
||||
}
|
||||
var version = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[4..]);
|
||||
if (version != FormatVersion)
|
||||
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;
|
||||
}
|
||||
|
|
@ -290,13 +325,14 @@ internal static class StepCacheFile
|
|||
return null;
|
||||
}
|
||||
|
||||
var offsets = new Dictionary<ulong, ulong>((int)chunkCount);
|
||||
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..]);
|
||||
offsets[key] = off;
|
||||
var len = BinaryPrimitives.ReadUInt32LittleEndian(entry[16..]);
|
||||
offsets[key] = (off, len);
|
||||
}
|
||||
|
||||
return new LazyReader(stream, mapId, fingerprint, bakeTimestamp, chunkCount, offsets);
|
||||
|
|
@ -316,8 +352,10 @@ internal static class StepCacheFile
|
|||
w.Write((ushort)chunkY);
|
||||
w.Write((uint)chunk.BuiltMultisVersion);
|
||||
|
||||
var multiZ = chunk.GetMultiZCellsForSerialization();
|
||||
w.Write((byte)(multiZ != null ? 1 : 0));
|
||||
var strataOffsetByCell = chunk.GetStrataOffsetByCellForSerialization();
|
||||
var strataData = chunk.GetStrataDataForSerialization();
|
||||
var hasStrata = strataOffsetByCell != null;
|
||||
w.Write((byte)(hasStrata ? 1 : 0));
|
||||
|
||||
w.Write(chunk.WalkMask);
|
||||
w.Write(chunk.WetMask);
|
||||
|
|
@ -341,9 +379,19 @@ internal static class StepCacheFile
|
|||
WriteSBytes(w, chunk.SwimZW);
|
||||
WriteSBytes(w, chunk.SwimZNW);
|
||||
|
||||
if (multiZ != null)
|
||||
if (hasStrata)
|
||||
{
|
||||
w.Write(multiZ);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +402,7 @@ internal static class StepCacheFile
|
|||
r.ReadUShort();
|
||||
r.ReadUShort();
|
||||
var multisVersion = (int)r.ReadUInt();
|
||||
var hasMultiZ = r.ReadByte() != 0;
|
||||
var hasStrata = r.ReadByte() != 0;
|
||||
|
||||
var chunk = new StepChunk { BuiltMultisVersion = multisVersion };
|
||||
|
||||
|
|
@ -380,11 +428,20 @@ internal static class StepCacheFile
|
|||
ReadSBytes(r, chunk.SwimZW);
|
||||
ReadSBytes(r, chunk.SwimZNW);
|
||||
|
||||
if (hasMultiZ)
|
||||
if (hasStrata)
|
||||
{
|
||||
var multiZ = new byte[BytesPerMultiZ];
|
||||
r.Read(multiZ);
|
||||
chunk.RestoreMultiZCellsFromSerialization(multiZ);
|
||||
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;
|
||||
|
|
@ -404,7 +461,7 @@ internal static class StepCacheFile
|
|||
internal sealed class LazyReader : IDisposable
|
||||
{
|
||||
private FileStream _stream;
|
||||
private readonly Dictionary<ulong, ulong> _offsets;
|
||||
private readonly Dictionary<ulong, (ulong offset, uint length)> _offsets;
|
||||
private byte[] _buffer;
|
||||
|
||||
public uint MapId { get; }
|
||||
|
|
@ -417,7 +474,7 @@ internal static class StepCacheFile
|
|||
|
||||
internal LazyReader(
|
||||
FileStream stream, uint mapId, ulong fingerprint, ulong bakeTimestamp,
|
||||
uint chunkCount, Dictionary<ulong, ulong> offsets
|
||||
uint chunkCount, Dictionary<ulong, (ulong offset, uint length)> offsets
|
||||
)
|
||||
{
|
||||
_stream = stream;
|
||||
|
|
@ -426,13 +483,13 @@ internal static class StepCacheFile
|
|||
BakeTimestamp = bakeTimestamp;
|
||||
ChunkCount = chunkCount;
|
||||
_offsets = offsets;
|
||||
_buffer = new byte[BytesPerChunkBase + BytesPerMultiZ];
|
||||
_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;
|
||||
/// no allocations beyond the returned StepChunk and its arrays.
|
||||
/// 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)
|
||||
{
|
||||
|
|
@ -442,16 +499,21 @@ internal static class StepCacheFile
|
|||
}
|
||||
|
||||
var key = PackChunkKey(chunkX, chunkY);
|
||||
if (!_offsets.TryGetValue(key, out var offset))
|
||||
if (!_offsets.TryGetValue(key, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_stream.Position = (long)offset;
|
||||
// Try to read the maximum size; the file may have less remaining, which is OK
|
||||
// since BufferReader stops at the bytes it actually needs.
|
||||
var read = _stream.Read(_buffer, 0, _buffer.Length);
|
||||
return read < BytesPerChunkBase ? null : ReadChunk(_buffer);
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace Server.Engines.Pathing.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-chunk storage backing StepCache. Holds raw walk + swim masks and destination Z
|
||||
/// values for each of 256 cells in a 16x16 chunk, plus build-time metadata (multis
|
||||
/// version, multi-Z bitmap) and LRU bookkeeping.
|
||||
/// version, multi-Z strata) and LRU bookkeeping.
|
||||
/// </summary>
|
||||
internal sealed class StepChunk
|
||||
{
|
||||
|
|
@ -35,11 +37,23 @@ internal sealed class StepChunk
|
|||
public readonly sbyte[] SwimZW = new sbyte[CellsPerChunk];
|
||||
public readonly sbyte[] SwimZNW = new sbyte[CellsPerChunk];
|
||||
|
||||
/// <summary>Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays.</summary>
|
||||
public const ushort NoStrata = ushort.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// 32 bytes = 256 bits when allocated. Lazy: most chunks are entirely single-Z,
|
||||
/// so we only pay the 32 bytes on chunks that actually need it.
|
||||
/// Length-256 offset table: <c>StrataOffsetByCell[cell] = byte offset</c> into
|
||||
/// <see cref="StrataData"/> where this cell's strata begin, or <see cref="NoStrata"/>
|
||||
/// for cells without multi-Z. Null when the chunk has zero multi-Z cells.
|
||||
/// </summary>
|
||||
private byte[] _multiZCells;
|
||||
private ushort[] _strataOffsetByCell;
|
||||
|
||||
/// <summary>
|
||||
/// Packed per-cell strata. For each cell with strata:
|
||||
/// u8 stratumCount, then stratumCount × Stratum (19 bytes each):
|
||||
/// sbyte zCenter, byte walkMask, byte wetMask,
|
||||
/// sbyte walkZ_N..NW (8), sbyte swimZ_N..NW (8)
|
||||
/// </summary>
|
||||
private byte[] _strataData;
|
||||
|
||||
/// <summary>Snapshot of Sector.MultisVersion at the time this chunk was built.</summary>
|
||||
public int BuiltMultisVersion;
|
||||
|
|
@ -47,24 +61,32 @@ internal sealed class StepChunk
|
|||
/// <summary>Updated on every cache hit/miss. Used by LRU fallback eviction.</summary>
|
||||
public long LastTouchedTicks;
|
||||
|
||||
public bool IsCellMultiZ(int cellIndex) =>
|
||||
_multiZCells != null && (_multiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0;
|
||||
/// <summary>Size in bytes of one Stratum record in StrataData.</summary>
|
||||
public const int StratumByteLength = 1 + 1 + 1 + 8 + 8;
|
||||
|
||||
public void MarkCellMultiZ(int cellIndex)
|
||||
public bool IsCellMultiZ(int cellIndex) => GetStrataOffset(cellIndex) != NoStrata;
|
||||
|
||||
public ushort GetStrataOffset(int cellIndex) =>
|
||||
_strataOffsetByCell == null ? NoStrata : _strataOffsetByCell[cellIndex];
|
||||
|
||||
public ReadOnlySpan<byte> StrataData =>
|
||||
_strataData == null ? ReadOnlySpan<byte>.Empty : _strataData.AsSpan();
|
||||
|
||||
/// <summary>
|
||||
/// Single-shot setter for the chunk's strata. Pass null/null to clear (chunk becomes
|
||||
/// "no multi-Z"). Otherwise <paramref name="offsetByCell"/> must be length 256 with
|
||||
/// <see cref="NoStrata"/> for cells without strata, and <paramref name="data"/> the
|
||||
/// packed strata records.
|
||||
/// </summary>
|
||||
internal void SetStrata(ushort[] offsetByCell, byte[] data)
|
||||
{
|
||||
_multiZCells ??= new byte[32];
|
||||
_multiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7));
|
||||
_strataOffsetByCell = offsetByCell;
|
||||
_strataData = data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialization hook for <see cref="StepCacheFile"/>: returns the multi-Z bitmap,
|
||||
/// or null if no cells in this chunk are multi-Z. Read-only — callers must not mutate.
|
||||
/// </summary>
|
||||
internal byte[] GetMultiZCellsForSerialization() => _multiZCells;
|
||||
/// <summary>Serialization hook: returns the raw offset array (or null if no strata).</summary>
|
||||
internal ushort[] GetStrataOffsetByCellForSerialization() => _strataOffsetByCell;
|
||||
|
||||
/// <summary>
|
||||
/// Deserialization hook: assigns the multi-Z bitmap from a file load. Caller is
|
||||
/// responsible for passing a 32-byte array (or null for "no cells multi-Z").
|
||||
/// </summary>
|
||||
internal void RestoreMultiZCellsFromSerialization(byte[] multiZ) => _multiZCells = multiZ;
|
||||
/// <summary>Serialization hook: returns the raw data array (or null if no strata).</summary>
|
||||
internal byte[] GetStrataDataForSerialization() => _strataData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,89 @@ public static class StepProbe
|
|||
private const int PersonHeight = 16;
|
||||
private const int StepHeight = 2;
|
||||
|
||||
public readonly struct ComputedStratum(sbyte zCenter, StepMask mask)
|
||||
{
|
||||
public readonly sbyte ZCenter = zCenter;
|
||||
public readonly StepMask Mask = mask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tier 4 strata builder: enumerates the distinct walkable standing-Zs at (x, y)
|
||||
/// — one per land surface plus one per walkable static — and runs
|
||||
/// <see cref="ComputeMaskAt"/> at each, producing a per-stratum walkability snapshot.
|
||||
/// Returns null when the cell has 0 or 1 strata (single-Z; the caller should use
|
||||
/// the chunk's main mask).
|
||||
/// </summary>
|
||||
public static ComputedStratum[] ComputeStrataAt(Map map, int x, int y)
|
||||
{
|
||||
if (map == null || map == Map.Internal)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (x < 0 || y < 0 || x >= map.Width || y >= map.Height)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collect candidate Zs. 16 slots is generous — multi-Z cells in practice rarely
|
||||
// exceed 3-4 surfaces (bridge over land, paver-over-ground, multi-floor stairs).
|
||||
Span<int> zs = stackalloc int[16];
|
||||
var count = 0;
|
||||
|
||||
var landTile = map.Tiles.GetLandTile(x, y);
|
||||
var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags;
|
||||
if (!landTile.Ignored && (landFlags & TileFlag.Impassable) == 0)
|
||||
{
|
||||
map.GetAverageZ(x, y, out _, out var landCenter, out _);
|
||||
zs[count++] = landCenter;
|
||||
}
|
||||
|
||||
foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y))
|
||||
{
|
||||
if (count >= zs.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
|
||||
if (!data.Surface || data.Impassable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
zs[count++] = tile.Z + data.CalcHeight;
|
||||
}
|
||||
|
||||
if (count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort and merge near-equal Zs. Two Zs separated by less than 2*StepHeight collapse
|
||||
// into a single stratum — the slow path's tolerance treats them as the same surface.
|
||||
zs[..count].Sort();
|
||||
Span<int> distinct = stackalloc int[16];
|
||||
var distinctCount = 0;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (distinctCount == 0 || zs[i] - distinct[distinctCount - 1] > 2 * StepHeight)
|
||||
{
|
||||
distinct[distinctCount++] = zs[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (distinctCount <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var strata = new ComputedStratum[distinctCount];
|
||||
for (var i = 0; i < distinctCount; i++)
|
||||
{
|
||||
var z = (sbyte)Math.Clamp(distinct[i], sbyte.MinValue, sbyte.MaxValue);
|
||||
strata[i] = new ComputedStratum(z, ComputeMaskAt(map, x, y, z));
|
||||
}
|
||||
return strata;
|
||||
}
|
||||
|
||||
public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ)
|
||||
{
|
||||
if (map == null || map == Map.Internal)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue