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:
parent
eec37edd67
commit
9d26a44a28
11 changed files with 281 additions and 107 deletions
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <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"/>).
|
||||
/// 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 <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>
|
||||
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<byte> 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;
|
||||
|
||||
/// <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>
|
||||
/// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded)
|
||||
/// → index trailer → patches the header IndexOffset. <paramref name="chunkCount"/> must
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue