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
|
|
@ -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<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>
|
||||
/// Walk every chunk in <paramref name="mapId"/>, populate the resident set, then
|
||||
/// save to <paramref name="path"/>. Returns the number of chunks written.
|
||||
|
|
@ -367,6 +355,15 @@ public sealed class StepCache
|
|||
/// </summary>
|
||||
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>
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue