## What
On **first boot** (right after map selection), offer to pre-bake the pathfinding `.swb` cache for the selected maps. This removes first-pathfind-after-boot latency and is now cheap — ~18 MB/facet after the v8 format work (the old ~565 MB is gone). The answer persists in `modernuo.json` as **`pathfinding.prebakeMaps`** (default **false**): asked exactly once, and skipped on headless/CI boots (redirected input) where operators can set the flag directly.
## How — a generic startup phase, not pathfinding hardcoded in the engine
The clean-console (pre-Serilog) prompt window is inside the engine startup, but UOContent isn't loaded until after `ServerConfiguration.Load`. So rather than coupling the engine to pathfinding, this adds a generic lifecycle phase:
- **`Main.cs`**: new `AssemblyHandler.Invoke("ConfigurePrompts")` — runs **after** `LoadAssemblies` (so content can participate) but **before** the first `logger.Information` (so console prompts aren't interleaved with the async console sink). The first log line moves below it. Any class can hook in with `public static void ConfigurePrompts()` and self-gate on first-boot state. No `ServerConfiguration` or pathfinding coupling added to the engine.
- **`PathCacheCommands.ConfigurePrompts()`**: the first-boot prompt (interactive-only, flag-absent-only); persists the answer.
- **`PathCacheCommands.Initialize()`** (`Invoke("Initialize")` phase, after the tile matrix loads — which the bake walks): when the flag is set, bakes any map whose `.swb` is **missing or stale** (tile-data fingerprint mismatch, via `StepCache.ComputeLiveFingerprint` / `TryReadFingerprintFromFile`). A fresh cache is a no-op, so only the first boot — or a post-client-update boot — pays the several-minute cost.
## Docs
Fixed the now-stale "~565 MB / ~1.5–2 GB / do not bake by default" section in `dev-docs/pathfinding.md` (it's 17.9 MB for Trammel, tens of MB for all six facets after v8), added a "First-boot pre-bake prompt" section, and added the `pathfinding.prebakeMaps` lever row.
## Verified
- `dotnet build UOContent -c Release` → 0 errors (rebased on #2474).
- Pathfinding/StepCache tests: **90/90 pass**.
- Bootstrap streamlining of the startup phases is intentionally left as a follow-up.
7.3 KiB
Server Lifecycle & Bootstrap Phases
How a ModernUO server starts, the reflection-discovered lifecycle hooks (ConfigurePrompts,
Configure, Initialize), the runtime EventSink events, and which hook to use for what.
The startup orchestration lives in Projects/Server/Main.cs (Core entry point). The named
phases are dispatched by AssemblyHandler.Invoke("<Name>"), which finds every
public static void <Name>() (parameterless) across Core.Assembly and all loaded
content assemblies and calls them — no registration required.
Startup sequence (in order)
Don't hardcode line numbers when reasoning about this — refer to the phase/method names; the ordering is what's stable.
- Console banner + setup — direct synchronous
Console.*writes (no logging yet). ServerConfiguration.Load()— reads/createsmodernuo.json. On first boot (file absent) it runs the engine's own interactive console prompts: data directories, listeners, server name, expansion + map selection. Pre-Serilog — nothing has logged yet, so the console is clean for prompts. (Load(mocked: true)skips all prompts; that's what tests use.)AssemblyHandler.LoadAssemblies(...)— loadsUOContent.dll(and friends) fromAssemblyDirectories(default./Assemblies). Note: this depends onAssemblyDirectories, notDataDirectories, so it does not need the data-dir prompt to have run.AssemblyHandler.Invoke("ConfigurePrompts")— first-boot interactive prompts contributed by any assembly (engine or content). Runs after assemblies load (so content can participate) but before the first Serilog line (so prompts aren't interleaved with the async console sink). Each handler self-gates on first-boot state.- First
logger.Information(...)— Serilog goes live. From here on, log via the logger; the console sink is async, so anything you write withConsole.*after this can interleave with log output. VerifySerialization()→Timer.Init(...).AssemblyHandler.Invoke("Configure")— the main configuration phase. World is not loaded yet (no entities), but maps are registered.TileMatrixLoader.LoadTileMatrix()→RegionJsonSerializer.LoadRegions().World.Load()— deserializes all items/mobiles; firesEventSink.WorldLoad.AssemblyHandler.Invoke("Initialize")— post-world phase. World entities and the tile matrix are available.NetState.Start()/PingServer.Start()→EventSink.InvokeServerStarted()→RunEventLoop()(the single-threaded game loop begins).
The three reflection phases — which to use
| Phase | Runs | Use it for | Don't |
|---|---|---|---|
ConfigurePrompts() |
after assemblies load, before logging | one-time first-boot interactive prompts; persist the answer to modernuo.json; self-gate so it asks once; skip when input is redirected |
log (Serilog isn't live — use Console); touch World/maps/tile data (not ready) |
Configure() |
post-logging, pre-World | command registration, reading settings (GetOrUpdateSetting), EventSink subscriptions, wiring systems |
anything needing loaded World entities or the tile matrix |
Initialize() |
post-World, post-tile-matrix | work needing a loaded world / tile data: decoration/generation, validation, pre-baking caches | first-boot prompts (too late, and it would clobber logs) |
All three are public static void <Name>(), parameterless, discovered across every loaded
assembly. Within a phase, order is controlled by [CallPriority(n)] (lower runs first;
default 50). Same-priority order is unspecified, so never rely on one class's Configure
running before another's at the same priority — use EventSink/explicit calls for ordering.
Pre-Serilog vs post-Serilog — why ConfigurePrompts exists
Logging uses an async Serilog console sink (Serilog.Sinks.Async → LogFactory). Once the
first logger.* call fires (right after the ConfigurePrompts phase), log lines are pumped to
the console from a background thread and will interleave with anything written via
Console.*. Interactive prompts therefore have to run before that point. ConfigurePrompts
is the only reflection phase that runs pre-logging — that is its entire reason to exist.
Inside it: use Console, never the logger; and guard with Console.IsInputRedirected so
headless/CI boots don't block on Console.ReadLine.
Runtime lifecycle events (EventSink)
Subscribe to these from Configure/Initialize (EventSink.<Event> += handler):
ServerStarted— after world load and listeners are up, at loop start.WorldLoad/WorldSave— around persistence (seeWorldEvents).Shutdown— during shutdown.
Recipe: add a first-boot prompt
public static void ConfigurePrompts()
{
// Ask once, and only when a human is at the console. The answer persists in modernuo.json.
if (ServerConfiguration.GetSetting("my.feature", (string)null) != null || Console.IsInputRedirected)
{
return;
}
Console.Write("Enable my feature? [y/N] ");
var yes = Console.ReadLine()?.Trim().StartsWith("y", StringComparison.OrdinalIgnoreCase) == true;
ServerConfiguration.SetSetting("my.feature", yes);
}
If acting on the answer needs a loaded world / tile data, do that in Initialize() (read the
setting there), not in ConfigurePrompts.
Canonical example — pathfinding pre-bake
Projects/UOContent/Engines/Pathing/PathCacheCommands.cs is the reference pairing:
ConfigurePrompts()— first-boot[y/N], storespathfinding.prebakeMaps.Initialize()— when set, bakes any missing/stale.swb(needs the tile matrix, so it must beInitialize, notConfigure).
Testing note
Tests do not go through Main. The test fixtures (Server.Tests/UOContent.Tests
TestServerInitializer) call a curated subset of phase methods directly with
ServerConfiguration.Load(mocked: true), so console prompts are skipped. Consequence: changes
to the startup ordering in Main.cs (including the prompt phases) are not covered by the
test suite and need first-boot runtime verification.
Planned: unify the engine's first-boot prompts into ConfigurePrompts
Today the engine's own first-boot prompts (data dirs, listeners, server name, expansion + maps)
are inline in ServerConfiguration.Load, separate from the ConfigurePrompts mechanism. They
can be unified into the same phase so there's one prompt sequence/wiring:
- Feasible because assembly loading uses
AssemblyDirectories(default./Assemblies), notDataDirectories— so assemblies can load before the data-dir prompt, letting all prompts move into the post-assemblyConfigurePromptsphase. UOClient.Load()(client-file discovery viaCore.FindDataFile) needsDataDirectories, so it must move with the data-dir prompt into the unified phase.Core.Expansionis currently assigned duringLoad; under unification it'd be set duringConfigurePrompts— verify nothing between assembly-load and that point depends on it.
This is an engine-startup restructure the test suite can't cover (see Testing note), so it needs first-boot runtime verification before merging.