Stacked on #2475 (the `ConfigurePrompts` phase). Base will switch to `main` once #2475 merges. ## What Move the engine's own first-boot prompts — data directories, listeners, server name, expansion + map selection — out of `ServerConfiguration.Load` and into **`ServerConfiguration.ConfigurePrompts()`** (`[CallPriority(0)]`), so **all** first-boot prompting (engine and content) runs through the single `AssemblyHandler.Invoke("ConfigurePrompts")` phase. `Load` now only reads/creates the config file. ## Why it's safe - **Assembly loading uses `AssemblyDirectories` (default `./Assemblies`), not `DataDirectories`** — so assemblies load fine before the now-later data-dir prompt. This is the linchpin that makes the move possible. - **`UOClient.Load()`** (client-file discovery via `Core.FindDataFile`) needs `DataDirectories`, so it moved *with* the data-dir prompt into `ConfigurePrompts`. - **`Core.Expansion`** is now assigned in `ConfigurePrompts` (every non-mocked boot). Nothing between `LoadAssemblies` and that phase reads it — type initializers run lazily on first use, not during `LoadAssemblies`. - **`[CallPriority(0)]`** keeps the engine prompts (including map selection) ahead of content prompts such as the pathfinding pre-bake (priority 50), preserving "after map selection". - `Main.cs` already invokes the phase — **no startup-ordering edit** here. ## Tests `Server.Tests` **708/708**, `UOContent.Tests` **418/418**, build clean. Fixtures are unaffected: they call `Load(true)` (now just reads config) and set expansion/data dirs directly; `ConfigurePrompts` is gated on `m_Mocked`. ## ⚠️ Needs first-boot runtime verification `Main.cs` startup ordering is **not** covered by the fixture-based suite (the fixtures bypass `Main`). Please boot once with a fresh `modernuo.json` to confirm the first-boot prompt sequence (data dirs → … → expansion/maps → pathfinding pre-bake) and that `Core.Expansion` resolves correctly. Docs updated in `dev-docs/server-lifecycle.md`.
7.6 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.
Unified: the engine's first-boot prompts run through ConfigurePrompts
The engine's own first-boot prompts (data directories, listeners, server name, expansion + map
selection) live in ServerConfiguration.ConfigurePrompts() ([CallPriority(0)]) and are
discovered by the same Invoke("ConfigurePrompts") phase as content prompts — one sequence and
one wiring for all first-boot prompting. ServerConfiguration.Load now only reads/creates the
config file. What made this safe:
- Assembly loading uses
AssemblyDirectories(default./Assemblies), notDataDirectories, so assemblies load fine before the (now-later) data-dir prompt. UOClient.Load()(client-file discovery viaCore.FindDataFile) needsDataDirectories, so it moved with the data-dir prompt intoConfigurePrompts.Core.Expansionis now assigned inConfigurePrompts(every non-mocked boot). Nothing between assembly-load and that phase reads it — type initializers run lazily on first use, not duringLoadAssemblies.[CallPriority(0)]keeps the engine prompts (including map selection) ahead of content prompts such as the pathfinding pre-bake (default priority 50), preserving "after map selection".
Since Main.cs startup ordering isn't covered by the fixture-based suite (see Testing note), this
path is validated by a first-boot runtime check rather than tests.