-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
Atlas has three layers: an engine that owns the embedded server and the game thread, an xUnit adapter that discovers and schedules scenarios, and a bridge mod that hands the live game API from inside the server to the engine.
This page condenses the design spec and feasibility spike that shaped Atlas; both live
in-repo under docs/specs/ and docs/feasibility-spike.md for the full engineering
rationale.
flowchart TB
Runner["xUnit test runner"]
Adapter["Atlas.XUnit (adapter)<br/>discovers [AtlasScenario], builds fixtures,<br/>posts scenario delegates onto the game thread"]
Engine["Atlas / Internal (engine)<br/>owns the game thread, the tick pump,<br/>mod staging, the scheduler, the watchdog"]
Bridge["AtlasBridge (bridge)<br/>ModSystem loaded by the embedded server,<br/>captures ICoreServerAPI, hands it to the<br/>engine, drives Ticks/Until"]
Server["Vintage Story ServerMain<br/>(embedded, isDedicatedServer: false)"]
Runner --> Adapter --> Engine --> Bridge --> Server
Atlas (the engine) does not depend on xUnit: bootstrap, mod staging, the scheduler and the
bridge rendezvous are runner-agnostic, so a future CLI (tracked as issue #3) can reuse them
unchanged. Test authors only ever reference Atlas.XUnit.
Vintage Story's embedding path relies on process-wide statics (ServerMain.Logger,
GamePaths.DataPath, RuntimeEnv.ServerMainThreadId, the global TyronThreadPool.Inst).
The feasibility spike proved sequential reuse is safe: many server lifecycles, one after
another, in the same process. Running two servers concurrently in one process is not
supported and is not attempted. This is why parallel scenario execution is out of scope for
v1 and requires multi-process orchestration instead (tracked as issue #1).
Vintage Story expects a single thread to drive the server loop: whichever thread calls
Launch() becomes RuntimeEnv.ServerMainThreadId, and after that every Process() call
must come from that same thread. Atlas dedicates one thread per server instance to this role:
- Redirect
APP_CONTEXT_BASE_DIRECTORYto the Vintage Story install and hookAssemblyResolve(install, install/Lib, install/Mods), so asset and library probing resolve correctly even though the host process is not the game executable. - Stage the mod(s) under test plus
AtlasBridge.dllinto a scratch mods folder (see Mod Staging). - Boot
ServerMainwithisDedicatedServer: false(no socket is ever opened) against a scratch data path, callPreLaunch()thenLaunch(). - Pump: call
Process(), drain the scheduler's queue, repeat, until shutdown or a fatal error.
Because the game thread also drains the scheduler queue between Process() calls, scenario
code that runs on that queue has full, race-free access to the game API without any
additional locking.
A custom SynchronizationContext installed on the game thread. The xUnit adapter posts each
scenario delegate into its queue and awaits completion; every await continuation inside a
scenario body returns to that same queue. Scenario code therefore never leaves the game
thread. World.Ticks(n) and World.Until(...) are continuations resumed by the bridge's
tick listener, not by the .NET thread pool. The scheduler itself is engine-agnostic and is
unit-tested against a fake pump, with no Vintage Story install required.
This is also why scenario bodies must never call ConfigureAwait(false): doing so detaches
the continuation from the game thread's queue and breaks the thread-pinning guarantee. See
Writing Scenarios for the full rule.
A minimal server-side ModSystem shipped inside Atlas and staged as a dll next to the
mod-under-test. It captures ICoreServerAPI in StartServerSide and hands it to the engine
through a static rendezvous.
This works because of assembly identity: the game loads mod dlls into the default
AssemblyLoadContext from the staged path, and Atlas pre-loads the same file from the same
path before booting the server. Both sides therefore observe the same assembly identity and
the same static state, so a value the bridge writes from inside the server's mod-loading pass
is visible to the engine reading the same static from its own side, with no IPC, no sockets,
and no serialization involved. ServerMain.api is internal, so this bridge is the only way to
reach ICoreServerAPI from outside the server; it doubles as the natural place to host
anything Atlas needs to inject into the running server. AtlasBridge also registers the tick
listener that feeds Ticks/Until.
- One xUnit class fixture = one server, one fresh world, one scratch data path per test class. Scenarios in a class run sequentially against that world (the adapter disables xUnit's test parallelization for the assembly).
-
[AtlasScenario(FreshWorld = true)]tears the class world down and reboots it before that scenario runs, for scenarios that pollute world state heavily. - Cross-class isolation is total: fresh server, fresh world, fresh scratch path every time.
-
Scenario timeout: an off-thread
Watchdograces the scenario task againstTimeoutMs. If the watchdog wins,ScenarioTimeoutExceptionis thrown andHostRegistrymarks the class host dead, since the game thread may still be running the abandoned scenario with no safe way to reclaim it. -
Server crash mid-scenario: the exception is captured on the game thread and rethrown
inside the owning xUnit test;
HostRegistry.MarkDeadmarks the class host dead so remaining scenarios in the class fail fast with a clear message instead of cascading into opaque timeouts. -
Engine-initiated shutdown: when the engine stops itself (its reaction to an unhandled
exception in one of its server threads),
ServerMain.Process()becomes a silent sleep loop. Since 0.9.0 the pump watches the engine's publicstoppedflag and records a stop Atlas did not request as a host crash: pending tick waiters are faulted with the real cause and the scenario fails promptly withServerCrashedExceptionpointing at the server's own logs (server-main.login the scratch data path, where the engine keeps the stop reason and the failing thread's stack), instead of spinning until the watchdog turns the crash into a timeout. Atlas's own stop paths are unaffected: they cancel the pump before ever calling the engine'sStop. -
Mod load failure: fixture startup fails with
AtlasSetupException, carrying the mod loader's own report, instead of a downstream timeout. -
Concurrent host request:
HostRegistry.GetOrCreateAsyncthrowsAtlasSetupExceptionif a second host is requested while one is already in flight, which should only happen if[assembly: CollectionBehavior(DisableTestParallelization = true)]is missing. - Diagnostics: server logs land in the scenario's scratch data path.
flowchart TD
A["Scenario running"] -->|watchdog TimeoutMs elapses| B["ScenarioTimeoutException<br/>HostRegistry.MarkDead"]
A -->|server crashes on game thread| C["ServerCrashedException<br/>HostRegistry.MarkDead"]
A -->|mod load fails at fixture startup| D["AtlasSetupException<br/>(ModLoader report)"]
B --> E["Later scenarios in the class<br/>fail fast, host not reused"]
C --> E
The full decisions table, error handling model, and out-of-scope list live in
docs/specs/2026-07-02-atlas-design.md in the repository. The empirical groundwork
(bootstrap gotchas, determinism evidence, prior art) lives in docs/feasibility-spike.md.