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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue