diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index 655e13788..e51a848d2 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -1875,6 +1875,12 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa internal List Multis => _multis ?? m_DefaultMultiList; + // Cheap public "does this sector currently contain any multi" check. MultisVersion can't + // answer this (it counts enter AND leave, so a place-then-remove leaves it non-zero with + // zero multis). Used by the pathfinding step cache to route multi-covered cells to the + // live movement path instead of the static-only chunk cache. + public bool HasMultis => _multis is { Count: > 0 }; + public int MultisVersion => _multisVersion; internal ref readonly ValueLinkList Mobiles => ref _mobiles; diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index ea3468759..0d865d08d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -114,6 +114,49 @@ public class StepCacheFileTests Assert.Equal(0, cache.OpenLazyReaderCount); } + /// + /// HasLazyReader is the boot prebake's skip predicate (PathCacheCommands.Initialize): a map + /// with an open, fingerprint-valid reader needs no bake. Lock the open/clear contract. + /// + [Fact] + public void HasLazyReader_TracksOpenAndClear() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + Assert.False(cache.HasLazyReader(map.MapID)); + + // Build + save a chunk so there's a valid .swb to open. + cache.MissPromotionThreshold = 1; + Span surfZ = stackalloc sbyte[16]; + Assert.True(StepProbe.ComputeStandableSurfaceZs(map, 1500, 1600, surfZ) > 0); + cache.TryGetMask(map, 1500, 1600, surfZ[0]); + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-haslazy-{Guid.NewGuid():N}.swb"); + try + { + Assert.True(cache.SaveToFile(path, map.MapID) > 0); + cache.Clear(); + Assert.False(cache.HasLazyReader(map.MapID)); + + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + Assert.True(cache.HasLazyReader(map.MapID)); // open → true + + cache.Clear(); + Assert.False(cache.HasLazyReader(map.MapID)); // clear closes the reader → false + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + [Fact] public void TryOpenLazyReader_BadMagic_ReturnsFalse() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs new file mode 100644 index 000000000..dee698e59 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -0,0 +1,43 @@ +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepCacheFingerprintTests +{ + /// + /// Regression: the cache fingerprint must hash the on-disk tiledata.mul, NOT the mutable + /// in-memory tables. The server patches item flags/heights at runtime + /// (ItemFixes, LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a + /// fingerprint taken over the live tables depended on WHEN it was computed: a runtime + /// [PathBake stamped one value into the .swb and the next startup's Initialize() recomputed a + /// different one, marking the bake stale and re-baking on every boot. Hashing the file makes + /// the fingerprint a pure function of the client's tile data, immune to those mutations. + /// + [Fact] + public void Fingerprint_IgnoresRuntimeTileDataMutation() + { + const int mapId = 1; // Trammel — loaded by the test bootstrap. + + var before = StepCacheFile.ComputeFingerprint(mapId); + + const int probeId = 0x2A0; + var original = TileData.ItemTable[probeId].Flags; + try + { + // Mutate an in-memory item flag the way ItemFixes/CTF/etc. do at runtime. XOR + // guarantees the value actually changes regardless of the current flag state. + TileData.ItemTable[probeId].Flags ^= TileFlag.NoShoot; + Assert.NotEqual(original, TileData.ItemTable[probeId].Flags); // sanity: mutation took + + var after = StepCacheFile.ComputeFingerprint(mapId); + + Assert.Equal(before, after); + } + finally + { + TileData.ItemTable[probeId].Flags = original; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 72531ba97..244c51534 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -1,4 +1,7 @@ +using System.Collections.Generic; +using System.Reflection; using Server.Engines.Pathing.Cache; +using Server.Items; using Xunit; namespace Server.Tests.Pathfinding; @@ -205,42 +208,49 @@ public class StepCacheLifecycleTests } [Fact] - public void MultisVersion_Bump_TriggersDirtyRebuild() + public void MultiCoveredCell_AndHalo_RouteToFallthrough() { var cache = StepCache.Instance; cache.Clear(); - cache.MissPromotionThreshold = 2; + cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately var map = Map.Maps[1]; - var sector = map.GetRealSector(1500 >> 4, 1600 >> 4); - // First touch defers (Fallthrough_NotBuilt); second touch promotes and builds. - Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); - Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); + // A cell far from any multi serves from the static cache. + Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit); - // Bump _multisVersion via reflection. - var versionField = typeof(Map.Sector).GetField( - "_multisVersion", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - Assert.NotNull(versionField); - var current = (int)versionField.GetValue(sector); - versionField.SetValue(sector, current + 1); + // Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a + // single-entry list is enough to mark the sector as multi-bearing — the fallthrough + // decision never dereferences the multi, so no real BaseMulti instance is needed. + const int mx = 2000; + const int my = 2000; + var sx = mx >> 4; + var sy = my >> 4; + var sector = map.GetRealSector(sx, sy); + var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(multisField); + var original = multisField.GetValue(sector); + try + { + multisField.SetValue(sector, new List { null }); - // Third query: detects version mismatch, rebuilds. - Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind); + // Cell inside the multi sector → routed to the live path. + Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind); - var stats = cache.GetStats(); - Assert.Equal(1L, stats.MissesDirtyRebuild); - Assert.Equal(2L, stats.BuildsTotal); + // Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo + // (its mask would otherwise propose an edge into the multi sector). + var boundaryX = sx * 16 - 1; // last tile of sector sx-1; halo (x+1) reaches into sx + Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, boundaryX, my, 0).HitKind); - // Mutual-exclusivity invariant: hits + miss-builds + dirty-rebuilds = served-result count. - // Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild), - // and the first was a Fallthrough_NotBuilt (no build, slow-path signal). - Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits); - Assert.Equal(1L, stats.FallthroughNotBuilt); - Assert.Equal(0L, stats.FallthroughMultiZ); - Assert.Equal(0L, stats.FallthroughOffMap); + // Two tiles out → interior of the multi-free sector, unaffected. + Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind); + + Assert.True(cache.GetStats().FallthroughMulti >= 2); + } + finally + { + multisField.SetValue(sector, original); + } } [Fact] diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs index 6418ca1c1..ad4d8b3e7 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs @@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache; /// /// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive /// the slow-path fallthrough decision in callers. Ordering is load-bearing: -/// values 0-2 are hits, values 3-6 are fallthroughs (see StepMask.IsHit). +/// values 0-2 are hits, values 3+ are fallthroughs (see StepMask.IsHit). /// public enum CacheHitKind : byte { @@ -14,4 +14,5 @@ public enum CacheHitKind : byte Fallthrough_OffMap = 4, // out of bounds Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge Fallthrough_NotBuilt = 6, // first-touch miss without lazy file hit; build deferred until second touch + Fallthrough_Multi = 7, // a multi (house/boat) covers this cell or its halo; use the live path } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs index 10c78014d..4b3cfc974 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs @@ -13,6 +13,7 @@ public readonly struct CacheStats( long fallthroughOffMap, long fallthroughSourceZMismatch, long fallthroughNotBuilt, + long fallthroughMulti, long evictionsByLruCap, long buildsTotal ) @@ -25,6 +26,7 @@ public readonly struct CacheStats( public readonly long FallthroughOffMap = fallthroughOffMap; public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; public readonly long FallthroughNotBuilt = fallthroughNotBuilt; + public readonly long FallthroughMulti = fallthroughMulti; public readonly long EvictionsByLruCap = evictionsByLruCap; public readonly long BuildsTotal = buildsTotal; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 055e19173..62a1363ac 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -54,6 +54,7 @@ public sealed class StepCache private long _fallthroughOffMap; private long _fallthroughSourceZMismatch; private long _fallthroughNotBuilt; + private long _fallthroughMulti; private long _evictionsByLruCap; private long _buildsTotal; @@ -119,6 +120,7 @@ public sealed class StepCache fallthroughOffMap: _fallthroughOffMap, fallthroughSourceZMismatch: _fallthroughSourceZMismatch, fallthroughNotBuilt: _fallthroughNotBuilt, + fallthroughMulti: _fallthroughMulti, evictionsByLruCap: _evictionsByLruCap, buildsTotal: _buildsTotal ); @@ -153,6 +155,7 @@ public sealed class StepCache _fallthroughOffMap = 0; _fallthroughSourceZMismatch = 0; _fallthroughNotBuilt = 0; + _fallthroughMulti = 0; _evictionsByLruCap = 0; _buildsTotal = 0; } @@ -162,21 +165,6 @@ public sealed class StepCache // stays bounded by MaxResidentChunks regardless of file size. private readonly Dictionary _lazyReaders = new(); - /// - /// Combined XxHash3 fingerprint of the running server's TileData flag tables AND - /// the per-map .mul / .uop file contents (mapX.mul, staidxX.mul, staticsX.mul). - /// Public surface for tooling (benchmark fixtures, bake utilities) that wants to - /// detect a stale .swb file without round-tripping through the lazy-open path. - /// - public static ulong ComputeLiveFingerprint(int mapId) => StepCacheFile.ComputeFingerprint(mapId); - - /// - /// Peek at a .swb file's stored fingerprint field without parsing the rest of the - /// header. Returns false on missing file, bad magic, or wrong version. - /// - public static bool TryReadFingerprintFromFile(string path, out ulong fingerprint) => - StepCacheFile.TryReadFingerprint(path, out fingerprint); - /// /// Walk every chunk in , populate the resident set, then /// save to . Returns the number of chunks written. @@ -367,6 +355,15 @@ public sealed class StepCache /// public int OpenLazyReaderCount => _lazyReaders.Count; + /// + /// True if a valid .swb reader is open for . A reader only opens via + /// after validates the + /// file's fingerprint against the live tile data, so "has reader" already means "present and + /// up-to-date" — the boot prebake uses this to skip baking maps that don't need it, instead of + /// recomputing the fingerprint a second time. + /// + public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId); + /// 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); @@ -450,6 +447,41 @@ public sealed class StepCache private const int ChunkSize = 16; + /// + /// True if a multi (house / boat) covers (x, y) or any of its 8 neighbours. Multi-covered + /// cells — plus the 1-cell halo, because a cell's mask encodes the edges TO its neighbours, so + /// a neighbouring wall must block those edges — are served by the live movement path, not the + /// static chunk cache. Cheap: an interior cell checks only its own sector (chunk == sector); + /// only edge/corner cells additionally check the adjacent sector(s) the halo reaches. + /// + private static bool MultiInfluence(Map map, int x, int y) + { + var sx = x >> 4; + var sy = y >> 4; + if (map.GetRealSector(sx, sy).HasMultis) + { + return true; + } + + var west = (x & 15) == 0; + var east = (x & 15) == 15; + var north = (y & 15) == 0; + var south = (y & 15) == 15; + if (!(west || east || north || south)) + { + return false; // interior cell — its whole halo is inside the (multi-free) own sector + } + + return west && map.GetRealSector(sx - 1, sy).HasMultis + || east && map.GetRealSector(sx + 1, sy).HasMultis + || north && map.GetRealSector(sx, sy - 1).HasMultis + || south && map.GetRealSector(sx, sy + 1).HasMultis + || west && north && map.GetRealSector(sx - 1, sy - 1).HasMultis + || east && north && map.GetRealSector(sx + 1, sy - 1).HasMultis + || west && south && map.GetRealSector(sx - 1, sy + 1).HasMultis + || east && south && map.GetRealSector(sx + 1, sy + 1).HasMultis; + } + /// /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind. /// Inspect to decide whether to use the result or fall @@ -483,6 +515,19 @@ public sealed class StepCache ); } + // Multis (houses, boats) are not baked into the static chunk cache (they're dynamic + // content). If a multi covers this cell or its 1-cell halo, route to the live movement + // path, which is fully multi-aware. Gated on Sector.HasMultis, so the multi-free majority + // of the map pays a single (interior) sector lookup. + if (MultiInfluence(map, x, y)) + { + _fallthroughMulti++; + return new StepMask( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + CacheHitKind.Fallthrough_Multi + ); + } + var chunkX = x >> 4; var chunkY = y >> 4; var key = EncodeKey(map.MapID, chunkX, chunkY); @@ -532,19 +577,9 @@ public sealed class StepCache ); } } - else - { - var sector = map.GetRealSector(chunkX, chunkY); - if (chunk.BuiltMultisVersion != sector.MultisVersion) - { - chunk = BuildChunk(map, chunkX, chunkY); - _chunks[key] = chunk; - hitKindResult = CacheHitKind.Miss_DirtyRebuild; - // _missesDirtyRebuild++ deferred to the outcome switch below so a - // multi-Z fallthrough on a freshly dirty-rebuilt chunk doesn't double-count. - } - } + // A resident chunk is static-only — it never goes stale from multis (multi-covered cells + // fall through to the live path above). chunk.LastTouchedTicks = Core.TickCount; var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4)); @@ -690,13 +725,9 @@ public sealed class StepCache { return null; } - var loaded = reader.TryReadChunk(chunkX, chunkY); - if (loaded == null) - { - return null; - } - var sector = map.GetRealSector(chunkX, chunkY); - return loaded.BuiltMultisVersion == sector.MultisVersion ? loaded : null; + // Static-only chunks are valid once the file fingerprint matched at open time; multi-covered + // cells fall through before reaching here. Returns null when the file lacks this chunk. + return reader.TryReadChunk(chunkX, chunkY); } /// @@ -805,8 +836,6 @@ public sealed class StepCache private StepChunk BuildChunk(Map map, int chunkX, int chunkY) { var chunk = new StepChunk(); - var sector = map.GetRealSector(chunkX, chunkY); - chunk.BuiltMultisVersion = sector.MultisVersion; var baseX = chunkX << 4; var baseY = chunkY << 4; diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index 85afd68e4..8cbc5c51f 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -20,7 +20,7 @@ namespace Server.Engines.Pathing.Cache; /// /// Header (40 bytes): /// u32 Magic = 0x42575300 ('SWB\0') -/// u32 Version = current FormatVersion (8) +/// u32 Version = current FormatVersion (9) /// u32 MapId /// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the /// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul. @@ -42,7 +42,7 @@ namespace Server.Engines.Pathing.Cache; /// Record body (after inflate — the v6 layout): /// u16 ChunkX /// u16 ChunkY -/// u32 BuiltMultisVersion +/// u32 BuiltMultisVersion (reserved since v9 — always 0; chunks are static-only) /// u8 Kind 0 = Full; 2 = Uniform /// // Uniform (Kind == 2): ~28-byte record — all 256 cells share these single values: /// byte walkMask, wetMask; sbyte sourceZ; sbyte walkZ_N..NW (8); sbyte swimZ_N..NW (8) @@ -90,14 +90,20 @@ namespace Server.Engines.Pathing.Cache; internal static class StepCacheFile { public const uint Magic = 0x42575300; // 'SWB\0' - public const uint FormatVersion = 8; + + // v9: chunks are STATIC-ONLY (land + statics.mul, no multis). v8 and earlier baked multis + // (houses/boats) into chunks, which is unsafe to persist — multis are dynamic, and the + // BuiltMultisVersion they were tagged with is a non-persisted session counter. Bumping the + // version rejects those old files so they re-bake static-only. The BuiltMultisVersion record + // field is retained as a reserved (always-0) u32 to avoid a layout change. + public const uint FormatVersion = 9; /// /// Lowest format version this binary can load. Files below it are treated as missing /// (silently rejected) and overwritten by the next SaveToFile / BakeMap. The cache is /// fully regenerable, so a format bump just forces a one-time re-bake of stale files. /// - public const uint MinSupportedVersion = 8; + public const uint MinSupportedVersion = 9; // Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved. private const byte KindFull = 0; @@ -177,38 +183,30 @@ internal static class StepCacheFile } /// - /// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the + /// Combined XxHash3 fingerprint over (1) the on-disk tiledata.mul file and (2) the /// per-map .mul / .uop file contents (via ). - /// Bake files carry this hash so a load can refuse to populate the cache when EITHER - /// tile flags shifted (client patch) OR the map data was rewritten (CentredSharp / - /// UOFiddler edit). The .mul format has no built-in CRC; this is the only way to - /// detect those mutations. + /// Bake files carry this hash so a load can refuse to populate the cache when EITHER the + /// tile data shifted (client patch) OR the map data was rewritten (CentredSharp / UOFiddler + /// edit). The .mul format has no built-in CRC; this is the only way to detect those mutations. + /// + /// IMPORTANT: hash the FILES, never the in-memory / + /// . The server patches those tables at runtime (ItemFixes, + /// LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a fingerprint over + /// the live tables varies with WHEN it is taken; the file hash is the only lifecycle-stable + /// "did the client's tile data change?" signal. Server-side tile patches are applied identically + /// every boot and intentionally do NOT invalidate the cache — change one and you must + /// [PathCacheClear or bump the format. /// public static ulong ComputeFingerprint(int mapId) { var hasher = HashUtility.CreateXxHash3(); - // TileData flag tables — same projection trick as before: just the Flags ulong - // from each entry, written little-endian into a contiguous byte buffer. The - // struct itself has a string Name (reference) whose object identity isn't - // stable across runs, so MemoryMarshal.Cast over the whole struct would drift. - var landTable = TileData.LandTable; - var itemTable = TileData.ItemTable; - var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)]; - var span = bytes.AsSpan(); + // (1) tiledata.mul — hashed once, cached. The authoritative source for tile flags/heights. + Span tileDataBytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(tileDataBytes, TileDataFileFingerprint()); + hasher.Append(tileDataBytes); - for (var i = 0; i < landTable.Length; i++) - { - BinaryPrimitives.WriteUInt64LittleEndian(span[(i * 8)..], (ulong)landTable[i].Flags); - } - var itemOffset = landTable.Length * 8; - for (var i = 0; i < itemTable.Length; i++) - { - BinaryPrimitives.WriteUInt64LittleEndian(span[(itemOffset + i * 8)..], (ulong)itemTable[i].Flags); - } - hasher.Append(bytes); - - // Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already + // (2) Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already // streamed them through XxHash3 once at construction; mix the result in. var map = Map.Maps[mapId]; if (map != null && map != Map.Internal && map.Tiles != null) @@ -221,6 +219,35 @@ internal static class StepCacheFile return hasher.GetCurrentHashAsUInt64(); } + private static ulong _tileDataFileFingerprint; + private static bool _tileDataFileFingerprintComputed; + + /// + /// XxHash3 over the raw tiledata.mul bytes, computed once and cached — the file never + /// changes during a run. Mirrors for the map + /// files. Returns 0 if the file can't be found (the server can't run without it anyway, so + /// this only matters in stripped test hosts, where 0 is a fine deterministic constant). + /// + private static ulong TileDataFileFingerprint() + { + if (_tileDataFileFingerprintComputed) + { + return _tileDataFileFingerprint; + } + + var path = Core.FindDataFile("tiledata.mul", false); + if (path != null) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var hasher = HashUtility.CreateXxHash3(); + hasher.Append(fs); + _tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64(); + } + + _tileDataFileFingerprintComputed = true; + return _tileDataFileFingerprint; + } + /// /// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded) /// → index trailer → patches the header IndexOffset. must diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index 8b6bd5b20..d42cdc91f 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -5,8 +5,11 @@ namespace Server.Engines.Pathing.Cache; /// /// Computes static-only walkability for a single cell — the per-cell, per-direction -/// "can step" mask and destination Z, based purely on land + statics + multis. Mirrors -/// .Check minus the item and mobile collision phases. +/// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT +/// multis). Mirrors .Check minus the item and mobile collision +/// phases. Multis (houses, boats) are intentionally excluded: they're dynamic content, so +/// cells they cover route to the live movement path via 's +/// multi-halo fallthrough rather than being baked into the static chunk cache. /// /// /// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only @@ -56,7 +59,7 @@ public static class StepProbe zs[count++] = landCenter; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { if (count >= zs.Length) { @@ -141,7 +144,7 @@ public static class StepProbe cand[count++] = landCenter; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { if (count >= cand.Length) { @@ -270,7 +273,7 @@ public static class StepProbe } // Otherwise scan statics for a wet surface. - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; if (data.Wet) @@ -312,7 +315,7 @@ public static class StepProbe isSet = true; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var calcTop = tile.Z + id.CalcHeight; @@ -377,7 +380,7 @@ public static class StepProbe int testTop; - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var notWater = !itemData.Wet; diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index c7ab3c203..01ec4cdee 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -83,9 +83,14 @@ public static class PathCacheCommands /// /// Auto-invoked by AssemblyHandler.Invoke("Initialize") after the tile matrix and /// world are loaded. When is set, bakes any map whose - /// .swb is missing or stale (its tile-data fingerprint no longer matches), so the - /// first pathfind on each region is already warm. A fresh cache makes this a no-op, so only - /// first boot — or a client/map update that changes the fingerprint — pays the cost. + /// .swb is missing or stale, so the first pathfind on each region is already warm. A + /// fresh cache makes this a no-op, so only first boot — or a client/map update that changes + /// the fingerprint — pays the cost. + /// + /// Validity is decided by : runs + /// in the earlier Configure phase, opening (and fingerprint- + /// validating) a reader for every up-to-date .swb. So a map with an open reader is + /// already good and we skip it — no need to recompute the fingerprint a second time here. /// public static void Initialize() { @@ -103,13 +108,13 @@ public static class PathCacheCommands continue; } - var path = PathFor(map.MapID); - var live = StepCache.ComputeLiveFingerprint(map.MapID); - if (StepCache.TryReadFingerprintFromFile(path, out var onDisk) && onDisk == live) + if (StepCache.Instance.HasLazyReader(map.MapID)) { - continue; // .swb already matches the current tile data + continue; // AutoLoadAtStartup already opened a fingerprint-valid .swb for this map } + var path = PathFor(map.MapID); + logger.Information( "PathBake: pre-baking map {MapId} (pathfinding.prebakeMaps) — this can take several minutes...", map.MapID @@ -156,6 +161,7 @@ public static class PathCacheCommands from.SendMessage($" builds={stats.BuildsTotal} hits={stats.Hits}"); from.SendMessage($" miss(notBuilt)={stats.MissesNotBuilt} miss(dirty)={stats.MissesDirtyRebuild}"); from.SendMessage($" fallthru(multiZ)={stats.FallthroughMultiZ} fallthru(offMap)={stats.FallthroughOffMap} fallthru(srcZ)={stats.FallthroughSourceZMismatch}"); + from.SendMessage($" fallthru(multi)={stats.FallthroughMulti} fallthru(notBuilt)={stats.FallthroughNotBuilt}"); from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}"); } diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index 096bcc44f..dfe3d8e09 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -141,7 +141,11 @@ several-minutes cost. Wiring: defining `public static void ConfigurePrompts()` and self-gating on first-boot state. - The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load, which the bake walks). -- Staleness uses `StepCache.ComputeLiveFingerprint` vs `StepCache.TryReadFingerprintFromFile`. +- Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at + open time (hash of `tiledata.mul` + the per-map `.mul`/`.uop` files — never the in-memory + `TileData` tables, which the server patches at runtime). `Configure` opens a reader for every + up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is + already true, so the fingerprint is computed once per boot, not twice. ## Configuration levers