-
Notifications
You must be signed in to change notification settings - Fork 1
Writing Scenarios
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.
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.
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.
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.
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.
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,StrictIsolationandTimeoutMsmirror[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 --parallelkeeps a theory's rows together with their class's worker.
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.
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 forcountserver ticks to elapse. -
await World.Until(predicate, timeoutTicks = 600): pollspredicateonce per tick until it returns true, or throwsScenarioTimeoutException(carrying the number of ticks waited) oncetimeoutTickselapses. -
World.EntitySimulationTicks: a monotonic counter of the embedded server's real entity-simulation ticks. Read it before and after a stretch ofTicks(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) canAssert.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.
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.
- 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.
| 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 = 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 = 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:restoredhook. 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 as3 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.
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.ModDataat 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 = 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 andatlas runcan 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 useFreshWorldinstead. -
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 combiningRestartWorldwithStrictIsolationis 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
AtlasSetupExceptionrather than silently dropping them. Re-join players after the restart, useRollbackWorld(player-aware since 0.8.0) when a restored world is enough, or useFreshWorldwhen 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)).
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,WorldTypeandPlayStylehave 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
AtlasSetupExceptionnaming 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.vcdbsSee the CLI page for the full atlas fixture reference and the builder-scenario
contract.
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
.jsonextension 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
(
ReplaceAllNoAirunless the exporting tool chose otherwise). ThePlaceSchematic(path, origin, mode)overload overrides it, e.g.EnumReplaceMode.ReplaceAllstamps the schematic's full cuboid, clearing existing blocks where the schematic has air. - A missing or malformed file fails with
AtlasSetupExceptioncarrying 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.
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.
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.
-
WorldAreais aCuboidipaired with the dimension it lives in (record struct WorldArea(Cuboidi Bounds, int Dimension)), with an implicit conversion back toCuboidifor call sites that only need the bounds. -
EntitiesIn(WorldArea area)is the dimension-aware query surface.BlockPos.Area(radius)returns aWorldAreathat inherits the source position's dimension, soworld.EntitiesIn(pos.Area(5))queries the same dimensionposis in. -
EntitiesIn(Cuboidi area)is kept for back-compat and is documented as dimension 0; it is implemented asEntitiesIn(new WorldArea(area, dimension: 0)). -
SpawnEntity(entityCode, pos)spawns the entity inpos's dimension. This required an explicit fix: the underlying engine'sEntityPos.SetPos(BlockPos)copies X/Y/Z only and does not read the sourceBlockPos's dimension, so Atlas setsentity.Pos.Dimensionfrompos.dimensionitself afterSetPos.
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.
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 = falseandRaw.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.
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. |
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.
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
ITestPlayerit 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.
Atlas ships no custom assertion layer. Use standard xUnit Assert against whatever the query
surface returns.
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.
- 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 onConfig.TickTimeand 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.