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;
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}",
path, reader.IndexedChunkCount, mapId
);

View file

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

View file

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