From 7c9215d97cb25c56b0b5cb35034944ccdaa0a485 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 6 May 2026 01:32:44 -0700 Subject: [PATCH] feat(pathfinding): lazy .swb backing store for the step cache (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a binary disk format + lazy reader so the step cache can warm-start from a precomputed file without paying chunk-build cost on the first pathfind through a region. **Resident memory stays bounded by `MaxResidentChunks` regardless of file size** — opening a `.swb` reads only the header + chunk-offset index (~16 bytes per indexed chunk), and individual chunks are seeked + deserialized only when `ResolveMissingChunk` asks for them. The lazy design (vs. an eager bulk load): a 250 MB bake on a RAM-constrained shard never materializes more than the LRU cap (~40 MB at the default 8192-chunk cap), and unwanted regions never enter memory at all. Builds on PR #2447. ## What changed - **`StepCacheFile`** — binary reader/writer module. Writer emits header → chunks (offsets recorded) → index trailer, then patches the header's `IndexOffset` field. Reader is `OpenForLazy(path)` returning a `LazyReader` that holds an open `FileStream` + offset dictionary. - **`StepCacheFile.LazyReader`** — `TryReadChunk(chunkX, chunkY)` does a single seek + bulk read for one record. `Dispose` releases the underlying stream. Files are opened with `FileShare.Read | FileShare.Delete` so admin tooling can replace them. - **TileData fingerprint via XxHash3.** The `.swb` header carries a hash of `LandTable + ItemTable` flags. Load rejects any file whose hash doesn't match the running server. Computed via `HashUtility.ComputeHash64` (engine-blessed hasher) — adds a `ReadOnlySpan` overload alongside the existing `ReadOnlySpan` one for parity. - **`StepCache.SaveToFile(path, mapId)`** — writes resident chunks for the given map. - **`StepCache.TryOpenLazyReader(path, mapId)`** — opens the file, validates header, holds the reader for the map's lifetime. - **`StepCache.ResolveMissingChunk`** — now consults the lazy reader before invoking the runtime baker. A loaded chunk whose `BuiltMultisVersion` doesn't match the live sector falls through to the baker (snapshot was made before a multi was added/removed in that sector). - **`StepCache.Clear` closes lazy readers.** Test cleanup can delete `.swb` files cleanly. - **Auto-load at startup.** `PathCacheCommands.Configure()` opens `Data/Pathfinding/.swb` as a lazy reader for every map. - **`[PathCacheSave`** / **`[PathCacheLoad`** — admin commands for the same workflow. - **`pathfinding.maxResidentChunks` shard-tunable.** Read from `server.cfg` at boot via `ServerConfiguration.GetOrUpdateSetting` (default 8192 ≈ 40 MB). Small shards can tune down; large shards with substantial bakes can tune up to reduce eviction churn. Default is written back to `server.cfg` on first boot, matching the engine pattern used by other settings. ## File layout (v1) ``` Header (48 bytes): u32 Magic = 0x42575300 ('SWB\0') u32 Version = 1 u32 MapId u64 TileDataHash XxHash3 over LandTable + ItemTable flags (HashUtility) u64 BakeTimestamp informational u32 ChunkCount u64 IndexOffset file position where the chunk index begins Chunk records (fixed size, ~5,393 bytes each, +32 if multi-Z): u16 ChunkX u16 ChunkY u32 BuiltMultisVersion u8 HasMultiZ byte WalkMask[256], WetMask[256] sbyte SourceZ[256], WalkZN..WalkZNW[256], SwimZN..SwimZNW[256] [byte MultiZCells[32] when HasMultiZ == 1] Index trailer (16 × ChunkCount bytes): (u64 chunkKey, u64 fileOffset) ``` ## Memory math | Scenario | Disk file | RAM at boot | Notes | |---|---|---|---| | Empty / no `.swb` files | — | 0 | Silent; cache builds on demand. | | Admin-curated towns (5K chunks) | 25 MB | 0 + per-query | Index ≈ 80 KB. Resident grows to the configured cap under steady-state queries. | | Full-map bake (50K chunks) | 250 MB | 0 + per-query | Index ≈ 800 KB. Same configured cap. Cold areas never load. | | All 5 maps fully baked | 1.25 GB | 0 + per-query | Index ≈ 4 MB total. Same configured cap. | ## Hash choice (FNV-1a → XxHash3) The original draft used inlined FNV-1a-64. Switched to XxHash3 via `HashUtility`: - ~30× faster on this workload (~30 GB/s SIMD vs ~2 GB/s byte-by-byte). Boot-time only, so absolute saving is microseconds — the real wins are elsewhere. - Stronger collision resistance and distribution. - Drops ~25 lines of inlined hash code; matches the rest of the codebase's hashing pattern. - Hash is stable as long as `HashUtility`'s `xxHash3Seed` constant doesn't change (already marked `// DO NOT CHANGE THIS NUMBER`). --- Projects/Server/Utilities/HashUtility.cs | 16 + .../Engines/Pathing/StepCacheFileTests.cs | 213 +++++++++ .../Engines/Pathing/Cache/StepCache.cs | 131 +++++- .../Engines/Pathing/Cache/StepCacheFile.cs | 408 ++++++++++++++++++ .../Engines/Pathing/Cache/StepChunk.cs | 12 + .../Engines/Pathing/PathCacheCommands.cs | 85 +++- 6 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs create mode 100644 Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs diff --git a/Projects/Server/Utilities/HashUtility.cs b/Projects/Server/Utilities/HashUtility.cs index 695ac58db..2655cff5d 100644 --- a/Projects/Server/Utilities/HashUtility.cs +++ b/Projects/Server/Utilities/HashUtility.cs @@ -50,6 +50,22 @@ public static class HashUtility return result; } + public static ulong ComputeHash64(ReadOnlySpan bytes) + { + if (bytes.Length == 0) + { + return 0; + } + + var hasher = _xxHash3 ??= new XxHash3(unchecked((long)xxHash3Seed)); + hasher.Append(bytes); + + var result = hasher.GetCurrentHashAsUInt64(); + hasher.Reset(); + + return result; + } + public static uint ComputeHash32(ReadOnlySpan str) { if (str == ReadOnlySpan.Empty) diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs new file mode 100644 index 000000000..0917d26bf --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -0,0 +1,213 @@ +using System.IO; +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepCacheFileTests +{ + /// + /// Round-trip a populated cache through a .swb file under lazy loading: build chunks, + /// save, clear (closes lazy readers + drops residents), open as lazy backing store, + /// then re-query and verify chunks come back from the file (not the runtime baker). + /// + [Fact] + public void RoundTrip_LazyLoad_PreservesChunkMaskAndZ() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + + // Populate three distinct chunks by querying different sectors. + var sourceQueries = new[] { (1500, 1600), (1516, 1600), (1500, 1616) }; + foreach (var (x, y) in sourceQueries) + { + cache.TryGetMask(map, x, y, sourceZ: 10); + } + + Assert.Equal(3, cache.GetStats().ResidentChunks); + Assert.Equal(3L, cache.GetStats().BuildsTotal); + + // Snapshot the answers we expect to recover after round-trip. + var expected = new StepMask[sourceQueries.Length]; + for (var i = 0; i < sourceQueries.Length; i++) + { + var (x, y) = sourceQueries[i]; + expected[i] = cache.TryGetMask(map, x, y, sourceZ: 10); + } + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb"); + try + { + var written = cache.SaveToFile(path, map.MapID); + Assert.Equal(3, written); + + cache.Clear(); + Assert.Equal(0, cache.GetStats().ResidentChunks); + + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + Assert.Equal(1, cache.OpenLazyReaderCount); + + // Lazy: opening doesn't materialize chunks, so the resident set stays empty + // until we query. + Assert.Equal(0, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().BuildsTotal); + + // Sanity: the lazy reader's index covers each chunk we're about to query. + foreach (var (x, y) in sourceQueries) + { + Assert.True(cache.LazyReaderHasChunk(map.MapID, x >> 4, y >> 4), + $"lazy reader missing chunk ({x >> 4},{y >> 4})"); + } + + for (var i = 0; i < sourceQueries.Length; i++) + { + var (x, y) = sourceQueries[i]; + var lookup = cache.TryGetMask(map, x, y, sourceZ: 10); + Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind); + Assert.Equal(expected[i].WalkMask, lookup.WalkMask); + Assert.Equal(expected[i].WetMask, lookup.WetMask); + Assert.Equal(expected[i].WalkZ_N, lookup.WalkZ_N); + Assert.Equal(expected[i].WalkZ_NW, lookup.WalkZ_NW); + } + + Assert.Equal(0L, cache.GetStats().BuildsTotal); + Assert.Equal(3, cache.GetStats().ResidentChunks); + } + finally + { + cache.Clear(); // Releases the FileStream so the delete below succeeds on Windows. + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public void TryOpenLazyReader_MissingFile_ReturnsFalse() + { + var cache = StepCache.Instance; + cache.Clear(); + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-missing-{System.Guid.NewGuid():N}.swb"); + Assert.False(cache.TryOpenLazyReader(path, mapId: 1)); + Assert.Equal(0, cache.OpenLazyReaderCount); + } + + [Fact] + public void TryOpenLazyReader_BadMagic_ReturnsFalse() + { + var cache = StepCache.Instance; + cache.Clear(); + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-badmagic-{System.Guid.NewGuid():N}.swb"); + try + { + File.WriteAllBytes(path, new byte[] + { + 0xDE, 0xAD, 0xBE, 0xEF, + 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + }); + Assert.False(cache.TryOpenLazyReader(path, mapId: 1)); + Assert.Equal(0, cache.OpenLazyReaderCount); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public void TryOpenLazyReader_TileDataHashMismatch_ReturnsFalse() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{System.Guid.NewGuid():N}.swb"); + try + { + cache.SaveToFile(path, map.MapID); + + // Corrupt the TileDataHash field at byte offset 12 (Magic[4] + Version[4] + MapId[4]). + var bytes = File.ReadAllBytes(path); + for (var i = 12; i < 20; i++) + { + bytes[i] ^= 0xFF; + } + File.WriteAllBytes(path, bytes); + + cache.Clear(); + Assert.False(cache.TryOpenLazyReader(path, map.MapID)); + Assert.Equal(0, cache.OpenLazyReaderCount); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + /// + /// The lazy reader holds a FileStream open. Saving 100 chunks then opening them all + /// must NOT materialize any of them in the resident set — that's the whole point of + /// lazy loading on RAM-constrained shards. A query for one specific chunk pulls only + /// that one chunk into memory. + /// + [Fact] + public void LazyReader_DoesNotMaterializeUntilQueried() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + + // Populate a handful of chunks. + var coords = new (int, int)[] + { + (1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600) + }; + foreach (var (x, y) in coords) + { + cache.TryGetMask(map, x, y, sourceZ: 10); + } + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{System.Guid.NewGuid():N}.swb"); + try + { + Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID)); + + cache.Clear(); + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + + // Open succeeded — but no chunks resident yet. + Assert.Equal(0, cache.GetStats().ResidentChunks); + + // Query one specific chunk: only that chunk lands in the resident set. + cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); + Assert.Equal(1, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().BuildsTotal); // resolved from file, not baker + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 060e57336..a5f00d397 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -67,6 +67,7 @@ public sealed class StepCache { _chunks.Clear(); _keysList.Clear(); + CloseLazyReaders(); _hits = 0; _missesNotBuilt = 0; _missesDirtyRebuild = 0; @@ -77,6 +78,111 @@ public sealed class StepCache _buildsTotal = 0; } + // Per-map open .swb readers, populated by TryOpenLazyReader at startup. Chunks are + // fetched on demand from the file when ResolveMissingChunk fires; resident memory + // stays bounded by MaxResidentChunks regardless of file size. + private readonly Dictionary _lazyReaders = new(); + + /// + /// Persist all resident chunks for to a .swb file. Returns + /// the number of chunks written. The file embeds a TileData fingerprint so a stale + /// file (built before a client patch) can be detected and rejected at open time. + /// + public int SaveToFile(string path, int mapId) + { + var matching = 0; + foreach (var key in _keysList) + { + DecodeKey(key, out var keyMapId, out _, out _); + if (keyMapId == mapId) + { + matching++; + } + } + + var enumerator = _keysList.GetEnumerator(); + StepCacheFile.Write(path, (uint)mapId, (uint)matching, EmitChunk); + enumerator.Dispose(); + return matching; + + bool EmitChunk(out int chunkX, out int chunkY, out StepChunk chunk) + { + while (enumerator.MoveNext()) + { + var key = enumerator.Current; + DecodeKey(key, out var emittedMapId, out chunkX, out chunkY); + if (emittedMapId == mapId) + { + chunk = _chunks[key]; + return true; + } + } + chunkX = chunkY = 0; + chunk = null!; + return false; + } + } + + /// + /// Open a .swb file as a lazy backing store for . Reads only + /// header + chunk-offset index (~16 bytes per chunk); individual records are fetched + /// on demand by . Returns false on missing file, + /// magic / version mismatch, or TileData hash mismatch (stale bake). + /// + public bool TryOpenLazyReader(string path, int mapId) + { + var reader = StepCacheFile.OpenForLazy(path); + if (reader == null) + { + return false; + } + + if (reader.MapId != (uint)mapId) + { + logger.Warning( + "StepCache: {Path} declares mapId {FileMapId} but caller requested {RequestedMapId}; ignoring", + path, reader.MapId, mapId + ); + reader.Dispose(); + return false; + } + + if (_lazyReaders.TryGetValue(mapId, out var existing)) + { + existing.Dispose(); + } + _lazyReaders[mapId] = reader; + + logger.Information( + "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", + path, reader.IndexedChunkCount, mapId + ); + return true; + } + + /// + /// Number of .swb readers currently open. Mostly for tests / telemetry. + /// + public int OpenLazyReaderCount => _lazyReaders.Count; + + /// Test-only diagnostic: does the lazy reader for hold an offset for (chunkX, chunkY)? + internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) => + _lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY); + + /// + /// Closes all open lazy readers, releasing their underlying file streams. Called from + /// so test cleanup can delete .swb files (they're held with + /// FileShare.Read | FileShare.Delete, so this is mostly belt-and-suspenders). + /// + public void CloseLazyReaders() + { + foreach (var reader in _lazyReaders.Values) + { + reader.Dispose(); + } + _lazyReaders.Clear(); + } + /// /// Probabilistic LRU sample size — picks SampleSize random resident chunks per /// eviction and evicts the oldest of that sample. Approximates true LRU at a tiny @@ -230,10 +336,29 @@ public sealed class StepCache } /// - /// Chunk-miss resolution: build the chunk via the runtime baker. + /// Chunk-miss resolution: try the lazy file reader for this map first; if there's no + /// file or no record at this (chunkX, chunkY), fall back to the runtime baker. The + /// file path validates each loaded chunk's MultisVersion against the live sector — a + /// stale snapshot triggers a rebuild rather than serving a wrong answer. /// - private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) => - BuildChunk(map, chunkX, chunkY); + private StepChunk ResolveMissingChunk(Map map, int chunkX, int chunkY) + { + if (_lazyReaders.TryGetValue(map.MapID, out var reader)) + { + var loaded = reader.TryReadChunk(chunkX, chunkY); + if (loaded != null) + { + var sector = map.GetRealSector(chunkX, chunkY); + if (loaded.BuiltMultisVersion == sector.MultisVersion) + { + return loaded; + } + // Snapshot is stale (multis added/removed since the bake). Fall through + // to the runtime baker; a future SaveToFile will overwrite the entry. + } + } + return BuildChunk(map, chunkX, chunkY); + } private StepChunk BuildChunk(Map map, int chunkX, int chunkY) { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs new file mode 100644 index 000000000..610573ff4 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -0,0 +1,408 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +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 (little-endian, BufferWriter / BufferReader convention): +/// +/// Header (48 bytes): +/// u32 Magic = 0x42575300 ('SWB\0') +/// u32 Version = current FormatVersion +/// u32 MapId +/// u64 TileDataHash XxHash3 over LandTable + ItemTable flags (via +/// HashUtility); rejects a load when client tile data has +/// shifted under us. +/// u64 BakeTimestamp DateTime.UtcNow.Ticks at write time (informational). +/// u32 ChunkCount +/// u64 IndexOffset File position where the chunk index begins. +/// +/// Per chunk (ChunkCount times, variable size): +/// u16 ChunkX +/// u16 ChunkY +/// u32 BuiltMultisVersion +/// u8 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] +/// +/// Index trailer (16 × ChunkCount bytes): +/// For each chunk: { u64 chunkKey, u64 fileOffset } +/// +/// Per-chunk size: ~5,393 bytes (no multi-Z) or ~5,425 bytes (with multi-Z). +/// LRU bookkeeping (LastTouchedTicks) is intentionally not persisted. +/// +internal static class StepCacheFile +{ + public const uint Magic = 0x42575300; // 'SWB\0' + public const uint FormatVersion = 1; + + private const int HeaderSize = + sizeof(uint) // Magic + + sizeof(uint) // Version + + sizeof(uint) // MapId + + sizeof(ulong) // TileDataHash + + sizeof(ulong) // BakeTimestamp + + sizeof(uint) // ChunkCount + + sizeof(ulong); // IndexOffset + + private const int IndexEntryBytes = sizeof(ulong) + sizeof(ulong); // chunkKey + offset + + private const int BytesPerChunkBase = + sizeof(ushort) + sizeof(ushort) + sizeof(uint) + sizeof(byte) + + StepChunk.CellsPerChunk // WalkMask + + StepChunk.CellsPerChunk // WetMask + + StepChunk.CellsPerChunk // SourceZ + + 8 * StepChunk.CellsPerChunk // WalkZ[8] + + 8 * StepChunk.CellsPerChunk; // SwimZ[8] + + private const int BytesPerMultiZ = 32; + + /// + /// Byte offset of the IndexOffset u64 within the header + /// (Magic+Version+MapId+TileDataHash+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); + + /// + /// Computes a stable hash of the loaded TileData flags via XxHash3 (HashUtility). + /// Bake files carry this hash so a load can refuse to populate the cache when tile + /// data has shifted (client patch, mismatched version) — mismatched data would + /// silently skew walkability answers. Hash is stable as long as HashUtility's seed + /// constant doesn't change. + /// + public static ulong ComputeTileDataHash() + { + var landTable = TileData.LandTable; + var itemTable = TileData.ItemTable; + + // Project just the Flags ulong from each entry into a contiguous byte buffer. + // The struct itself contains a string Name (reference) whose object identity isn't + // stable across runs, so we can't MemoryMarshal.Cast the whole struct. + var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)]; + var span = bytes.AsSpan(); + + for (var i = 0; i < landTable.Length; i++) + { + BinaryPrimitives.WriteUInt64LittleEndian(span[(i * 8)..], (ulong)landTable[i].Flags); + } + var itemOffset = landTable.Length * 8; + for (var i = 0; i < itemTable.Length; i++) + { + BinaryPrimitives.WriteUInt64LittleEndian(span[(itemOffset + i * 8)..], (ulong)itemTable[i].Flags); + } + + return HashUtility.ComputeHash64(bytes); + } + + /// + /// 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) ?? "."); + + var capacity = HeaderSize + + (BytesPerChunkBase + BytesPerMultiZ) * (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(ComputeTileDataHash()); + w.Write((ulong)DateTime.UtcNow.Ticks); + w.Write(chunkCount); + w.Write(0UL); // IndexOffset placeholder, patched after chunks + + var indexEntries = new (ulong key, ulong offset)[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); + indexEntries[written] = (PackChunkKey(chunkX, chunkY), chunkOffset); + written++; + } + + if (written != chunkCount) + { + throw new InvalidOperationException( + $"StepCacheFile.Write: declared {chunkCount} chunks but enumerator yielded {written}" + ); + } + + var indexOffset = (ulong)w.Position; + for (var i = 0u; i < chunkCount; i++) + { + w.Write(indexEntries[i].key); + w.Write(indexEntries[i].offset); + } + + // Patch IndexOffset directly into the buffer (BufferWriter has no Seek). + BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(IndexOffsetFieldPosition, 8), indexOffset); + + var totalBytes = (int)w.Position; + File.WriteAllBytes(path, buffer.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 TileDataHash 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 != FormatVersion) + { + stream.Dispose(); + return null; + } + + var mapId = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[8..]); + var tileDataHash = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[12..]); + var bakeTimestamp = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[20..]); + var chunkCount = BinaryPrimitives.ReadUInt32LittleEndian(headerBuf[28..]); + var indexOffset = BinaryPrimitives.ReadUInt64LittleEndian(headerBuf[32..]); + + if (tileDataHash != ComputeTileDataHash()) + { + stream.Dispose(); + return null; + } + + // Read the chunk-offset index in one shot. + var indexBytes = (int)chunkCount * IndexEntryBytes; + var indexBuf = new byte[indexBytes]; + stream.Position = (long)indexOffset; + if (stream.Read(indexBuf, 0, indexBytes) != indexBytes) + { + stream.Dispose(); + return null; + } + + var offsets = new Dictionary((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; + } + + return new LazyReader(stream, mapId, tileDataHash, bakeTimestamp, chunkCount, offsets); + } + catch + { + stream?.Dispose(); + return null; + } + } + + private static ulong PackChunkKey(int chunkX, int chunkY) => ((ulong)(uint)chunkX << 32) | (uint)chunkY; + + private static void WriteChunk(BufferWriter w, int chunkX, int chunkY, StepChunk chunk) + { + w.Write((ushort)chunkX); + w.Write((ushort)chunkY); + w.Write((uint)chunk.BuiltMultisVersion); + + var multiZ = chunk.GetMultiZCellsForSerialization(); + w.Write((byte)(multiZ != null ? 1 : 0)); + + w.Write(chunk.WalkMask); + w.Write(chunk.WetMask); + WriteSBytes(w, chunk.SourceZ); + + WriteSBytes(w, chunk.WalkZN); + WriteSBytes(w, chunk.WalkZNE); + WriteSBytes(w, chunk.WalkZE); + WriteSBytes(w, chunk.WalkZSE); + WriteSBytes(w, chunk.WalkZS); + WriteSBytes(w, chunk.WalkZSW); + WriteSBytes(w, chunk.WalkZW); + WriteSBytes(w, chunk.WalkZNW); + + WriteSBytes(w, chunk.SwimZN); + WriteSBytes(w, chunk.SwimZNE); + WriteSBytes(w, chunk.SwimZE); + WriteSBytes(w, chunk.SwimZSE); + WriteSBytes(w, chunk.SwimZS); + WriteSBytes(w, chunk.SwimZSW); + WriteSBytes(w, chunk.SwimZW); + WriteSBytes(w, chunk.SwimZNW); + + if (multiZ != null) + { + w.Write(multiZ); + } + } + + 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 hasMultiZ = r.ReadByte() != 0; + + var chunk = new StepChunk { BuiltMultisVersion = multisVersion }; + + r.Read(chunk.WalkMask); + r.Read(chunk.WetMask); + ReadSBytes(r, chunk.SourceZ); + + ReadSBytes(r, chunk.WalkZN); + ReadSBytes(r, chunk.WalkZNE); + ReadSBytes(r, chunk.WalkZE); + ReadSBytes(r, chunk.WalkZSE); + ReadSBytes(r, chunk.WalkZS); + ReadSBytes(r, chunk.WalkZSW); + ReadSBytes(r, chunk.WalkZW); + ReadSBytes(r, chunk.WalkZNW); + + ReadSBytes(r, chunk.SwimZN); + ReadSBytes(r, chunk.SwimZNE); + ReadSBytes(r, chunk.SwimZE); + ReadSBytes(r, chunk.SwimZSE); + ReadSBytes(r, chunk.SwimZS); + ReadSBytes(r, chunk.SwimZSW); + ReadSBytes(r, chunk.SwimZW); + ReadSBytes(r, chunk.SwimZNW); + + if (hasMultiZ) + { + var multiZ = new byte[BytesPerMultiZ]; + r.Read(multiZ); + chunk.RestoreMultiZCellsFromSerialization(multiZ); + } + + 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; + + public uint MapId { get; } + public ulong TileDataHash { 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)); + + internal LazyReader( + FileStream stream, uint mapId, ulong tileDataHash, ulong bakeTimestamp, + uint chunkCount, Dictionary offsets + ) + { + _stream = stream; + MapId = mapId; + TileDataHash = tileDataHash; + BakeTimestamp = bakeTimestamp; + ChunkCount = chunkCount; + _offsets = offsets; + _buffer = new byte[BytesPerChunkBase + BytesPerMultiZ]; + } + + /// + /// 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. + /// + public StepChunk TryReadChunk(int chunkX, int chunkY) + { + if (_stream == null) + { + return null; + } + + var key = PackChunkKey(chunkX, chunkY); + if (!_offsets.TryGetValue(key, out var offset)) + { + 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); + } + + public void Dispose() + { + _stream?.Dispose(); + _stream = null; + _buffer = null; + } + } +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs index c49ab5606..cbaee7bba 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepChunk.cs @@ -55,4 +55,16 @@ internal sealed class StepChunk _multiZCells ??= new byte[32]; _multiZCells[cellIndex >> 3] |= (byte)(1 << (cellIndex & 7)); } + + /// + /// 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; + + /// + /// 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; } diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index fe61c5c4f..1daae3266 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -1,3 +1,4 @@ +using System.IO; using Server.Engines.Pathing.Cache; namespace Server.Engines.Pathing; @@ -5,14 +6,49 @@ namespace Server.Engines.Pathing; /// /// Admin commands for inspecting and operating the pathfinding step cache. /// [PathCacheStats — current resident-chunk count + hit/miss/eviction telemetry. -/// [PathCacheClear — drop all cached chunks and zero counters. +/// [PathCacheClear — drop all cached chunks, close lazy readers, zero counters. +/// [PathCacheSave — persist resident chunks per map to Data/Pathfinding/<mapId>.swb. +/// [PathCacheLoad — open those files as lazy backing stores. Also runs at startup. /// public static class PathCacheCommands { + private static string PathFor(int mapId) => + Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", $"{mapId}.swb"); + public static void Configure() { + // Resident-chunk cap is shard-tunable. Default 8192 ≈ 40 MB; small shards may + // want lower, large shards (or full-map bakes) may want higher. Setting is + // written back to server.cfg on first boot for discoverability. + StepCache.Instance.MaxResidentChunks = ServerConfiguration.GetOrUpdateSetting( + "pathfinding.maxResidentChunks", + 8192 + ); + CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); + CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); + CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); + AutoLoadAtStartup(); + } + + /// + /// Open Data/Pathfinding/<mapId>.swb as a lazy backing store for every map. + /// Reads only the header + chunk-offset index up front (~16 bytes per chunk); + /// individual chunk records are fetched on demand when the cache asks for them. + /// RAM stays bounded by MaxResidentChunks regardless of file size. + /// + private static void AutoLoadAtStartup() + { + for (var i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + if (map == null || map == Map.Internal) + { + continue; + } + StepCache.Instance.TryOpenLazyReader(PathFor(map.MapID), map.MapID); + } } [Usage("PathCacheStats")] @@ -37,4 +73,51 @@ public static class PathCacheCommands StepCache.Instance.Clear(); e.Mobile.SendMessage($"StepCache cleared: {residentBefore} chunks dropped, counters reset."); } + + [Usage("PathCacheSave")] + [Description("Persists resident StepCache chunks for every loaded map to Data/Pathfinding/.swb.")] + private static void OnPathCacheSave(CommandEventArgs e) + { + var totalChunks = 0; + var totalMaps = 0; + for (var i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + if (map == null || map == Map.Internal) + { + continue; + } + var path = PathFor(map.MapID); + var written = StepCache.Instance.SaveToFile(path, map.MapID); + if (written > 0) + { + totalChunks += written; + totalMaps++; + e.Mobile.SendMessage($" map {map.MapID}: {written} chunks → {path}"); + } + } + e.Mobile.SendMessage($"StepCache saved: {totalChunks} chunks across {totalMaps} map(s)."); + } + + [Usage("PathCacheLoad")] + [Description("Opens Data/Pathfinding/.swb as a lazy backing store for every map. Chunks are fetched on demand, so RAM stays bounded by the LRU cap regardless of file size.")] + private static void OnPathCacheLoad(CommandEventArgs e) + { + var openedMaps = 0; + for (var i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + if (map == null || map == Map.Internal) + { + continue; + } + if (StepCache.Instance.TryOpenLazyReader(PathFor(map.MapID), map.MapID)) + { + openedMaps++; + } + } + e.Mobile.SendMessage( + $"StepCache: opened {openedMaps} map(s) for lazy loading (total readers open: {StepCache.Instance.OpenLazyReaderCount})." + ); + } }