Skip to content

Architecture

Pixnop edited this page Jul 13, 2026 · 2 revisions

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.

Layers

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
Loading

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.

One live server per process

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).

The game thread and the pump

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:

  1. Redirect APP_CONTEXT_BASE_DIRECTORY to the Vintage Story install and hook AssemblyResolve (install, install/Lib, install/Mods), so asset and library probing resolve correctly even though the host process is not the game executable.
  2. Stage the mod(s) under test plus AtlasBridge.dll into a scratch mods folder (see Mod Staging).
  3. Boot ServerMain with isDedicatedServer: false (no socket is ever opened) against a scratch data path, call PreLaunch() then Launch().
  4. 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.

GameThreadScheduler

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.

AtlasBridge and the AppDomain-slot rendezvous

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.

World lifecycle

  • 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.

Crash and watchdog error paths

  • Scenario timeout: an off-thread Watchdog races the scenario task against TimeoutMs. If the watchdog wins, ScenarioTimeoutException is thrown and HostRegistry marks 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.MarkDead marks 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 public stopped flag 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 with ServerCrashedException pointing at the server's own logs (server-main.log in 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's Stop.
  • Mod load failure: fixture startup fails with AtlasSetupException, carrying the mod loader's own report, instead of a downstream timeout.
  • Concurrent host request: HostRegistry.GetOrCreateAsync throws AtlasSetupException if 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
Loading

Further reading

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.

Clone this wiki locally