fix(pathfinding): stop opening every .swb twice at boot (#2548)

## Problem

Every map's `.swb` step cache was opened, indexed and logged **twice** on boot.

`MovementPath.Configure()` explicitly called `PathCacheCommands.Configure()` and `CacheEvictionTimer.Configure()`. Both are types exposing a public static parameterless `Configure()`, which `AssemblyHandler.Invoke("Configure")` already discovers and calls once each (`AssemblyHandler.cs:157`). So `PathCacheCommands.Configure()` ran twice, and `AutoLoadAtStartup()` with it. `PathCacheCommands.Configure()` called `PathfindRecorder.Configure()` the same way.

`TryOpenLazyReader` disposes the prior reader before replacing it, so there was no handle leak — but the header and full chunk index of each `.swb` were read twice (~48 MB of files across six facets). The expensive `.mul` hashing was already memoized, so it was not doubled.

## Fix

Consolidate the cache lifecycle into `Initialize`:

- `Configure()` keeps only settings and command registration.
- `Initialize()` opens the readers once, then prebakes only maps that still lack one.
- The post-bake reopen is per-map instead of a blanket `AutoLoadAtStartup()` — on a partial bake (some valid `.swb`, one stale) that would close and reopen the readers already open, a second double-open on a different path.

`Initialize` is the correct phase. `Configure` runs before `TileMatrixLoader.LoadTileMatrix()` and `World.Load()` (`Main.cs:458/460/463/465`), so opening a `.swb` there forced the lazy `Map.Tiles` property — the fingerprint hashes the map files — and built every `TileMatrix` ahead of the loader that owns it, possibly before `TileMatrix.Configure()` settled `Pre6000ClientSupport`. Both sit at the default call priority and the phase sort is unstable. Moving pathfinding out leaves nothing in `Configure` that touches `Map.Tiles`, closing that hazard; the other 22 `.Tiles` users in UOContent are all runtime paths.

Multis stay out of the bake by design — houses and boats are player data that moves, handled by the multi-aware path at query time.

## Logging

The per-map `StepCache: opened ... chunks indexed` line drops to `Debug`. Opening is the expected case; `BakeMap` already logs a rebuild at `Information`, and `Initialize` still emits `PathBake: pre-bake complete (N map(s) written)`.

## Verification

- `dotnet build Projects/UOContent` — 0 errors, 0 warnings.
- `dotnet test --filter FullyQualifiedName~Pathfinding` — **123 passed, 0 failed**.

Boot logs should now show one `opened` line per map at `Debug`, none at `Information`.
This commit is contained in:
Kamron Batman 2026-07-25 13:07:20 -07:00 committed by GitHub
parent c39454137e
commit 9c11ccdb80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 28 additions and 21 deletions

View file

@ -285,7 +285,8 @@ public sealed class StepCache
} }
_lazyReaders[mapId] = reader; _lazyReaders[mapId] = reader;
logger.Information( // Debug: opening is the expected case. A rebuild is the interesting one, and BakeMap logs it.
logger.Debug(
"StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}",
path, reader.IndexedChunkCount, mapId path, reader.IndexedChunkCount, mapId
); );

View file

@ -60,8 +60,6 @@ public sealed class MovementPath
public static void Configure() public static void Configure()
{ {
CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand);
CacheEvictionTimer.Configure();
PathCacheCommands.Configure();
} }
[Usage("Path")] [Usage("Path")]

View file

@ -39,15 +39,12 @@ public static class PathCacheCommands
8192 8192
); );
PathfindRecorder.Configure();
CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats);
CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear);
CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake); CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake);
CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave);
CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad);
CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord); CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord);
AutoLoadAtStartup();
} }
/// <summary> /// <summary>
@ -80,18 +77,23 @@ public static class PathCacheCommands
} }
/// <summary> /// <summary>
/// Bakes any map whose <c>.swb</c> is missing or stale, when <see cref="PrebakeSetting"/> is /// Opens the existing <c>.swb</c> files, then — when <see cref="PrebakeSetting"/> is set —
/// set. Runs in the Initialize phase, once the tile matrix and world are loaded. An up-to-date /// bakes any that are missing or stale. An up-to-date cache makes the bake a no-op, so the cost
/// cache makes it a no-op, so the cost lands only on a first boot or after a client or map /// lands only on a first boot or after a client or map update moves the fingerprint.
/// update moves the fingerprint.
/// ///
/// A map is judged up-to-date by whether it has an open reader. <see cref="AutoLoadAtStartup"/> /// Both halves run here rather than in Configure: the fingerprint hashes the map files, so
/// already ran in the earlier Configure phase and only opens a reader for a .swb whose /// opening a .swb forces the lazy <see cref="Map.Tiles"/> property. In Configure that would
/// fingerprint validates, so an open reader is proof of a good bake — no need to fingerprint /// build every TileMatrix ahead of <c>TileMatrixLoader</c>, possibly before
/// the map a second time here. /// <c>TileMatrix.Configure()</c> settles <c>Pre6000ClientSupport</c> — both sit at the default
/// call priority and the phase sort is unstable.
///
/// A reader only opens once its fingerprint validates, so an open reader is proof of a good
/// bake and the map is skipped without fingerprinting it again.
/// </summary> /// </summary>
public static void Initialize() public static void Initialize()
{ {
AutoLoadAtStartup();
if (!ServerConfiguration.GetSetting(PrebakeSetting, false)) if (!ServerConfiguration.GetSetting(PrebakeSetting, false))
{ {
return; return;
@ -119,13 +121,15 @@ public static class PathCacheCommands
); );
StepCache.Instance.BakeMap(map.MapID, path); StepCache.Instance.BakeMap(map.MapID, path);
StepCache.Instance.ClearResidentChunks(); StepCache.Instance.ClearResidentChunks();
// Just this map: a blanket AutoLoadAtStartup() would reopen every reader already open.
StepCache.Instance.TryOpenLazyReader(path, map.MapID);
baked++; baked++;
} }
if (baked > 0) if (baked > 0)
{ {
logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked); logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked);
AutoLoadAtStartup(); // reopen what we just wrote
} }
} }

View file

@ -139,13 +139,17 @@ several-minutes cost. Wiring:
after assemblies load (so content can register prompts) but **before Serilog starts**, so the after assemblies load (so content can register prompts) but **before Serilog starts**, so the
console prompt is not interleaved with the async console sink. Any class can participate by console prompt is not interleaved with the async console sink. Any class can participate by
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, - Both the reader open and the bake run in `Invoke("Initialize")`, after the tile matrix and world
which the bake walks). load. Neither belongs in `Configure`, which runs *before* both: the fingerprint hashes the map
files, so opening a `.swb` there would force the lazy `Map.Tiles` property and build every
`TileMatrix` ahead of `TileMatrixLoader` — possibly before `TileMatrix.Configure()` settles
`Pre6000ClientSupport`, since both sit at the default call priority and the phase sort is
unstable. `PathCacheCommands.Configure` is limited to settings and command registration.
- Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at - 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 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 `TileData` tables, which the server patches at runtime). `Initialize` opens a reader for every
up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is up-to-date file, then skips any map where `StepCache.HasLazyReader` is already true, so the
already true, so the fingerprint is computed once per boot, not twice. fingerprint is computed once per boot. Each newly baked map reopens only itself.
## Configuration levers ## Configuration levers
@ -154,7 +158,7 @@ several-minutes cost. Wiring:
| `pathfinding.enable` | `PathFollower.Configure` | `true` | Master switch for `PathFollower` pathfinding. Off → greedy/auto-turn only, no A* at all. | | `pathfinding.enable` | `PathFollower.Configure` | `true` | Master switch for `PathFollower` pathfinding. Off → greedy/auto-turn only, no A* at all. |
| `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. | | `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. |
| `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 5121024 ≈ 2.55 MB) on small shards. | | `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 5121024 ≈ 2.55 MB) on small shards. |
| `pathfinding.maxSearchNodes` | `PathCacheCommands.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | | `pathfinding.maxSearchNodes` | `BitmapAStarAlgorithm.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. |
| `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. | | `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. |
| `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). | | `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). |