Skip to content

Writing Scenarios

Pixnop edited this page Jul 15, 2026 · 10 revisions

Writing Scenarios

Attribute reference

[assembly: AtlasMods(params string[] paths)]

Assembly-level. Declares the mod(s) staged for every scenario class in the assembly. Paths are resolved relative to the test assembly's own output directory, not the source tree. Accepts a folder, a .zip, or a .dll, exactly what the game's own mod loader accepts. See Mod Staging for the full resolution rules and the MSBuild ProjectReference sugar.

[assembly: CollectionBehavior(DisableTestParallelization = true)]

Standard xUnit attribute, required in every Atlas test assembly. Atlas hosts at most one live server per process; scenario classes must run sequentially. HostRegistry enforces this at runtime rather than relying on it silently: requesting a second host concurrently throws AtlasSetupException. See Troubleshooting.

[AtlasWorld] (class level)

Declares the world configuration a scenario class runs against. All properties are optional:

Property Default Meaning
Seed 424242 World seed. Identical seeds produce identical worlds.
WorldType "superflat" Type of world to create.
PlayStyle "creativebuilding" Play style for the world.
Mods [] Extra mod paths for this class, appended after the assembly-level ones.
SaveFile null Path to a prebuilt world save (.vcdbs), absolute or relative to the test assembly's directory. When set, the class boots against a copy of that save instead of generating a world, and Seed/WorldType/PlayStyle are ignored. See World fixtures below.

The defaults are deliberately fast and deterministic: superflat worldgen and a fixed seed keep boot time low and results reproducible.

[AtlasDataFiles] (assembly or class level, repeatable)

Declares files to seed into the embedded server's scratch data path before it boots, so files a mod reads during startup, most commonly api.LoadModConfig("….json") from ModConfig/ in StartServerSide, are already in place. Without seeding, that one-shot startup read returns null and the mod runs unconfigured for the whole scenario class.

Each source path (a file or a directory, resolved like mod paths: absolute or relative to the test assembly's directory) is copied into the data path before ServerMain launches. A directory's contents are copied, not the directory itself, into TargetPath; a file lands inside TargetPath under its own name. Two equivalent styles:

// Point a fixture folder at a specific data-path subfolder:
[AtlasDataFiles("fixtures/ModConfig", TargetPath = "ModConfig")]

// Or lay the fixture tree out like the data path itself and overlay it onto the root:
[AtlasDataFiles("fixtures/serverdata")]   // contains ModConfig/mymod.json, Macros/…, etc.
Property Default Meaning
SourcePaths (constructor) One or more source files or directories to copy.
TargetPath "" Directory under the data path to copy into, e.g. "ModConfig". Empty targets the data path root. Must stay inside the data path: rooted paths and .. segments that escape it fail the boot.

Assembly-level attributes apply to every scenario class; class-level attributes are copied after them, so on a file name collision the class-level seed wins. A missing source path (or a TargetPath escaping the data path) fails the boot with AtlasSetupException naming the offender. samples/SampleConfigMod plus ConfigScenarios in samples/Sample.Scenarios demonstrate the end-to-end pattern.

[AtlasScenario] (method level)

Marks an async Task method as a scenario, discovered like [Fact] and run on the embedded server's game thread.

Property Default Meaning
FreshWorld false If true, the class host is recycled (server + world reboot) before this scenario runs, instead of reusing the world shared by the rest of the class.
RollbackWorld false If true, the class host's world is rolled back to its snapshot before this scenario runs: the same clean-world slot in the lifecycle as FreshWorld, but without rebooting the server, roughly 25x faster. See The isolation trilogy below for what a rollback does and does not restore.
RestartWorld false If true, the class host is shut down gracefully (the engine's shutdown persists the world save) and a replacement host boots against that save before this scenario runs: the same world on a genuinely restarted server, for scenarios asserting on what actually persists. See The isolation trilogy below.
StrictIsolation false Rollback only: a degraded rollback fails the scenario with AtlasIsolationException instead of silently falling back to a full host recycle. Setting it without RollbackWorld is a setup error (nothing else can degrade).
TimeoutMs 60000 Maximum wall-clock time, in milliseconds, the scenario may run before an off-thread watchdog fails it and marks the class host dead.

The three world flags (FreshWorld, RollbackWorld, RestartWorld) are mutually exclusive: setting any two on the same scenario is a setup error, resolved before any boot.

TimeoutMs deliberately does not reuse xUnit's own [Fact(Timeout = ...)]: xUnit posts its timeout continuation back through SynchronizationContext.Current, which for an Atlas scenario is the game thread's own queue. If the game thread itself is the thing that is stuck, that continuation never drains and the test hangs forever instead of failing. Atlas enforces TimeoutMs from an independent watchdog thread instead.

[AtlasTheory] (method level)

The theory-style counterpart to [AtlasScenario], contributed by Seggr (Atlas's first external contribution). Combine it with [InlineData], [MemberData] or any other xUnit DataAttribute, and each data row runs as its own scenario on the embedded server's game thread, with the row's values in its display name and rows passing or failing independently:

[AtlasTheory]
[InlineData("game:chest-east")]
[InlineData("game:chest-west")]
public async Task Chest_is_placeable(string chestCode)
{
    BlockPos pos = World.Spawn.Offset(1, 1, 0);
    World.SetBlock(chestCode, pos);
    await World.Ticks(5);
    Assert.Equal(chestCode, World.BlockAt(pos).Code.ToString());
}

Behavior worth knowing:

  • Same settings as [AtlasScenario], applied per row. FreshWorld, RollbackWorld, RestartWorld, StrictIsolation and TimeoutMs mirror [AtlasScenario] exactly. Each data row is a full scenario of its own, so a world flag buys its isolation for every row, and the same mutual exclusions apply.
  • xUnit's own theory behavior is inherited, not reimplemented. Serializable rows are pre-enumerated at discovery time into one test case each, so they appear individually in VS Test Explorer; non-serializable data falls back to xUnit's standard runtime-enumerating test case; a theory with no data fails with xUnit's own "No data found for ..." error.
  • Rows run sequentially, like every other scenario of a class, and atlas run --parallel keeps a theory's rows together with their class's worker.

IWorldSession surface

AtlasScenarioBase.World (an IWorldSession) is the entry point. Every member runs on the game thread.

Member Kind Notes
Api Escape hatch Raw ICoreServerAPI. See below.
Spawn Query Default spawn position, resolved to terrain height.
Calendar Query The world's game calendar.
BlockAt(pos) Query Block at a BlockPos.
BlockEntityAt<T>(pos) Query Block entity at a position, cast to T, or null.
EntitiesIn(Cuboidi area) Query Entities in an area, dimension 0 (kept for back-compat).
EntitiesIn(WorldArea area) Query Dimension-aware entity query. See Dimensions below.
SetBlock(blockCode, pos) Action Sets a block by asset location code.
PlaceSchematic(path, origin) Action Places a block schematic with its minimum X/Y/Z corner at origin; returns the placed block count. See Schematics below.
PlaceSchematic(path, origin, mode) Action Same, but placing with mode instead of the replace mode stored in the schematic.
SpawnEntity(entityCode, pos) Action Spawns an entity by asset location code, in pos's dimension.
ExecuteCommand(command) Action await; runs a server command as the console and returns a CommandResult. See Command results below.
Ticks(count) Time await; waits for count server ticks.
Until(predicate, timeoutTicks = 600) Time await; polls predicate once per tick, throws ScenarioTimeoutException on timeout.
EntitySimulationTicks Time Monotonic count of the embedded server's real entity-simulation ticks. See The tick contract below.
JoinPlayer(name) Action await; joins a headless test player. See Test players below.
StatsOf(entity) Query Read-only stats view over any entity. See Test players below.

Helpers on BlockPos (via BlockPosExtensions): Offset(dx, dy, dz) and Area(radius) for building positions and areas around a reference point.

Time model

Scenario code advances the world explicitly; nothing ticks on a wall-clock timer behind your back except the server itself.

  • await World.Ticks(count): waits for count server ticks to elapse.
  • await World.Until(predicate, timeoutTicks = 600): polls predicate once per tick until it returns true, or throws ScenarioTimeoutException (carrying the number of ticks waited) once timeoutTicks elapses.
  • World.EntitySimulationTicks: a monotonic counter of the embedded server's real entity-simulation ticks. Read it before and after a stretch of Ticks(n) and the delta is the exact number of entity-simulation ticks the server ran, so an entity-tick-frequency probe (a counting behavior on a spawned entity, say) can Assert.Equal(counterDelta, probeTicks) instead of settling for a ratio.

Both Ticks and Until are tick-based, not wall-clock: they only make progress while the server is actually ticking. This is different from TimeoutMs on [AtlasScenario], which is a wall-clock watchdog running on an independent thread, specifically to catch the case where ticking itself has stalled (host-dead semantics): once the watchdog fires, the scenario fails with ScenarioTimeoutException and HostRegistry marks the class host dead, so every later scenario in that class fails fast instead of trying to reuse a host whose game thread may still be running the abandoned scenario.

The tick contract

Vintage Story has no fixed simulation step. One Atlas tick is one fire of the engine's 1ms game-tick listener, at most one per ServerMain.Process() pass; at the engine's default 33.33ms pacing a pass, an Atlas tick and an entity-simulation tick (a separate 20ms-stride server system) happen to line up 1:1, but that alignment is emergent from the default pacing, not a promise the engine makes. So await World.Ticks(n) guarantees the server advanced n game-tick fires and no more: it does NOT guarantee n entity-simulation ticks, n of any other system's ticks, or any wall-clock duration. When you need the entity-simulation count exactly, read World.EntitySimulationTicks rather than inferring it from Ticks(n). Ticks(n) semantics are deliberately unchanged; the counter is the addition.

EntitySimulationTicks reads the engine's own record of the entity-simulation system's last tick, sampled once per pass, so it observes every fire exactly once and the counter delta is exact for an unthrottled entity on every supported engine. One caveat when a server fork throttles entity ticks by distance (the Stratum distance-band throttle is the field example): anchor an entity-timing probe to player.Entity.Pos, not world spawn. The engine spawns a new player at a randomized offset from world spawn (up to the play style's spawn radius, 50 blocks on the defaults), so a probe placed at world spawn lands a random distance from the player and can straddle the fork's near/mid throttle band, counting full ticks on one run and half on the next. Anchored to the player's own position the distance is fixed and the count is stable (this was the long-standing "half the ticks on some runs" mystery: a throttle keyed on the randomized spawn, never an Atlas artifact).

On an engine whose tick machinery has drifted from the measured layout, EntitySimulationTicks degrades at boot behind a one-time warning and only reading the property throws (naming the drifted symbols); the rest of the time model, Ticks and Until included, is unaffected. The full measured contract (decompiles across 1.20.12, 1.21.7 and 1.22.3 plus the Stratum fork, and instrumented live runs) is written up in docs/specs/2026-07-14-tick-contract.md.

World lifecycle and isolation

  • One xUnit class fixture equals one server, one fresh world, one scratch data path, shared by every scenario in that class. World state persists between scenarios of the same class unless you opt out.
  • Three per-scenario opt-outs cover the three things "a clean run" can mean: [AtlasScenario(FreshWorld = true)] (a brand-new world on a rebooted server), [AtlasScenario(RollbackWorld = true)] (the same world restored in place, no reboot) and [AtlasScenario(RestartWorld = true)] (the same world on a genuinely rebooted server). See The isolation trilogy below for how to choose.
  • Cross-class isolation is total: every test class gets its own server, world, and scratch path.

The isolation trilogy

Mode World Server Cost Use when
FreshWorld = true New Rebooted One full boot The reset must also cover mod in-memory state that neither follows chunk/entity lifecycle events nor resyncs through the rollback hooks.
RollbackWorld = true Same, restored to its snapshot Kept running Roughly 25x cheaper than a boot The default fast reset: world state, joined players and mini-dimensions all return to the snapshot; the mod under test keeps its state in the world, follows lifecycle events, or resyncs through the rollback hooks.
RestartWorld = true Same, carried across a real restart Genuinely rebooted One graceful shutdown plus one full boot The scenario asserts on what actually survives a save/load round trip.

The three modes are mutually exclusive: combining any two on the same scenario is a setup error, resolved before any boot.

FreshWorld: a new world on a new host

FreshWorld = true recycles the whole class host: the embedded server is disposed and a new one boots into a new scratch data path. Fresh everything, at the cost of a full boot (asset loading, mod loading, savegame open, spawn-chunk generation). It is the correctness baseline the other two modes are measured against, and the fallback when they do not apply.

RollbackWorld: restore the same world in place

RollbackWorld = true restores the class host's world to its snapshot without rebooting the server, measured at roughly 25x faster than a recycle on the baseline superflat world. Since 0.8.0 the rollback is universal: it covers joined test players and every mini-dimension, so the earlier guards are gone and player-hosting or mini-dimension-hosting classes roll back like any other.

Prefer RollbackWorld whenever a snapshot restore is a sufficient reset; that is now the common case. Prefer FreshWorld when the reset must also cover one of the honest boundaries below, chiefly mod in-memory state the mod does not resync through the rollback hooks.

What a rollback restores.

  • World state: blocks, block entities, chunk-stored entities, chunk moddata, savegame data (SaveGame.ModData, spawn, entity id counters) and the calendar.
  • Every dimension: mini-dimension chunk columns round-trip through the snapshot, boot-time pregenerated ones included.
  • Joined test players: position, watched attributes (health, saturation, custom mod trees, merged key-by-key so behaviors that cached sub-tree references keep working), inventories, world player data (game mode, move speed, picking range, spawn, hotbar slot, deaths) and per-player moddata. Players that joined AFTER the snapshot was captured are removed by the rollback, so the world returns exactly to its captured population; their names are freed, so a later scenario can rejoin them as brand-new players.
  • Cooperating mods: a mod whose in-memory state is keyed to SaveGame data resyncs it through the atlas:rollback:restored hook. See Cooperating with rollback (mods) below.

The honest boundaries. What a rollback does NOT restore:

  • Mod in-memory state, unless the mod resyncs it through the rollback hook: ModSystem fields, statics and caches that ignore chunk/entity lifecycle events keep their pre-rollback content. A mod with such state and no hook needs FreshWorld; Atlas deliberately ships no detection heuristics for this case.
  • In-memory map chunk state (height maps, map moddata), which the engine keeps preferring over the restored blobs.
  • For players: animation/interaction state (test players are headless) and privileges/roles, which are host-scoped, not world state.

Lazy capture, and why ordering matters. The snapshot is captured once per host, at the class's first rollback-enabled scenario: that scenario runs against the world exactly as captured, and later rollback-enabled scenarios in the class are rolled back to that same snapshot. Classes that never set RollbackWorld pay nothing. The callout: anything a NON-rollback scenario mutates before the first rollback-enabled one runs becomes part of the snapshot, and every later rollback restores that polluted baseline, not the freshly booted world. If the class mixes rollback and shared-world scenarios, put the first rollback-enabled scenario before the mutating ones, or accept that the baseline is whatever the world looked like when the first rollback request captured it.

Fail closed, visibly. If capture or restore fails for any reason (including engine internals drifting in a future game version), Atlas falls back to the full host recycle: the scenario still gets its clean world, just at FreshWorld cost instead of rollback cost. A rollback failure can slow a run down, never corrupt it. Since 0.7.0 the degrade is attached to the scenario's own test output, with the classified reason (a mod rollback hook failure, engine internals drifted, or a generic capture/restore failure), the one-line failure detail and the measured cost of the fallback recycle. That output travels inside the test result, so it shows up in the IDE test explorer, in the TRX report's per-test StdOut and under atlas run; the one-line stderr warning remains. Before 0.7.0 the stderr line was the only signal, invisible at normal dotnet test verbosity, so a suite could silently pay full recycles everywhere while the author believed rollback was active. The 0.7.0-era reasons players joined and mini-dimension chunks loaded are no longer produced (both cases roll back now); already-recorded summaries and logs keep their meaning.

StrictIsolation: the speedup as a contract. For suites that treat the rollback speedup as a contract rather than an optimization, [AtlasScenario(RollbackWorld = true, StrictIsolation = true)] makes a degraded rollback FAIL the scenario with an AtlasIsolationException carrying the degrade reason, instead of silently recycling. The host is still recycled before the failure surfaces, so later scenarios of the class keep running on a clean world: strictness changes visibility, not safety. A genuine server crash during the rollback attempt is never re-labelled and keeps surfacing as ServerCrashedException. Setting StrictIsolation without RollbackWorld is a setup error (only a rollback request can degrade, so there is nothing to be strict about).

Per-class isolation summary, costs included. When a scenario class hands its host off (to the next class, a fixture harvest or process exit), Atlas prints one stderr line with the class's isolation outcomes and their accumulated costs:

[Atlas] isolation summary for MyMod.Tests.MyScenarios: 1 capture (1.2 s), 3 rollback(s)
succeeded (0.4 s total), 1 degraded to a full host recycle (mod rollback hook failed x1;
7.2 s total), 0 FreshWorld recycle(s), 2 restart(s) (14.1 s total).

Since 0.9.0 the summary is emitted whenever the class ran ANY isolation mode; only classes that never requested isolation stay silent. That closes two observability gaps:

  • FreshWorld-only classes are no longer silent. They report their recycle count and measured cost, e.g. 2 FreshWorld recycle(s) (14.2 s total); the recycle is measured in the registry the same way restarts are.
  • The lazy first capture of a rollback class is its own line item instead of being folded into the rollback count, so N rollback scenarios no longer read as N-1 restores: the summary starts with e.g. 1 capture (1.2 s), 3 rollback(s) succeeded (0.4 s total) and the arithmetic is self-explanatory. Successful restores carry their measured total too. (Before 0.9.0 the same class summarized as 3 rollback(s) succeeded, ... with the capture invisible inside the count.)

This is the honest place to see the isolation cost, which per-test durations hide: the restore, recycle or restart happens outside the timed test body. Since 0.8.0 the costs are explicit: the degrade breakdown carries the total cost of the fallback recycles, and the restart count carries the total cost (shutdown + harvest + boot) of the class's restarts. Each completed restart also reports its measured cost in the requesting scenario's own test output, the same channel degrade reports use, so it lands in the IDE test explorer, the TRX per-test output and atlas run, not only at end of class. Under atlas run --parallel the summaries travel beyond stderr too: each worker emits them as class-summary protocol events, the orchestrator prints each one live, repeats them under an "Isolation summaries:" section in its final summary, and stores them as the aggregated TRX's run-level output. The class-summary event follows the widened emission rule (it fires for FreshWorld-only classes as well) and its summary string uses the new wording, but the event's fields and v (still 1) are unchanged, per the protocol's additive rules.

Boot-time mini-dimension pregeneration no longer disqualifies rollback. In 0.7.0, a fixture mod that pregenerated a mini-dimension at server boot silently degraded every rollback of every class staging it, the classic dogfooding trap. Since 0.8.0 those chunk columns are part of the snapshot, so pregenerating at boot is fine; creating mini-dimensions on demand remains a snapshot-size optimization, not a requirement.

Cooperating with rollback (mods)

A rollback restores the database and the live SaveGame, but it cannot know what a mod derived from them into memory. A mod whose in-memory state is keyed to SaveGame data desyncs on rollback unless it participates: the SaveGame snaps back to the captured baseline while the mod's memory still describes the post-capture world (a registration the restored world forgot, an id the restored allocator never handed out).

When a mod needs the hook, and when it does not.

  • Needs it: registry-style in-memory state keyed on SaveGame data. A registry seeded from a persisted manifest, an id allocator, a generated-markers store: anything a mod rebuilds from SaveGame.ModData at boot.
  • Does not need it: state that follows chunk/entity lifecycle events (block entity state, chunk moddata handlers, entity behaviors). The rollback drives unload and reload through the engine's own paths, so the mod sees the same events as a normal save/load.
  • Neither lifecycle events nor a hook: that mod's in-memory state cannot be rolled back, and Atlas deliberately ships no detection heuristics. Scenarios over such a mod use FreshWorld.

The contract. Atlas pushes two engine event-bus events, synchronously on the game thread. The event name plus the payload shape is the whole contract: a cooperating mod references only VintagestoryAPI (never an Atlas assembly), and the listener is inert outside Atlas runs, because the events never fire in production.

Event When Payload (TreeAttribute)
atlas:rollback:captured Once per capture, right after the snapshot is in memory version (int, 1), generation (int, increments per capture)
atlas:rollback:restored Every restore, AFTER the database and SaveGame globals (moddata included) are restored, and BEFORE any chunk column reloads version (int, 1), generation (int, matching the capture), restoreCount (int, per generation, starting at 1)

The restored timing is the load-bearing promise: when the handler runs, api.WorldManager.SaveGame is already the restored object (the restore mutates the live instance in place) and no chunk-loaded handler or tick has observed the world yet, so the mod can resync before anything reads desynced state. The captured event is for the rarer mod whose state is NOT derivable from SaveGame data and that wants to pair its own cheap in-memory snapshot with Atlas's; most mods ignore it.

Subscribing is one line in StartServerSide:

api.Event.RegisterEventBusListener(OnRollbackRestored, 0.6, "atlas:rollback:restored");

Library mods that other mods build on should subscribe above the 0.5 default (0.6 here), mirroring ExecuteOrder at boot, so their state is coherent before their consumers' handlers run.

Write the handler as a reconciliation, not a boot re-hydrate. The tempting shortcut, re-running the boot seed loop as-is, is wrong in both desync directions: it re-seeds (or refuses as duplicates) entries that are still live in memory, and it leaves post-capture registrations alive without releasing their ids or running their teardown. Model the handler on Manifold's real one, the first shipped consumer: refactor the boot hydrate into a re-runnable reconciliation with three passes (at boot the drop pass is a no-op, so it reduces to the original seed loop).

private void OnRollbackRestored(string name, ref EnumHandling handling, IAttribute data)
{
    // The SaveGame is already the restored one; reconcile memory against it.
    var manifest = LoadManifestFromSaveGame(); // the same read the boot seed uses

    // 1. Drop: purge every registration the restored manifest does not describe,
    //    through the mod's OWN teardown path, so ids are released, Destroyed fires
    //    and per-record cleanup runs. This forgets everything created after the
    //    capture, id reservations included.
    foreach (var record in registry.All.Where(r => !manifest.Describes(r)))
        registry.Purge(record.Code);

    // 2. Keep: records matching the restored manifest stay untouched. They carry
    //    in-memory configuration the manifest does not persist, and their state is
    //    coherent with the restored world.

    // 3. Re-seed: manifest entries memory lacks (a removal the rollback undid),
    //    exactly as at boot; then reload dependent stores from the restored moddata.
    foreach (var entry in manifest.Entries.Where(e => registry.Get(e.Code) is null))
        registry.SeedFromManifest(entry);

    generatedColumns.LoadFromBytes(sapi.WorldManager.SaveGame.GetData("mymod:genchunks"));
}

Failure semantics. The engine's event bus has no per-listener try/catch, so a handler exception propagates into Atlas's push: the rollback degrades fail-closed to a full host recycle under the mod rollback hook failed reason (the mod's exception type and message embedded in the report), and fails the scenario under StrictIsolation. Do not swallow exceptions to keep the rollback alive: a half-resynced mod is exactly the state rollback exists to prevent, and the full recycle rebuilds the mod from scratch anyway.

Validated on a real consumer. The contract shipped against Manifold's real dimension registry (a registry plus id allocator seeded from a SaveGame manifest, mini-dimensions pregenerated at boot). With the reconciliation handler in place, the historical desync is provably gone: a dimension whose first incarnation only a rollback removed can be re-created under the same code, where it was historically refused as a duplicate. The flipped suite ran 13 rollbacks with zero degrades under StrictIsolation, with restores measured around 0.15 s.

RestartWorld: the persistence round trip

RestartWorld = true restarts the server for real and carries the world over. Before the scenario runs, the class host is shut down gracefully (the engine's shutdown persists the world save), the save is harvested, and a replacement host boots against it in a fresh scratch directory (the harvested file is deleted once the replacement is up). The scenario then runs on a genuinely restarted server whose world survived a real save/load round trip, so persistence scenarios (SaveGame moddata, manifests, whatever a mod writes for reload) are finally writable. Neither of the other modes exercises this path: FreshWorld throws the world away, and RollbackWorld restores state without restarting the server.

// Seed the state at class-fixture boot (from your test fixture mod, or an
// [AtlasDataFiles]-seeded config the mod reads in StartServerSide), NOT from
// an earlier scenario in the same class: xUnit gives no execution-order
// guarantee within a class, so a seed-then-restart scenario pair can run in
// either order and fail intermittently.

[AtlasScenario(RestartWorld = true)]
public async Task Seeded_state_survives_a_server_restart()
{
    // Runs on a genuinely rebooted server; everything the fixture wrote at
    // boot (plus anything persisted since) went through a real save/load
    // round trip.
}

Semantics worth knowing:

  • No intra-class ordering: xUnit does not guarantee scenario execution order within a class, and runners differ (plain dotnet test, IDE explorers and atlas run can each pick a different order). Never depend on an earlier scenario having seeded state for a restart scenario; seed at fixture boot instead.
  • Cost: one graceful shutdown plus one full boot, the same order of magnitude as FreshWorld. That is the point, not a defect: the boot IS the round trip under test. If the restart scenario is the first of its class (or the class does not own the live host yet), the class host is booted first and then restarted, so even a first scenario gets a genuine round trip; that case costs two boots.
  • Composition with [AtlasWorld(SaveFile = ...)]: the restart carries forward the CURRENT world state, mutations made by earlier scenarios included, not the original fixture. A scenario that needs the pristine fixture back should use FreshWorld instead.
  • Fail hard, never fall back: a failed harvest (no persisted save after the graceful shutdown) fails the scenario with an AtlasSetupException, and a crash while booting the replacement surfaces as-is. There is no silent degrade, which is also why combining RestartWorld with StrictIsolation is a setup error: a restart either works or fails the scenario hard, so there is nothing to be strict about.
  • Joined test players do NOT survive a restart: their connections die with the host. Requesting a restart on a class that has joined test players fails the scenario with an AtlasSetupException rather than silently dropping them. Re-join players after the restart, use RollbackWorld (player-aware since 0.8.0) when a restored world is enough, or use FreshWorld when the carried-over world is not actually needed.
  • The restart's cost is visible: each completed restart is reported in the requesting scenario's own test output with its measured cost (shutdown + harvest + boot), paid outside the scenario's reported duration, and counted with a running cost total in the per-class isolation summary (2 restart(s) (14.1 s total)).

World fixtures (prebuilt saves)

A scenario class can boot against a prebuilt world instead of a generated one:

[AtlasWorld(SaveFile = "fixtures/castle-world.vcdbs")]
public class SiegeScenarios : AtlasScenarioBase
{
    ...
}

The save is copied into the class's own scratch data path before the server boots, so:

  • The fixture is never written to. Every test class runs against its own pristine copy; tests cannot corrupt the fixture or each other.
  • Any file name works. The copy is renamed to the engine's pinned save name internally.
  • World generation settings are ignored. Seed, WorldType and PlayStyle have no effect when a save is supplied: the savegame carries its own world configuration.
  • Version compatibility follows the engine's rules. A save from an older game version is auto-upgraded on load, exactly as a dedicated server would.
  • A missing fixture fails the boot fast with an AtlasSetupException naming the path.

To produce a fixture, either copy a save out of a normal game session, or build the world with an ordinary builder scenario and let atlas fixture run it and harvest the save its graceful teardown writes:

atlas fixture bin/Debug/net10.0/MyMod.Scenarios.dll \
      --scenario BuildsCastleWorld --out fixtures/castle.vcdbs

See the CLI page for the full atlas fixture reference and the builder-scenario contract.

Schematics (prebuilt structures)

For a single prebuilt structure rather than a whole world, IWorldSession.PlaceSchematic is lighter than a world fixture: it loads a block schematic (.json, e.g. a worldedit export) and places it with its minimum X/Y/Z corner at the given position, returning the placed block count.

[AtlasScenario]
public async Task Castle_gate_opens()
{
    BlockPos origin = World.Spawn.Offset(10, 0, 10);
    int placed = World.PlaceSchematic("fixtures/castle-gate.json", origin);
    Assert.True(placed > 0);
    ...
}

Behavior worth knowing:

  • Path resolution matches every other fixture path: absolute, or relative to the test assembly's directory; the .json extension is optional.
  • Placement mirrors the engine's worldedit import: blocks, decors, block entities (with their saved data) and any entities stored in the schematic; blocks extend toward positive X, Y and Z from the origin, in the origin's dimension.
  • Replace mode: by default the schematic's own stored replace mode is used (ReplaceAllNoAir unless the exporting tool chose otherwise). The PlaceSchematic(path, origin, mode) overload overrides it, e.g. EnumReplaceMode.ReplaceAll stamps the schematic's full cuboid, clearing existing blocks where the schematic has air.
  • A missing or malformed file fails with AtlasSetupException carrying the resolved path and the engine's error.

The division of labor: [AtlasWorld(SaveFile = ...)] loads a whole prebuilt world, PlaceSchematic stamps a single prebuilt structure into the running one.

The game-thread contract

Every member of IWorldSession runs on the game thread. The xUnit adapter posts your scenario delegate onto a SynchronizationContext installed on that thread, and every await continuation inside the scenario body returns to that same queue by default. This is what makes direct, unsynchronized calls into the Vintage Story API safe from scenario code.

Never call ConfigureAwait(false) inside a scenario body. Doing so detaches the continuation from the game thread's queue and hands it to the .NET thread pool instead, which breaks the thread-pinning guarantee: subsequent game API calls in that scenario would run off-thread, racing the server's own pump. This is a contract, not something Atlas detects or enforces at runtime, so it is on you as the scenario author to avoid it.

Dimensions

Vintage Story stores dimensions as stacked Y-offset slices of a single flat world: a BlockPos carries a dimension field, and box queries against the engine are dimension-correct as long as both corners of the box carry that same dimension.

  • WorldArea is a Cuboidi paired with the dimension it lives in (record struct WorldArea(Cuboidi Bounds, int Dimension)), with an implicit conversion back to Cuboidi for call sites that only need the bounds.
  • EntitiesIn(WorldArea area) is the dimension-aware query surface. BlockPos.Area(radius) returns a WorldArea that inherits the source position's dimension, so world.EntitiesIn(pos.Area(5)) queries the same dimension pos is in.
  • EntitiesIn(Cuboidi area) is kept for back-compat and is documented as dimension 0; it is implemented as EntitiesIn(new WorldArea(area, dimension: 0)).
  • SpawnEntity(entityCode, pos) spawns the entity in pos's dimension. This required an explicit fix: the underlying engine's EntityPos.SetPos(BlockPos) copies X/Y/Z only and does not read the source BlockPos's dimension, so Atlas sets entity.Pos.Dimension from pos.dimension itself after SetPos.

Full custom-dimension end-to-end coverage (spawning a second dimension and asserting queries stay isolated to it) is out of scope here: it requires a dimension-creating mod, and is validated when a real consumer (Manifold) adopts this surface.

Command results

ExecuteCommand runs a server command as the console (admin role, every privilege) and returns the command's outcome, so a scenario can assert directly on what a command did:

CommandResult result = await World.ExecuteCommand("/time set day");
Assert.True(result.Ok, result.Message);

CommandResult carries:

Member Notes
Ok Whether the command completed successfully.
Message The status message, already resolved through the game's localization. Failures without an engine message (e.g. an unknown command) get a synthesized one, so Assert.True(result.Ok, result.Message) always names the failure.
Raw The engine's raw TextCommandResult: ErrorCode, Data, the unresolved message and its parameters.

Behavior worth knowing:

  • The command text must include the leading slash; a slashless command throws ArgumentException (the engine's dispatch strips the first character unconditionally, so it would otherwise be silently misparsed).
  • An unknown command does not throw: it completes with Ok = false and Raw.ErrorCode == "nosuchcommand", so scenarios can assert on intentional failures.
  • Commands whose argument parsing goes async (e.g. player lookups) complete on a later tick; the returned task follows them to their final result.

This replaces the SaveGame side channel earlier versions needed for driving a fixture mod through commands: register a command in the fixture mod, return a TextCommandResult from its handler, and assert on it from the scenario.

Test players and entity stats

IWorldSession.JoinPlayer(name) joins a headless test player into the world: no rendering, no real network. It works over the same in-memory dummy-network mechanism the game's own singleplayer client uses to talk to its local server (a hand-wired dummy TCP/UDP socket pair, carrying a single identification packet), which the server recognizes as a local connection and admits without auth - exactly like real singleplayer. The result is a real, world-present EntityPlayer with inventory, health, and every other behavior an ordinary connected player has.

Several players can be joined into the same world, one JoinPlayer call per distinct name; each rides its own dummy socket on the embedded server, so joined players coexist and act independently (own connection, own inventory, own entity) - enough for player-to-player interaction scenarios.

Joined players reach the Playing client state. Since 0.9.0, JoinPlayer completes the engine's own join sequence (it sends the real ClientLoaded/PlayerReady packets after the inventory wait, so the server runs its own transition), and a joined test player ends up in EnumClientState.Playing instead of sitting one state short of visible. Joined players are therefore seen by everything that filters on ConnectedClient.IsPlayingClient or counts Playing players (distance-based throttling, GetPlayersAround/NearestPlayer, playing-count broadcasts), and the engine's PlayerNowPlaying (and, on 1.22+, PlayerReady) events fire exactly as for a real client. The higher fidelity has observable side effects: the join is announced in chat, the server streams world updates to the player's inert dummy buffers, natural entity spawning considers test players, and test players become valid interaction targets. Playing is the default with no opt-out: the packets were originally skipped as out of scope, not because of a technical constraint, and the decompiled 1.20.12/1.21.7/1.22.3 handlers confirmed every post-transition engine path is safe for the dummy socket. A player kicked by a mod DURING the join keeps the earlier behavior: JoinPlayer returns, the player never reaches Playing, and the kick is observed via ITestPlayer.IsConnected (see Kicks below). A joined player that stays registered without reaching Playing fails fast with an actionable AtlasSetupException (engine drift diagnosis).

[AtlasScenario]
public async Task Player_can_receive_and_carry_an_item()
{
    ITestPlayer player = await World.JoinPlayer("Tester");

    await player.GiveItem("game:bread-spelt-perfect", 3);
    await player.TeleportTo(World.Spawn.Offset(10, 0, 10));

    Assert.True(player.Stats.Health > 0);
    Assert.Equal(3, player.Player.InventoryManager.ActiveHotbarSlot.Itemstack.StackSize);
}

ITestPlayer members (all run on the game thread):

Member Kind Notes
Entity Escape hatch The live EntityPlayer.
Player Escape hatch The live IServerPlayer.
IsConnected Query false once the server has dropped the player (kick, ban). See below.
Position Query Current position as a BlockPos.
Stats Query IEntityStats view (see below).
GiveItem(itemOrBlockCode, quantity = 1) Action Resolves an item or block code and places the stack directly into the active hotbar slot.
TeleportTo(pos) Action Dimension-aware: calls EntityPlayer.ChangeDimension first if pos.dimension differs from the player's current dimension, since the engine's own TeleportTo/TeleportToDouble never read a BlockPos's dimension (the same gotcha SpawnEntity works around).

IWorldSession.StatsOf(entity) returns the same IEntityStats view for any entity, not just players - e.g. a creature spawned via SpawnEntity:

Member Notes
Health / MaxHealth Read off the entity's health watched-attribute tree. Zero if the entity has no health behavior.
Saturation Read off the hunger watched-attribute tree. Zero if the entity has no hunger behavior.
Attribute<T>(path) Generic typed read into the watched-attribute tree by path (e.g. "hunger/currentsaturation", or a top-level key like "tempStab"). Returns null/default if the path does not resolve.

Kicks and IsConnected

ITestPlayer.IsConnected is the first-class "was the player dropped by the server" signal: it turns false once the server has removed the player (a mod-under-test kicking it via IServerPlayer.Disconnect, a ban, or any other server-side removal). Test players never leave on their own, so a false value always means the server ended the connection.

One caveat: kicks issued from a background thread (a common mod pattern, e.g. after an HTTP check inside a PlayerJoin handler) settle a few ticks late. The off-thread kick crashes the engine's own teardown halfway, and Atlas finishes that teardown on the game thread a couple of ticks later; IsConnected reports the settled truth, not the in-flight state. So wait for the state instead of asserting right after the kick:

await world.Until(() => !player.IsConnected);

Once the drop has settled, the kicked player's socket slot and name claim are released, so the scenario can rejoin under the same name.

Player names

Two rules, both enforced with an actionable AtlasSetupException rather than a confusing engine-side symptom:

  • Names must be unique within the world. The server identifies accounts by a name-derived UID, so joining the same name twice would be treated as the same account reconnecting and kick the first player mid-scenario; Atlas rejects the duplicate up front instead. Note that the world is shared by every scenario in a class (see World lifecycle above), so a player joined by an earlier scenario still counts: reuse the ITestPlayer it got back (share it via a field), pick another name, or isolate the scenario with [AtlasScenario(FreshWorld = true)]. A rollback also frees names: players that joined after the snapshot was captured are removed by the rollback and can rejoin as brand-new players.
  • Names must satisfy the engine's rule: letters, digits, underscores and dashes only, 16 characters at most. The engine rejects anything else at the identification step, which surfaces as a join timeout; the diagnosis names this rule as the most likely cause.

Real network clients (as opposed to in-memory headless players) remain a separate, larger scope, out of Atlas's current roadmap.

Asserts

Atlas ships no custom assertion layer. Use standard xUnit Assert against whatever the query surface returns.

The Api escape hatch

World.Api exposes the raw ICoreServerAPI the embedded server is running. The query and action surface above is deliberately small (YAGNI): anything Atlas does not model yet, reach through Api directly, on the game thread, exactly like the rest of IWorldSession. Every member of IWorldSession, including Api, is documented as running on the game thread; the same rule applies to whatever you do through Api.

Determinism notes

  • Fixed seed plus superflat worldgen produces bit-identical worlds across runs, verified empirically during the feasibility spike.
  • Tick ordering and resulting world state are reproducible because everything runs on a single game thread. Per-tick wall-clock timing inside a single Process() call is not bit-exact (it depends on Config.TickTime and per-system update intervals), so "wait N ticks" is reliable but "exactly N engine ticks of subsystem X fired in this window" is not guaranteed.

Clone this wiki locally