From 7bd2cb6a2a86744d293dedf51e4376e3dabb63ee Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 6 May 2026 22:55:42 -0700 Subject: [PATCH] feat(pathfinding): Tier 4 multi-Z strata (file format v2) (#2450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 } ``` --- .../Pathing/StepCacheLifecycleTests.cs | 92 +++++++++- .../Engines/Pathing/Cache/StepCache.cs | 149 +++++++++++++++- .../Engines/Pathing/Cache/StepCacheFile.cs | 160 ++++++++++++------ .../Engines/Pathing/Cache/StepChunk.cs | 60 ++++--- .../Engines/Pathing/Cache/StepProbe.cs | 83 +++++++++ 5 files changed, 471 insertions(+), 73 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index f57ef4728..4eeb5d80b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -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)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)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() { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index fb1e7be9e..7f5481151 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -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 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(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; } + /// + /// Tier 4 strata lookup. Walks the cell's stratum list, returns true with the first + /// stratum whose zCenter is within StepHeight of . + /// Layout matches : u8 count, then count × 19-byte + /// stratum (sbyte zCenter, byte walkMask, byte wetMask, 8 sbyte walkZ, 8 sbyte swimZ). + /// + 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 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; diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index ebd00811f..6272d3834 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -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 < 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' - public const uint FormatVersion = 1; + public const uint FormatVersion = 2; + + /// + /// 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. + /// + 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); + /// Fixed-size portion of a chunk record (everything except the optional strata trailer). 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; + /// Strata trailer overhead when present: 256×u16 offset table + u32 data length. + private const int StrataTrailerOverhead = StepChunk.CellsPerChunk * sizeof(ushort) + sizeof(uint); /// /// 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()); } /// @@ -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((int)chunkCount); + var offsets = new Dictionary((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 _offsets; + private readonly Dictionary _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 offsets + uint chunkCount, Dictionary 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]; } /// /// Returns the chunk record at (, ) - /// 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). /// 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() diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs index cbaee7bba..351e6ef29 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -1,9 +1,11 @@ +using System; + namespace Server.Engines.Pathing.Cache; /// /// 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. /// 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]; + /// Sentinel: cell has no strata — single-Z, use the main Walk/Wet arrays. + public const ushort NoStrata = ushort.MaxValue; + /// - /// 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: StrataOffsetByCell[cell] = byte offset into + /// where this cell's strata begin, or + /// for cells without multi-Z. Null when the chunk has zero multi-Z cells. /// - private byte[] _multiZCells; + private ushort[] _strataOffsetByCell; + + /// + /// 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) + /// + private byte[] _strataData; /// Snapshot of Sector.MultisVersion at the time this chunk was built. public int BuiltMultisVersion; @@ -47,24 +61,32 @@ internal sealed class StepChunk /// Updated on every cache hit/miss. Used by LRU fallback eviction. public long LastTouchedTicks; - public bool IsCellMultiZ(int cellIndex) => - _multiZCells != null && (_multiZCells[cellIndex >> 3] & (1 << (cellIndex & 7))) != 0; + /// Size in bytes of one Stratum record in StrataData. + 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 StrataData => + _strataData == null ? ReadOnlySpan.Empty : _strataData.AsSpan(); + + /// + /// Single-shot setter for the chunk's strata. Pass null/null to clear (chunk becomes + /// "no multi-Z"). Otherwise must be length 256 with + /// for cells without strata, and the + /// packed strata records. + /// + internal void SetStrata(ushort[] offsetByCell, byte[] data) { - _multiZCells ??= new byte[32]; - _multiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); + _strataOffsetByCell = offsetByCell; + _strataData = data; } - /// - /// Serialization hook for : returns the multi-Z bitmap, - /// or null if no cells in this chunk are multi-Z. Read-only — callers must not mutate. - /// - internal byte[] GetMultiZCellsForSerialization() => _multiZCells; + /// Serialization hook: returns the raw offset array (or null if no strata). + internal ushort[] GetStrataOffsetByCellForSerialization() => _strataOffsetByCell; - /// - /// 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"). - /// - internal void RestoreMultiZCellsFromSerialization(byte[] multiZ) => _multiZCells = multiZ; + /// Serialization hook: returns the raw data array (or null if no strata). + internal byte[] GetStrataDataForSerialization() => _strataData; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index 46182e6f7..c8f74468e 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -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; + } + + /// + /// Tier 4 strata builder: enumerates the distinct walkable standing-Zs at (x, y) + /// — one per land surface plus one per walkable static — and runs + /// 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). + /// + 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 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 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)