fix: Fixes pathfinding prebake and pathfinding multi-fallthrough. (#2478)

## Summary

Fixes the pathfinding step-cache (`.swb`) prebake so it bakes **once** and skips when a valid cache already exists, instead of re-baking on every boot. The root cause was the staleness fingerprint hashing mutable in-memory tile data rather than the on-disk files. This PR makes the fingerprint a pure function of the client data files and separates dynamic multis (houses/boats) from the static cache.

> This branch builds on the `ConfigurePrompts` first-boot-prompt unification (commit `5df8d0bd`, also included here) — that commit accounts for the `ServerConfiguration.cs` and `dev-docs/server-lifecycle.md` changes in the diff.

## The bug

With `pathfinding.prebakeMaps` set, the cache re-baked on **every** boot. The `.swb` staleness fingerprint hashed the live `TileData.LandTable`/`ItemTable` flags, which the server patches at runtime (`ItemFixes`, `LOSBlocker`, `PotionKeg`, `CTF`) at nondeterministic lifecycle points (Initialize-phase methods share a priority; static ctors fire lazily). So a fingerprint stamped at runtime (`[PathBake`) never matched the one recomputed during startup `Initialize()`, and the cache rebaked every time.

## Changes

**1. Fingerprint the files, not the in-memory tables** (`fix`)
Hash `tiledata.mul` (cached, computed once) plus the per-map `.mul`/`.uop` files — never the runtime-mutated `TileData` tables. The fingerprint is now lifecycle-stable. Existing `.swb` files rebake once after deploy, then stay stable.

**2. Compute the fingerprint once per boot** (`refactor`)
`Configure()`'s `AutoLoadAtStartup()` already opens and fingerprint-validates a reader for every up-to-date `.swb`. `Initialize()` now skips baking any map that already has an open reader (`StepCache.HasLazyReader`) instead of recomputing the fingerprint a second time.

**3. Bake static-only; route multis to the live path** (`refactor`)
Multis (houses/boats) are dynamic, so they're no longer baked into the static chunk cache — they were tagged with `BuiltMultisVersion`, a non-persisted session counter, which made persisting them unsafe (false matches / wasted re-bakes).
- Chunks bake land + `statics.mul` only.
- At query time, any cell whose sector (or its 1-cell halo) contains a multi routes to `Fallthrough_Multi` → the existing live, multi-aware `CheckMovement` path. The halo prevents a cell proposing a walkable edge into a neighbouring wall; interior (multi-free) cells pay one sector lookup.
- Adds `Sector.HasMultis` (one engine accessor); `.swb` format → v9 (rejects old multi-baked files); new `Fallthrough_Multi` telemetry.
- Behaviour-preserving: multis use the same live path the engine used before the cache existed.

**4. Comment polish** (`style`) — no behaviour change.

## Testing

All green: **92** pathfinding (incl. a new fingerprint-stability test and a multi-halo fallthrough test), **423** UOContent, **708** Server.

## Follow-ups (not in this PR)

- **Background bake worker** — make `[PathBake` and the boot prebake non-blocking (game thread serves tile reads to an off-thread worker).
- **Per-multi MCL cache** — cache walkability in each multi's own frame (keyed by multiID, movement-invariant) so houses/boats get a fast path instead of the live fallback.
- **House-pathfinding equivalence tests** — the one area not yet covered by a dedicated automated test; multi pathing is currently correct by delegation to the live path.
This commit is contained in:
Kamron Batman 2026-06-08 11:59:24 -07:00 committed by GitHub
parent eec37edd67
commit 9d26a44a28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 281 additions and 107 deletions

View file

@ -1875,6 +1875,12 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
internal List<BaseMulti> Multis => _multis ?? m_DefaultMultiList; internal List<BaseMulti> 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; public int MultisVersion => _multisVersion;
internal ref readonly ValueLinkList<Mobile> Mobiles => ref _mobiles; internal ref readonly ValueLinkList<Mobile> Mobiles => ref _mobiles;

View file

@ -114,6 +114,49 @@ public class StepCacheFileTests
Assert.Equal(0, cache.OpenLazyReaderCount); Assert.Equal(0, cache.OpenLazyReaderCount);
} }
/// <summary>
/// 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.
/// </summary>
[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<sbyte> 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] [Fact]
public void TryOpenLazyReader_BadMagic_ReturnsFalse() public void TryOpenLazyReader_BadMagic_ReturnsFalse()
{ {

View file

@ -0,0 +1,43 @@
using Server.Engines.Pathing.Cache;
using Xunit;
namespace Server.Tests.Pathfinding;
[Collection("Sequential Pathfinding Tests")]
public class StepCacheFingerprintTests
{
/// <summary>
/// Regression: the cache fingerprint must hash the on-disk tiledata.mul, NOT the mutable
/// in-memory <see cref="TileData"/> 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.
/// </summary>
[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;
}
}
}

View file

@ -1,4 +1,7 @@
using System.Collections.Generic;
using System.Reflection;
using Server.Engines.Pathing.Cache; using Server.Engines.Pathing.Cache;
using Server.Items;
using Xunit; using Xunit;
namespace Server.Tests.Pathfinding; namespace Server.Tests.Pathfinding;
@ -205,42 +208,49 @@ public class StepCacheLifecycleTests
} }
[Fact] [Fact]
public void MultisVersion_Bump_TriggersDirtyRebuild() public void MultiCoveredCell_AndHalo_RouteToFallthrough()
{ {
var cache = StepCache.Instance; var cache = StepCache.Instance;
cache.Clear(); cache.Clear();
cache.MissPromotionThreshold = 2; cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately
var map = Map.Maps[1]; var map = Map.Maps[1];
var sector = map.GetRealSector(1500 >> 4, 1600 >> 4);
// First touch defers (Fallthrough_NotBuilt); second touch promotes and builds. // A cell far from any multi serves from the static cache.
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit);
Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind);
// Bump _multisVersion via reflection. // Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a
var versionField = typeof(Map.Sector).GetField( // single-entry list is enough to mark the sector as multi-bearing — the fallthrough
"_multisVersion", // decision never dereferences the multi, so no real BaseMulti instance is needed.
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance const int mx = 2000;
); const int my = 2000;
Assert.NotNull(versionField); var sx = mx >> 4;
var current = (int)versionField.GetValue(sector); var sy = my >> 4;
versionField.SetValue(sector, current + 1); 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<BaseMulti> { null });
// Third query: detects version mismatch, rebuilds. // Cell inside the multi sector → routed to the live path.
Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind); Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind);
var stats = cache.GetStats(); // Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo
Assert.Equal(1L, stats.MissesDirtyRebuild); // (its mask would otherwise propose an edge into the multi sector).
Assert.Equal(2L, stats.BuildsTotal); 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. // Two tiles out → interior of the multi-free sector, unaffected.
// Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild), Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind);
// and the first was a Fallthrough_NotBuilt (no build, slow-path signal).
Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits); Assert.True(cache.GetStats().FallthroughMulti >= 2);
Assert.Equal(1L, stats.FallthroughNotBuilt); }
Assert.Equal(0L, stats.FallthroughMultiZ); finally
Assert.Equal(0L, stats.FallthroughOffMap); {
multisField.SetValue(sector, original);
}
} }
[Fact] [Fact]

View file

@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache;
/// <summary> /// <summary>
/// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive /// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive
/// the slow-path fallthrough decision in callers. Ordering is load-bearing: /// 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).
/// </summary> /// </summary>
public enum CacheHitKind : byte public enum CacheHitKind : byte
{ {
@ -14,4 +14,5 @@ public enum CacheHitKind : byte
Fallthrough_OffMap = 4, // out of bounds Fallthrough_OffMap = 4, // out of bounds
Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge 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_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
} }

View file

@ -13,6 +13,7 @@ public readonly struct CacheStats(
long fallthroughOffMap, long fallthroughOffMap,
long fallthroughSourceZMismatch, long fallthroughSourceZMismatch,
long fallthroughNotBuilt, long fallthroughNotBuilt,
long fallthroughMulti,
long evictionsByLruCap, long evictionsByLruCap,
long buildsTotal long buildsTotal
) )
@ -25,6 +26,7 @@ public readonly struct CacheStats(
public readonly long FallthroughOffMap = fallthroughOffMap; public readonly long FallthroughOffMap = fallthroughOffMap;
public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch;
public readonly long FallthroughNotBuilt = fallthroughNotBuilt; public readonly long FallthroughNotBuilt = fallthroughNotBuilt;
public readonly long FallthroughMulti = fallthroughMulti;
public readonly long EvictionsByLruCap = evictionsByLruCap; public readonly long EvictionsByLruCap = evictionsByLruCap;
public readonly long BuildsTotal = buildsTotal; public readonly long BuildsTotal = buildsTotal;
} }

View file

@ -54,6 +54,7 @@ public sealed class StepCache
private long _fallthroughOffMap; private long _fallthroughOffMap;
private long _fallthroughSourceZMismatch; private long _fallthroughSourceZMismatch;
private long _fallthroughNotBuilt; private long _fallthroughNotBuilt;
private long _fallthroughMulti;
private long _evictionsByLruCap; private long _evictionsByLruCap;
private long _buildsTotal; private long _buildsTotal;
@ -119,6 +120,7 @@ public sealed class StepCache
fallthroughOffMap: _fallthroughOffMap, fallthroughOffMap: _fallthroughOffMap,
fallthroughSourceZMismatch: _fallthroughSourceZMismatch, fallthroughSourceZMismatch: _fallthroughSourceZMismatch,
fallthroughNotBuilt: _fallthroughNotBuilt, fallthroughNotBuilt: _fallthroughNotBuilt,
fallthroughMulti: _fallthroughMulti,
evictionsByLruCap: _evictionsByLruCap, evictionsByLruCap: _evictionsByLruCap,
buildsTotal: _buildsTotal buildsTotal: _buildsTotal
); );
@ -153,6 +155,7 @@ public sealed class StepCache
_fallthroughOffMap = 0; _fallthroughOffMap = 0;
_fallthroughSourceZMismatch = 0; _fallthroughSourceZMismatch = 0;
_fallthroughNotBuilt = 0; _fallthroughNotBuilt = 0;
_fallthroughMulti = 0;
_evictionsByLruCap = 0; _evictionsByLruCap = 0;
_buildsTotal = 0; _buildsTotal = 0;
} }
@ -162,21 +165,6 @@ public sealed class StepCache
// stays bounded by MaxResidentChunks regardless of file size. // stays bounded by MaxResidentChunks regardless of file size.
private readonly Dictionary<int, StepCacheFile.LazyReader> _lazyReaders = new(); private readonly Dictionary<int, StepCacheFile.LazyReader> _lazyReaders = new();
/// <summary>
/// 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.
/// </summary>
public static ulong ComputeLiveFingerprint(int mapId) => StepCacheFile.ComputeFingerprint(mapId);
/// <summary>
/// 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.
/// </summary>
public static bool TryReadFingerprintFromFile(string path, out ulong fingerprint) =>
StepCacheFile.TryReadFingerprint(path, out fingerprint);
/// <summary> /// <summary>
/// Walk every chunk in <paramref name="mapId"/>, populate the resident set, then /// Walk every chunk in <paramref name="mapId"/>, populate the resident set, then
/// save to <paramref name="path"/>. Returns the number of chunks written. /// save to <paramref name="path"/>. Returns the number of chunks written.
@ -367,6 +355,15 @@ public sealed class StepCache
/// </summary> /// </summary>
public int OpenLazyReaderCount => _lazyReaders.Count; public int OpenLazyReaderCount => _lazyReaders.Count;
/// <summary>
/// True if a valid .swb reader is open for <paramref name="mapId"/>. A reader only opens via
/// <see cref="TryOpenLazyReader"/> after <see cref="StepCacheFile.OpenForLazy"/> 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.
/// </summary>
public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId);
/// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset for (chunkX, chunkY)?</summary> /// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset for (chunkX, chunkY)?</summary>
internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) => internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) =>
_lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY); _lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY);
@ -450,6 +447,41 @@ public sealed class StepCache
private const int ChunkSize = 16; private const int ChunkSize = 16;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind. /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind.
/// Inspect <see cref="StepMask.IsHit"/> to decide whether to use the result or fall /// Inspect <see cref="StepMask.IsHit"/> 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 chunkX = x >> 4;
var chunkY = y >> 4; var chunkY = y >> 4;
var key = EncodeKey(map.MapID, chunkX, chunkY); 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; chunk.LastTouchedTicks = Core.TickCount;
var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4)); var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4));
@ -690,13 +725,9 @@ public sealed class StepCache
{ {
return null; return null;
} }
var loaded = reader.TryReadChunk(chunkX, chunkY); // Static-only chunks are valid once the file fingerprint matched at open time; multi-covered
if (loaded == null) // cells fall through before reaching here. Returns null when the file lacks this chunk.
{ return reader.TryReadChunk(chunkX, chunkY);
return null;
}
var sector = map.GetRealSector(chunkX, chunkY);
return loaded.BuiltMultisVersion == sector.MultisVersion ? loaded : null;
} }
/// <summary> /// <summary>
@ -805,8 +836,6 @@ public sealed class StepCache
private StepChunk BuildChunk(Map map, int chunkX, int chunkY) private StepChunk BuildChunk(Map map, int chunkX, int chunkY)
{ {
var chunk = new StepChunk(); var chunk = new StepChunk();
var sector = map.GetRealSector(chunkX, chunkY);
chunk.BuiltMultisVersion = sector.MultisVersion;
var baseX = chunkX << 4; var baseX = chunkX << 4;
var baseY = chunkY << 4; var baseY = chunkY << 4;

View file

@ -20,7 +20,7 @@ namespace Server.Engines.Pathing.Cache;
/// ///
/// Header (40 bytes): /// Header (40 bytes):
/// u32 Magic = 0x42575300 ('SWB\0') /// u32 Magic = 0x42575300 ('SWB\0')
/// u32 Version = current FormatVersion (8) /// u32 Version = current FormatVersion (9)
/// u32 MapId /// u32 MapId
/// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the /// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the
/// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul. /// 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): /// Record body (after inflate — the v6 layout):
/// u16 ChunkX /// u16 ChunkX
/// u16 ChunkY /// u16 ChunkY
/// u32 BuiltMultisVersion /// u32 BuiltMultisVersion (reserved since v9 — always 0; chunks are static-only)
/// u8 Kind 0 = Full; 2 = Uniform /// u8 Kind 0 = Full; 2 = Uniform
/// // Uniform (Kind == 2): ~28-byte record — all 256 cells share these single values: /// // 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) /// 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 internal static class StepCacheFile
{ {
public const uint Magic = 0x42575300; // 'SWB\0' 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;
/// <summary> /// <summary>
/// Lowest format version this binary can load. Files below it are treated as missing /// 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 /// (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. /// fully regenerable, so a format bump just forces a one-time re-bake of stale files.
/// </summary> /// </summary>
public const uint MinSupportedVersion = 8; public const uint MinSupportedVersion = 9;
// Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved. // Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved.
private const byte KindFull = 0; private const byte KindFull = 0;
@ -177,38 +183,30 @@ internal static class StepCacheFile
} }
/// <summary> /// <summary>
/// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the /// Combined XxHash3 fingerprint over (1) the on-disk <c>tiledata.mul</c> file and (2) the
/// per-map .mul / .uop file contents (via <see cref="TileMatrix.MapFilesFingerprint"/>). /// per-map .mul / .uop file contents (via <see cref="TileMatrix.MapFilesFingerprint"/>).
/// Bake files carry this hash so a load can refuse to populate the cache when EITHER /// Bake files carry this hash so a load can refuse to populate the cache when EITHER the
/// tile flags shifted (client patch) OR the map data was rewritten (CentredSharp / /// tile data shifted (client patch) OR the map data was rewritten (CentredSharp / UOFiddler
/// UOFiddler edit). The .mul format has no built-in CRC; this is the only way to /// edit). The .mul format has no built-in CRC; this is the only way to detect those mutations.
/// detect those mutations. ///
/// IMPORTANT: hash the FILES, never the in-memory <see cref="TileData.LandTable"/> /
/// <see cref="TileData.ItemTable"/>. 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.
/// </summary> /// </summary>
public static ulong ComputeFingerprint(int mapId) public static ulong ComputeFingerprint(int mapId)
{ {
var hasher = HashUtility.CreateXxHash3(); var hasher = HashUtility.CreateXxHash3();
// TileData flag tables — same projection trick as before: just the Flags ulong // (1) tiledata.mul — hashed once, cached. The authoritative source for tile flags/heights.
// from each entry, written little-endian into a contiguous byte buffer. The Span<byte> tileDataBytes = stackalloc byte[sizeof(ulong)];
// struct itself has a string Name (reference) whose object identity isn't BinaryPrimitives.WriteUInt64LittleEndian(tileDataBytes, TileDataFileFingerprint());
// stable across runs, so MemoryMarshal.Cast over the whole struct would drift. hasher.Append(tileDataBytes);
var landTable = TileData.LandTable;
var itemTable = TileData.ItemTable;
var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)];
var span = bytes.AsSpan();
for (var i = 0; i < landTable.Length; i++) // (2) Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already
{
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
// streamed them through XxHash3 once at construction; mix the result in. // streamed them through XxHash3 once at construction; mix the result in.
var map = Map.Maps[mapId]; var map = Map.Maps[mapId];
if (map != null && map != Map.Internal && map.Tiles != null) if (map != null && map != Map.Internal && map.Tiles != null)
@ -221,6 +219,35 @@ internal static class StepCacheFile
return hasher.GetCurrentHashAsUInt64(); return hasher.GetCurrentHashAsUInt64();
} }
private static ulong _tileDataFileFingerprint;
private static bool _tileDataFileFingerprintComputed;
/// <summary>
/// XxHash3 over the raw <c>tiledata.mul</c> bytes, computed once and cached — the file never
/// changes during a run. Mirrors <see cref="TileMatrix.MapFilesFingerprint"/> 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).
/// </summary>
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;
}
/// <summary> /// <summary>
/// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded) /// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded)
/// → index trailer → patches the header IndexOffset. <paramref name="chunkCount"/> must /// → index trailer → patches the header IndexOffset. <paramref name="chunkCount"/> must

View file

@ -5,8 +5,11 @@ namespace Server.Engines.Pathing.Cache;
/// <summary> /// <summary>
/// Computes static-only walkability for a single cell — the per-cell, per-direction /// 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 /// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT
/// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases. /// multis). Mirrors <see cref="MovementImpl"/>.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 <see cref="StepCache"/>'s
/// multi-halo fallthrough rather than being baked into the static chunk cache.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only /// 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; 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) if (count >= zs.Length)
{ {
@ -141,7 +144,7 @@ public static class StepProbe
cand[count++] = landCenter; 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) if (count >= cand.Length)
{ {
@ -270,7 +273,7 @@ public static class StepProbe
} }
// Otherwise scan statics for a wet surface. // 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]; var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
if (data.Wet) if (data.Wet)
@ -312,7 +315,7 @@ public static class StepProbe
isSet = true; 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 id = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var calcTop = tile.Z + id.CalcHeight; var calcTop = tile.Z + id.CalcHeight;
@ -377,7 +380,7 @@ public static class StepProbe
int testTop; 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 itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue];
var notWater = !itemData.Wet; var notWater = !itemData.Wet;

View file

@ -83,9 +83,14 @@ public static class PathCacheCommands
/// <summary> /// <summary>
/// Auto-invoked by <c>AssemblyHandler.Invoke("Initialize")</c> after the tile matrix and /// Auto-invoked by <c>AssemblyHandler.Invoke("Initialize")</c> after the tile matrix and
/// world are loaded. When <see cref="PrebakeSetting"/> is set, bakes any map whose /// world are loaded. When <see cref="PrebakeSetting"/> is set, bakes any map whose
/// <c>.swb</c> is missing or stale (its tile-data fingerprint no longer matches), so the /// <c>.swb</c> is missing or stale, so the first pathfind on each region is already warm. A
/// first pathfind on each region is already warm. A fresh cache makes this a no-op, so only /// fresh cache makes this a no-op, so only first boot — or a client/map update that changes
/// first boot — or a client/map update that changes the fingerprint — pays the cost. /// the fingerprint — pays the cost.
///
/// Validity is decided by <see cref="StepCache.HasLazyReader"/>: <see cref="Configure"/> runs
/// <see cref="AutoLoadAtStartup"/> in the earlier Configure phase, opening (and fingerprint-
/// validating) a reader for every up-to-date <c>.swb</c>. So a map with an open reader is
/// already good and we skip it — no need to recompute the fingerprint a second time here.
/// </summary> /// </summary>
public static void Initialize() public static void Initialize()
{ {
@ -103,13 +108,13 @@ public static class PathCacheCommands
continue; continue;
} }
var path = PathFor(map.MapID); if (StepCache.Instance.HasLazyReader(map.MapID))
var live = StepCache.ComputeLiveFingerprint(map.MapID);
if (StepCache.TryReadFingerprintFromFile(path, out var onDisk) && onDisk == live)
{ {
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( logger.Information(
"PathBake: pre-baking map {MapId} (pathfinding.prebakeMaps) — this can take several minutes...", "PathBake: pre-baking map {MapId} (pathfinding.prebakeMaps) — this can take several minutes...",
map.MapID map.MapID
@ -156,6 +161,7 @@ public static class PathCacheCommands
from.SendMessage($" builds={stats.BuildsTotal} hits={stats.Hits}"); from.SendMessage($" builds={stats.BuildsTotal} hits={stats.Hits}");
from.SendMessage($" miss(notBuilt)={stats.MissesNotBuilt} miss(dirty)={stats.MissesDirtyRebuild}"); 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(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}"); from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}");
} }

View file

@ -141,7 +141,11 @@ several-minutes cost. Wiring:
defining `public static void ConfigurePrompts()` and self-gating on first-boot state. 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, - The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load,
which the bake walks). 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 ## Configuration levers