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
|
|
@ -1875,6 +1875,12 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
|
||||
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;
|
||||
|
||||
internal ref readonly ValueLinkList<Mobile> Mobiles => ref _mobiles;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,49 @@ public class StepCacheFileTests
|
|||
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]
|
||||
public void TryOpenLazyReader_BadMagic_ReturnsFalse()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BaseMulti> { 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]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache;
|
|||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ namespace Server.Engines.Pathing.Cache;
|
|||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases.
|
||||
/// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -83,9 +83,14 @@ public static class PathCacheCommands
|
|||
/// <summary>
|
||||
/// 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
|
||||
/// <c>.swb</c> 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.
|
||||
/// <c>.swb</c> 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 <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>
|
||||
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}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue