Releases: Pixnop/Atlas
Release list
v0.11.0
Added
-
atlas diff --json-tests: an opt-in flag (implies--json) adding a per-testtestsarray
to the diff's JSON document, one entry per merged test identity with{test, baseline: {outcome, durationMs} | null, candidate: {outcome, durationMs} | null, stdout?}(issue #94,
from StratumParity's evaluation of migrating off their hand-rolleddiff_trx.py: their
differential pipeline feeds a markdown job summary and a history dashboard, which need
outcome, duration and per-test stdout, not just the diff categories the plain--json
payload already carried). The TRX reader now also extractsOutput/StdOut; duplicate names
within a report merge worst-outcome-first exactly like the category diff, and the kept
attempt's stdout survives that merge outright (no fallback to the losing attempt's stdout,
unlike the failure message).vstays1: purely additive, and the field is omitted
entirely (not an empty array) unless the flag is given, so the default--jsonpayload is
unchanged. Full contract in docs/specs/2026-07-14-diff-command.md. -
atlas stage <path/to/test-output-or-assembly.dll>: an explicit pre-stage entry point for
the engine-assembly auto-staging preflight (issue #95, from StratumParity field feedback on
0.10.0's issue #49 mechanism). Auto-staging behaves exactly as documented on a repointed
VINTAGE_STORY: the module initializers re-stage the test-output copy on disk, but when
engine types were already JITted before any Atlas code ran, THAT run still fails fast (the
rerun goes green).run-parity.sh, StratumParity's differential CI script, runs each
install exactly once, so it cannot absorb the fail-then-rerun and kept a per-install rebuild
instead.atlas stageruns the identical decision (sameEngineStaging/EngineStagercore
the module initializers use, zero duplicated logic) explicitly, before anything can bind the
copy, so a one-shot script now readsatlas stage out/ && VINTAGE_STORY=... dotnet test out/ --no-buildand boots green on the first try. Prints one line per file (pair): staged,
already identical, or nothing to stage; exits 0 for staged-or-noop, 2 (the CLI's usage/setup
bucket) for the core's defined failure cases (an unwritable output, an install without its
pdb, a diverged copy already bound, the Newtonsoft direction refusal). The CLI project does
not ship its own copy of Atlas.dll (that would shadow a scenario's own copy duringatlas run's default probing, the exact version hazardScenarioAssemblyResolverexists to avoid):
it compiles against Atlas.dll (InternalsVisibleTo, reference excluded from the packed
tool's own output and dependencies) and resolves the real bytes from the stage target's own
directory at run time. That loader deliberately loads by bytes rather than by path, so
Assembly.Locationreads empty and Atlas's own module initializer (which runs a redundant
best-effort staging pass the instant anything in the Atlas module is touched) treats "the
assembly's own directory" as unresolvable and skips it, leaving this command's explicit call
as the only one that ever touches the target directory. Never loads VintagestoryAPI.dll or
VintagestoryLib.dll itself, proven by staging garbage, non-PE bytes cleanly in the E2E suite.
v0.10.0
Added
-
atlas diff baseline.trx candidate.trx: first-class differential comparison of two TRX
runs (issue #88, 0.10.0 roadmap, from the StratumParity field pattern: the same suite runs
against vanilla and against a fork, and the outcome comparison was hand-rolled scripts
until now). Comparison is keyed by test name exactly as the TRX reports it (theory rows
carry their arguments in the name, so every row diffs on its own; duplicate names, one per
rerun attempt, merge worst-outcome-first) and buckets every change into new failures
(failed in candidate; passed, skipped or absent in baseline), fixed (failed to passed),
vanished (present to absent), new tests, still failing, and notable duration shifts
between two passing runs (conservative on purpose: at least 2x AND at least 500 ms apart,
both directions reported, informational only). The console report is a summary line plus
compact per-category listings (empty categories print nothing);--jsonreplaces it with
a stable machine shape versioned like the worker protocol (v: 1first, additive
evolution, category keys always present). Exit codes gate differential CI directly: 0 no
regressions, 1 at least one regression (a new failure or a vanished test, nothing else), 2
usage or unreadable input. Works on the TRX Atlas writes (atlas run --parallel --trx)
and tolerates any spec-conforming TRX including plaindotnet test --logger trx; no
server, assembly orVINTAGE_STORYinvolved. Full contract in
docs/specs/2026-07-14-diff-command.md. -
IWorldSession.EntitySimulationTicks, a monotonic counter of the embedded server's
real entity-simulation ticks, so entity-tick-frequency probes can assert exact counts
instead of ratios (issue #79, from the StratumParity field report: a counting
EntityBehavior on spawned straw dummies observed non-constant ratios between
World.Ticks(n)and actual entity ticks, about half on some runs and 100 percent on
others, forcing the suite onto ratio assertions). The investigation (decompiles of
1.20.12/1.21.7/1.22.3 plus the Stratum patches, and instrumented live runs on both
flavors, written up in docs/specs/2026-07-14-tick-contract.md) found the variance was
never pump pacing: one Atlas tick is one fire of a 1ms game-tick listener, at most one
perServerMain.Process()pass, and at the engine's default 33.33ms pacing, passes,
Atlas ticks and entity-simulation ticks (a separate 20ms-stride server system) all run
1:1 on both vanilla and Stratum. The half-counts were Stratum's distance-band entity
throttle keyed on the engine's randomized new-player spawn (spawnRadius, 50 blocks on
default playstyles): a probe anchored to world spawn lands at a random 0-55 blocks from
the anchor player and straddles the fork's 32-block near/mid band boundary run by run
(measured: 25.5 blocks -> 150/150 ticks, 50.8 blocks -> 75/150). The new counter reads
the engine's own record of the entity-simulation system's last tick (the public
millisecondsSinceStartstamp of theServerSystemEntitySimulationentry in the
internalServerMain.Systemsarray, symbols verified identical on 1.20.12, 1.21.7 and
1.22.3 and untouched by Stratum), sampled by the pump once per pass, which observes
every fire exactly once (systems tick at most once per pass and every fire strictly
advances the stamp). Reads run on the game thread in the same turn as probe reads, so
Assert.Equal(counterDelta, probeTicks)is exact for an unthrottled entity on every
supported engine, and it holds on Stratum too once the probe is anchored to
player.Entity.Posinstead of world spawn (both proven in E2E on vanilla and the
fork). On an engine whose tick machinery drifted the counter degrades at boot behind a
one-time warning and only reading the property throws, with the drifted symbols named.
The spec also pins, for the first time, whatawait World.Ticks(n)guarantees and does
not;Ticks(n)semantics are deliberately unchanged. -
Engine-assembly auto-staging at launch (issue #49, option 2): a prebuilt test assembly now
runs against whichever installVINTAGE_STORYpoints at, without a rebuild. The 0.8.0
preflight fail-fasted on a diverged test-outputVintagestoryAPI.dll, and cross-VERSION
mixes never even reached it: the game thread's own JIT loadsVintagestoryLibagainst the
stale copy and kills the whole testhost with a rawTypeLoadException(measured: a
1.22.3-built output pointed at 1.21.7 dies onServerMain.PlaySoundAtwith no Atlas frame
executed). In-process redirection is measurably impossible, not just late:
AssemblyLoadContext.Default.LoadFromAssemblyPath(installCopy)defers to the default
binder, which prefers the app-path copy for a colliding simple name (measured on .NET 10:
it returns the test-output assembly). So Atlas now rewrites the test-output copy itself,
dll AND pdb as a unit, through same-directory temp files and atomic renames, from module
initializers that run before anything can bind it: Atlas.XUnit's fires at xUnit DISCOVERY
(instantiating the[AtlasScenario]discoverer, measured to precede the first engine-type
JIT), Atlas's own covers directServerHostflows, and both stage the assembly's own
directory too, which is the scenario directory underatlas run(where the app base is
the CLI's own bin, so the CLI needed no change). The boot preflight moved from the game
thread toStartAsync, on a thread that can still surface an error before that JIT, and
now verifies instead of failing: staging already happened, was a no-op (identical copies,
the documented rebuild flow, byte-for-byte unchanged behavior), or the boot throws an
AtlasSetupExceptionnaming both file identities and the remedies for the genuinely
impossible cases: a diverged copy already bound because engine types were JITted before
any Atlas code ran (the output is still re-staged so a plain re-run recovers), an
unwritable test output, an install shipping noVintagestoryAPI.pdb. Field motivation
from the issue #49 thread: StratumParity's differential CI (vanilla and the Stratum fork
on every push) rebuilt the test assembly once per install under the single-VINTAGE_STORY
constraint, doubling its CI time; auto-staging removes the per-install rebuild. The staging
set covers the second game-provided file too, direction-aware: the output's
Newtonsoft.Json.dll(the BUILD-time install's game build) is left alone when it is the
same-or-newer build than the target's (the measured-green mix: the newer build is a
superset for the older engine), and an OLDER build, which the newer engine cannot run (it
binds members its own build added:JToken.WriteTo(JsonWriter)on 1.22.3 killed every boot
in measurement, 90/105 scenarios plus all samples), is refused up front with both file
versions and the remedies named, plus the same on-disk re-stage, because the VSTest host
binds that assembly at process start, before any trigger could ever swap it in-process. So
the supported prebuilt pattern is: build against the newest engine you target, run
everywhere older; the full engine E2E suite built once on 1.22.3 runs 105/105 on 1.21.7 AND
1.20.12 without a rebuild, and the per-pushprebuilt-cross-installCI lane proves the
samples crossing 1.22.3 -> 1.21.7 -> 1.21.7 (idempotence) -> 1.22.3 from one build, with
byte-identity asserts on the staged copy. The pure decision core (EngineStaging) and the
thin IO/reflection shell (EngineStager) replaceVsInstall.VerifyApiCopyMatchesInstall;
ApiCopySynckeeps the shared identity/compare primitives.
Fixed
-
EngineCompatnow resolvesEnumClientState.Playingfrom the loaded engine by name instead
of comparing joined clients against the compiled-in value: 1.22 insertedAdmittedinto the
enum, shiftingPlayingfrom 3 (1.20.x/1.21.x) to 4, and the C# compiler bakes enum values
into the referencing assembly's IL exactly like theGameVersionconsts the shim already
reads from metadata at run time. A prebuilt Atlas therefore misread the join lifecycle on the
other engine line: on the first issue #49 cross-install engine run (1.22.3-built suite on
1.21.7), all 33 join-dependent scenarios timed out inWaitForPlaying, comparing that
engine'sQueuedagainst Playing, while every join-free scenario passed (72/105). The value
is boot-validated like every shim member (a fork that renames the state fails fast with the
symbol and game version named), and the compat source rule gains its corollary: engine enum
members whose positions shift across supported versions are compile-time constants too, and
must go throughEngineCompat. Same-version runs are byte-for-byte unaffected. -
WorldSession.SpawnEntitynow reachesEntity.Pos/ServerPosthroughEngineCompat
instead of direct member access: 1.22 turned both FIELDS into properties
(ServerPos => Pos, one shared instance), so the same source compiles on every supported
version but the emitted IL binds to exactly one shape, and a prebuilt binary dies with
MissingMethodException: get_ServerPos()on the other engine line (the four failures left
on the issue #49 cross-install engine run after the enum fix took it from 33 down to 4).
EngineCompat.ServerPosOf/PosOfresolve the member shape once per process (property
preferred, public-field fallback, boot-validated fail-fast with the missing member and game
version named).SidedPos, a property on every supported version, stays the documented
surface for SPAWNED entities (the one engine-test assert readingEntity.Posmoved to it);
the new accessors exist for the pre-registration window whereSidedPosis unusable (it
dereferencesentity.World, unset untilSpawnEntity, on pre-1.22 engines).
v0.9.1
Fixed
-
JoinPlayerno longer hands the world back to the scenario while the engine's background
server-assets build can still be enumerating live game content (issue #84, from the
StratumParity field report: a scenario that joined a player and immediately ran a
2048-SetBlock burst, under a staged source mod that lengthens the build, hit "Collection was
modified" insideBuildServerAssetsPacketon a TyronThreadPool thread, and an unhandled
pool-thread exception kills the whole testhost process, twice in a row on a 4-core CI
runner). After the join reaches the Playing state,JoinPlayernow waits for the exact
completion signal the issue #46 dispose-time guard already reads from the other end of the
host lifecycle (the privateServerMain.serverAssetsPacketbox:packetassigned by
non-dedicated builds, Atlas's case, orLengthbumped by dedicated ones), awaited on the
game-thread tick scheduler so the pump keeps processing while the build finishes, and
bounded at 1800 ticks (~60 s at the engine's nominal tick pace, mirroring the dispose-side
bound) with an engine-driftAtlasSetupExceptionon expiry. On the supported engines
(verified by decompile on 1.20.12, 1.21.7 and 1.22.3) the build is queued at boot by
ServerMain.Launch()andHandleRequestJoinitself blocks on the same signal, so a
completed join normally settles the check instantly: the guard costs two cached-reflection
reads per join and only actually waits when a mod kicked the player mid-join before the
join's own wait ran (or on engine forks that defer the build to the first join, the field
report's case). The probe reflection now has a single owner (ServerAssetsBuildProbe, its
pure signal shape inAssetsBuildSignal) shared by both guards, and an engine whose signal
layout drifted degrades exactly like the dispose side: skip the wait behind a one-time
warning. The faulty enumeration itself is vanilla engine code (reported upstream at
StratumServer/Stratum#151); Atlas closes the window scenarios could race it from. -
Scratch directories no longer accumulate until they exhaust the temp filesystem (issue
#83, from the StratumParity field report: a day of repeated local runs piled up 722
directories, 1.7 GB, under /tmp/atlas on a 16 GB tmpfs, at which point the ENGINE's own
disk guard ("Disk space is below 400 megabytes... Will kill server now") failed every
subsequent boot with a message that never mentions Atlas). The registry now sweeps a
disposed host's scratch directory (world save, logs, staged mods), but only when nothing
argues for keeping it: no scenario of the owning class has failed so far, the host did
not crash, its game thread joined within the teardown bound, andATLAS_KEEP_SCRATCHis
not set (set it to 1 to keep everything while debugging). Anything red keeps its scratch
untouched, because server-main.log in there is the documented post-mortem artifact the
0.9.0 engine-crash fail-fast points users at; an abnormal process death keeps everything
too (the sweep only runs on orderly teardowns). Multi-host classes sweep per host under
the same green-so-far rule (a FreshWorld recycle mid-class, a completed RestartWorld
restart once its save is harvested), theatlas fixtureharvest path never sweeps (the
fixture is copied out of the scratch after disposal), and deletion is best-effort by
design: a short bounded retry covers the engine releasing file handles a beat after
stop, and a delete that still fails logs one stderr line instead of failing any test.
Applies todotnet testand the atlas CLI (sequential, worker and parallel modes)
alike: they share the registry. The keep-or-delete decision and the retry loop live in
pure cores (ScratchRetention, ScratchCleanup, the AssetsBuildSettle pattern) with unit
coverage; the E2E regressions run guinea pig classes through the full pipeline and
assert a green class's scratch is gone after hand-off while a failing class's survives
with its server-main.log.
v0.9.0
Added
-
Supported game-version floor lowered from 1.22.0 to 1.21.0, with 1.20.x compatible
best-effort (issue #15, measured in docs/specs/2026-07-12-pre-122-compat.md). The entire
compile-level gap below 1.22 is the server exit lifecycle, now owned by one runtime shim
(EngineCompat): it installs the exit-state holder into whichever field the loaded engine
has (ServerMain.exitStateon 1.22+,ServerMain.exitbefore), adaptsStopbetween the
1.22Stop(string, EnumExitMode, ...)and the pre-1.22
Stop(string, string = null, EnumLogType = Notification)shapes, and reads
GameVersion.NetworkVersion/ShortGameVersionfrom the loaded assembly's metadata instead
of the compile-time constants (which the C# compiler bakes into Atlas's IL, so a prebuilt
Atlas on an older engine would otherwise have every test-player join kicked by the server's
network-version check). The shim is boot-validated fail-fast: an engine whose layout
drifted refuses to boot with anAtlasSetupExceptionnaming the game version and the
missing symbol, and 1.19.x or older is rejected up front citing the floor (their boot API
changes shape beyond what reflection can bridge). On 1.22.x the shim binds the modern
members directly: zero behavior change, proven by the unchanged E2E suites. Also fixes the
one measured pre-1.22 runtime difference: server-side entity positions are now read and
written throughSidedPos/ServerPos(pre-1.22 keepsPosandServerPosas two
separate instances and only maintainsServerPosfor headless joins; 1.22 unified them, so
the fix is a no-op there) inITestPlayer.Position,TeleportTo's dimension check,
SpawnEntityand the player-rollback position capture/restore. Honest matrix: 1.21.7 and
1.20.12 verified live with the full engine E2E suite plus samples (rebuilt against each
install's own dlls, the documented flow); 1.21.7 joins the per-push CI matrix, 1.20.12
stays on the weekly sweep; the prebuilt-NuGet-binary-on-old-engine path is designed for but
has no CI lane yet; 1.19.x and older stay unsupported. -
[AtlasTheory]: the theory-style counterpart to[AtlasScenario]. Combine with
[InlineData],[MemberData]or any other xUnitDataAttributeand 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/failing independently. The per-scenario settings (FreshWorld,
RollbackWorld,RestartWorld,StrictIsolation,TimeoutMs) mirror[AtlasScenario]
exactly and apply per row (each row is a full scenario of its own, with the same isolation
mutual exclusions). All of 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, and 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, andatlas run --parallelkeeps a theory's rows together with their class's worker.
Changed
-
Isolation summaries close the two observability gaps of the Manifold 3c validation
(issue #71). The per-class summary is now emitted whenever the class ran ANY isolation mode:
FreshWorld-only classes, previously silent, report their recycle count and measured cost
("2 FreshWorld recycle(s) (14.2 s total)"; the recycle is measured in the registry the same
way restarts are). And 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 now carry their
measured total too. Consumer-visible in the worker protocol: theclass-summaryevent fires
for FreshWorld-only classes as well and itssummarystring uses the new wording, but the
event's fields andv(still 1) are unchanged, per the additive rules; the worker-protocol
spec documents the widened emission rule and wording. -
Test players now reach
EnumClientState.Playing(issue #74). Behavior change:JoinPlayer
completes the engine's own join sequence by sending the realClientLoaded/PlayerReady
packets (26/29) after the inventory wait, so the SERVER runs its own transition instead of
test players sitting one state short of visible. Joined players are now seen by everything
that filters onConnectedClient.IsPlayingClientor counts Playing players (Stratum's
distance-based throttling,GetPlayersAround/NearestPlayer, playing-count broadcasts),
and the engine'sPlayerNowPlaying(and, on 1.22+,PlayerReady) events fire exactly as
for a real client. Observable side effects of the higher fidelity: 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. The
packets were originally skipped as out of scope, not because of a technical constraint; the
decompiled 1.20.12/1.21.7/1.22.3 handlers confirmed none exists (every post-transition
engine path is dummy-socket-safe orIsSinglePlayerClient-guarded), so Playing is the
default with no opt-out. A player kicked by a mod DURING the join keeps today's behavior:
JoinPlayerreturns, the player never reachesPlaying, and the kick is observed via
ITestPlayer.IsConnected. A joined player that stays registered without reachingPlaying
now fails fast with an actionableAtlasSetupException(engine drift diagnosis).
Fixed
-
World rollback no longer races the engine's chunk thread on the shared savegame database
connection. Playing test players (see above) keep chunk streaming active between scenarios,
and the chunk thread's single-row reads (ServerSystemSupplyChunks.TryLoadMapChunkand
friends) take no lock at all: the connection'stransactionLockonly serializes transaction
blocks against each other, so a rollback transaction from the game thread made those reads
throw mid-flight on 1.22.x ("Execute requires the command to have a transaction object when
the connection ... is in a pending local transaction"), which the engine escalates to a full
server shutdown; under CPU contention (4-core CI runners) this was deterministic. Capture
reads and the restore's database phase now run inside the engine's own suspend window
(ServerMain.Suspend(true), the exact convention the engine's autosave uses for main-thread
database access: it pauses every server thread and waits for each acknowledgment), with a
guaranteed resume; a suspend that cannot be acquired in time degrades the rollback
fail-closed to a full host recycle.Suspendis public and identical on 1.20.12, 1.21.7 and
1.22.3, so the fix needs no new reflection and protects the pre-1.22 database layer too
(same shared connection, different timing). -
The game-thread pump now notices an ENGINE-initiated shutdown and fails fast. When the
engine stops itself (its reaction to an unhandled exception in one of its server threads:
"Caught unhandled exception in thread '...'", stop reason "Exception during Process"),
ServerMain.Process()becomes a silent sleep loop, and the pump previously spun on it
forever: any engine crash became a job-timeout hang instead of a red test. The pump now
watches the engine's publicstoppedflag (set first thing by everyStopsince at least
1.20.12), and a stop Atlas did not request is recorded as a host crash: pending tick waiters
are faulted with the real cause, the scenario fails promptly with aServerCrashedException
whose message points at the server logs (the engine keeps the stop reason and the failing
thread's stack only there), and teardown proceeds. Atlas's own stop paths are unaffected:
they cancel the pump before ever calling the engine'sStop.
v0.8.0
Added
-
Isolation summaries and restart costs beyond stderr (issue #66, from the Manifold 0.7.0
dogfooding). RestartWorld's cost is no longer invisible: the restart (shutdown + harvest +
boot) is measured in the registry, attached to the requesting scenario's own test output
("[Atlas] world restarted: ... (cost 7.1 s, paid outside the scenario's reported
duration).", the same channel degrade reports use, so it lands in the IDE test explorer,
the TRX per-test output andatlas run), and accumulated into the per-class isolation
summary ("2 restart(s) (14.1 s total)"); degraded rollbacks get the same treatment, their
summary breakdown now carrying the total fallback-recycle cost. The per-class summaries
themselves now travel beyond stderr: worker mode emits a new additiveclass-summary
protocol event (documented in the worker-protocol spec;vstays 1) between the class's
last test event and its class-end, fed by a reflection-installed harness sink plus a
graceful final-host shutdown before the stream closes;atlas run --parallelprints each
observed summary live, repeats them under an "Isolation summaries:" section in its final
summary, and stores them as the aggregated TRX's run-level output
(ResultSummary/Output/StdOut). Plaindotnet testandatlas runkeep their stderr line
unchanged. -
Mini-dimension support and mod-cooperation hooks for world rollback (issue #48, stage 3 of
the snapshot/rollback design). Rollback now covers EVERY dimension: capture records loaded
chunk columns as (X, Z, Dimension) triples (the database rows were dimension-keyed all
along), marks loaded mini-dimension chunks dirty before the forced save so the engine's
dimension-aware reload always finds complete columns, discards non-zero-dimension columns
through the engine's own per-chunk unload helper (never persisting, and never firing
column-unloaded events the engine itself does not emit for mini-dimensions), and reloads
them via the publicLoadChunkColumnForDimensionwith a dimension-aware completion check.
Boot-time pregenerated mini-dimensions no longer disqualify rollback (the issue #48
acceptance bar): theMiniDimensionChunksLoadeddegrade reason is no longer produced (the
enum member is kept, likePlayersJoinedin stage 2, so recorded summaries and logs keep
their meaning). New mod-cooperation contract for mods whose in-memory state is keyed to
SaveGame data (registries, allocators, generated-marker stores): Atlas pushes two engine
event-bus events synchronously on the game thread,atlas:rollback:captured(once per
capture, after the snapshot is in memory) andatlas:rollback:restored(every restore,
after the database and SaveGame globals are restored and BEFORE any chunk column reloads,
so chunk-loaded handlers and ticks never observe desynced mod state), with versioned
TreeAttributepayloads (version= 1,generation,restoreCount). The event name plus
payload shape is the whole contract: cooperating mods reference only VintagestoryAPI and
rebuild their state fromapi.WorldManager.SaveGameexactly as at boot; the listener is
inert outside Atlas runs. A throwing handler degrades the rollback fail-closed under the
newModHookFailedreason ("mod rollback hook failed") and fails the scenario under
StrictIsolation. Mods that cooperate through neither lifecycle events nor the hook remain
a documented hard boundary (useFreshWorld); no unsound detection heuristics were added.
Also ships the restore-cost instrumentation the spec asked for: every restore logs one
stderr line with the measured duration and the dirty-columns-at-restore ratio (the numbers
that would justify the deliberately deferred dirty-column filtering optimization). -
Player-aware world rollback (issue #47, stage 2 of the snapshot/rollback design):
[AtlasScenario(RollbackWorld = true)]now works on classes with joined test players. The
snapshot captures, per joined player, the playerdata blob the forced save wrote (restored
verbatim into the database) plus the live state a reset needs; a rollback resets the live,
still-connected player in place: position, watched attributes (health, saturation, custom mod
trees, merged key-by-key so behaviors that cached sub-tree references keep working),
inventories (the swapped-inventories duplicate-items case), 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 engine-side identity caches and playerdata rows are
purged and their joined-name claims released, so the same name can rejoin as a brand-new
player (the rollback waits for the release, so an immediate rejoin never hits the
duplicate-name guard). Restore ordering is player-safe by construction: post-capture players
are removed while the world is still live, the database is restored before the in-memory
unload (so any player-adjacent column the engine re-requests already reads snapshot bytes),
and live players are reset in the same game-thread turn as the unload, before a single tick
is pumped. The stage 1 guards are gone: capture no longer refuses joined players, and the
players-joined setup error in the rollback path is removed (thePlayersJoineddegrade
reason is kept so already-recorded summaries and logs keep their meaning, but it is no longer
produced); rollbacks on player-hosting classes count as plain successes in the per-class
isolation summary. Not reset, documented boundary: a player's animation/interaction state
(test players are headless) and the host-scoped privileges/role data, which is not world
state.RestartWorldstill rejects joined players: their connections die with the host.
v0.7.0
Added
-
[AtlasScenario(RestartWorld = true)]: restart-same-world isolation (issue #54), completing
the isolation trilogy:FreshWorld = truerecycles the host for a brand-new world (strongest
isolation, one full boot);RollbackWorld = truerestores the same host's world snapshot
without a reboot (fastest, no restart);RestartWorld = truerestarts the server for real
and carries the world over, for scenarios asserting on what actually persists. 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. Costs one full boot, same asFreshWorld: the boot IS the round trip under
test. Semantics: the three world flags are mutually exclusive (any combination is a setup
error resolved before any boot);StrictIsolationwithRestartWorldis a setup error too
(a restart either works or fails the scenario hard, so there is no silent degrade to be
strict about); a failed harvest fails the scenario with anAtlasSetupExceptionand a
replacement boot crash surfaces as-is, never a silent fallback; a class-level
[AtlasWorld(SaveFile = ...)]composes naturally, the restart carrying forward the CURRENT
world state (mutations included), not the original fixture; joined test players do not
survive a restart (their connections die with the host), so requesting one with players
joined fails the scenario instead of silently dropping them. The per-class isolation summary
line now also reportsN restart(s). -
atlas --version(oratlas version): prints the package version (the informational version
without the+shabuild metadata) and exits 0; needs no scenario assembly and no VINTAGE_STORY. -
Rollback degrades are now visible in the standard workflow (issue #53). When a
[AtlasScenario(RollbackWorld = true)]request cannot be honored and falls back to a full
host recycle, the degrade is attached to the scenario's own test output, with the classified
reason (players joined, mini-dimension chunks loaded, engine drift, 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 underatlas run(which now prints non-empty test
output indented beneath the PASS/FAIL line); the one-line stderr warning remains. Previously
the stderr line was the only signal, invisible at normaldotnet testverbosity, so a suite
could silently pay full recycles everywhere (e.g. a fixture pregenerating mini-dimensions at
boot poisoning every rollback) while the author believed rollback was active. -
[AtlasScenario(RollbackWorld = true, StrictIsolation = true)]: opt-in strict mode for
suites that treat the rollback speedup as a contract. A degraded rollback FAILS the scenario
with anAtlasIsolationExceptioncarrying 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. SettingStrictIsolationwithoutRollbackWorldis a setup error
(only a rollback request can degrade, so there is nothing to be strict about). -
Per-class isolation summary: when a scenario class hands its host off (to the next class, the
fixture harvest or process exit), Atlas prints one stderr line with the class's isolation
outcomes, e.g.[Atlas] isolation summary for MyMod.Tests.MyScenarios: 2 rollback(s) succeeded, 1 degraded to a full host recycle (mini-dimension chunks loaded x1), 0 FreshWorld recycle(s).Classes that never requested rollback isolation stay silent. This is the honest
place to see the isolation cost, which per-test durations hide (the restore or recycle happens
outside the timed test body). -
atlas fixture <Scenarios.dll> --scenario <substring> --out <fixture.vcdbs> [--force]: builds
the prebuilt world save that[AtlasWorld(SaveFile = "fixtures/myworld.vcdbs")]boots against,
turning what used to be folklore (run a builder scenario, then harvest the save its graceful
teardown wrote from the host's scratch data path) into a first-class command. The builder is an
ordinary[AtlasScenario]whose side effect is building the world (place blocks, run commands,
seed data):--scenariomust select exactly ONE scenario by display-name substring (zero or
several matches is a usage error listing the candidates, exit 2), the run uses the same
in-process mechanics asatlas run --filter, and after the scenario passes and the host tears
down gracefully the persisted save is copied to--out(parent directories created; an
existing file is only overwritten with--force). A failing builder writes nothing and exits 1,
so a broken builder can never silently produce a half-built fixture. -
IWorldSession.PlaceSchematic(path, origin), plus anEnumReplaceModeoverload: 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. This makes "how do I load a
prebuilt structure in a test" first-class next to the world-fixtures story:
[AtlasWorld(SaveFile = ...)]loads a whole prebuilt world,PlaceSchematicstamps a single
prebuilt structure into the running one. Paths resolve like every other fixture path
(absolute, or relative to the test assembly's directory), a missing or malformed file fails
withAtlasSetupExceptioncarrying the resolved path and the engine's error, and placement
mirrors the engine's worldedit import: blocks, decors, block entities (with their saved
data) and any entities stored in the schematic. -
Fail-fast preflight for a stale test-output
VintagestoryAPI.dllcopy (issue #49, option 1):
before the embedded server boots, Atlas compares the test output's copy byte-for-byte
(size + SHA-256) against theVINTAGE_STORYinstall's copy and fails with a clear
AtlasSetupExceptionnaming both files (size, short hash, assembly version) and both remedies
(rebuild against the target install, or copy the install's dll AND pdb over the local ones).
This is the multi-install trap of differential runs: repointingVINTAGE_STORYat a different
install without rebuilding loads the target'sVintagestoryLibagainst the stale local
VintagestoryAPIand dies deep into boot with a crypticMissingFieldException. A version
check would not catch it (forks rebuild the API at the same assembly version), so the
comparison is content-based. Consumers that do not copy the dll (Private=false) are
unaffected: the check skips silently when no local copy exists.
Fixed
- In-process
AssemblyRunnerdisposal race (issue #59): everywhere Atlas drives xunit's
AssemblyRunnerin-process (atlas run, the worker mode and the engine's own nested-runner
E2E test), disposal now waits (bounded, 30 s) for the runner to reportIdleplus a short
grace, and LEAKS the runner instead of disposing it if it never idles. xunit.runner.utility
2.x disposes the runner's completion events while its worker thread can still be heading into
its finalWaitOne()(AssemblyRunner.cs:263); the worker then dies with an unhandled
ObjectDisposedExceptionon a pool thread, which kills the whole process. That is how one
flaky nested run sank an entire green CI leg: a leaked event in a finishing process is
harmless, a disposed-while-awaited event is a process kill.
v0.6.0
Added
-
World rollback (issue #2, stage 1):
[AtlasScenario(RollbackWorld = true)]restores the class
host's world to its snapshot before the scenario runs, in the same lifecycle slot where
FreshWorld = truerecycles the whole host today, but without rebooting the server (measured
at roughly 25x faster than a recycle on the baseline superflat world). The snapshot is
captured lazily, once per host, at the class's first rollback-enabled scenario, so classes
that never opt in pay nothing. A rollback restores blocks, block entities, chunk-stored
entities, chunk moddata, savegame data and the calendar (dimension 0); it does NOT restore mod
in-memory state that ignores chunk/entity lifecycle events, nor in-memory map chunk state
(height maps, map moddata). Fail closed: if capture or restore fails for any reason (including
engine internals drifting in a future game version), Atlas logs a one-line warning to stderr
and falls back to the full host recycle, so the scenario still gets its clean world. Guard
rails: requesting a rollback on a class that has joined test players fails the scenario with
AtlasSetupException(player state would not be rolled back; players + rollback is a later
stage), and combiningRollbackWorldwithFreshWorldis a setup error. -
atlas run --worker: worker execution mode for the CLI, stage 1 of the multi-process
parallelization design (issue #1).--workerruns the assembly exactly like plainrun
(one process, sequential, same exit codes) but reports exclusively as line-delimited JSON
events on stdout:run-start,class-start,test-pass/test-fail/test-skip,
class-end,error,run-end, every line versioned withv: 1. All human and engine
chatter (the embedded server logs to the console) is rerouted to stderr, and a fail-safe
guarantees the stream always ends with a well-formedrun-endeven when the run crashes.
--classes <A,B>(worker mode only; a usage error without--worker) restricts the run to
the given scenario classes by exact fully qualified name, and--list --workeremits one
discoveredevent per scenario without booting anything: together they are the seam the
stage 2 orchestrator (--parallel N) will drive. The protocol contract is documented in
docs/specs/2026-07-06-worker-protocol.md. -
atlas run --parallel [N]: multi-process orchestration over the assembly's scenario classes,
stage 2 of the parallelization design (issue #1). The orchestrator discovers the classes
without booting anything, then drains a greedy per-class queue with N worker subprocesses
(each oneatlas run <dll> --worker --classes <class>: one live server per worker, one class
per dispatch, workers pull the next class as they free up). N defaults to
min(max(1, cores / 2), class count). Results stream back over the stage 1 JSONL protocol and
print live per test; the final summary adds per-class wall clocks and the speedup versus the
sum of class times (what running them back to back would have cost). A worker that dies
without a well-formedrun-end, exits nonzero without a failing scenario, or outlives
--worker-timeout <seconds>(default 600 per class; the whole worker process tree is killed)
is translated into a synthesized failed class carrying a stderr tail for forensics, and the
queue keeps draining: a crashed worker can fail its class, never shorten the test list.
--trx <path>writes one aggregated VSTest-style TRX report covering every class, so CI
artifact upload and TRX tooling keep working withoutdotnet test.--parallelis
incompatible with--workerand--list;--worker-timeoutand--trxrequire it.
v0.5.0
Added
-
atlas runCLI facade (issue #3): adotnet tool(packagePixnop.Atlas.Cli, command
atlas) that executes the Atlas scenarios of a compiled test assembly without VSTest,
through the same in-process xunit runner the engine's own nested E2E tests use. One process,
sequential, embedded server booted exactly as underdotnet test.atlas run path/to/Scenarios.dllstreams per-scenario PASS/FAIL lines with durations and a summary,
and exits non-zero on any failure (an empty run counts as a failure, so a typo'd filter
cannot go green in CI);--filter <substring>selects scenarios by display name (ordinal,
case-insensitive);--listprints the discovered scenarios without booting anything.
VINTAGE_STORYis validated up front with the same check as the engine's boot, so a missing
install fails fast at the CLI boundary. Building block for future multi-process
parallelization (issue #1). -
Prebuilt world saves:
[AtlasWorld(SaveFile = "fixtures/myworld.vcdbs")](or
WorldOptions.SaveFile) boots the scenario class against a copy of the given save instead of
generating a fresh world. Any file name works (the copy is renamed to the engine's pinned save
name), the fixture itself is never written to, and each test class gets its own pristine copy,
so tests cannot corrupt the fixture or each other.Seed,WorldTypeandPlayStyleare
ignored when a save is supplied; the savegame carries its own world configuration. A missing
fixture fails the boot with anAtlasSetupExceptionnaming the path. -
ITestPlayer.IsConnected: first-class "was the player dropped by the server" signal.false
once the server has removed the player (kick, ban); test players never leave on their own, so
afalsevalue always means the server ended the connection. Kicks issued from a background
thread settle a few ticks late (see the zombie-kick fix below), so wait with
await world.Until(() => !player.IsConnected)rather than asserting right after the kick.
Fixed
-
Kicked test players no longer linger as zombies (kick-on-join left the player in
AllOnlinePlayerswithConnectionState == Admitted, a still-ticking half-despawned entity,
and per-tick "Exception thrown while calculating near heat source strength" warnings). Root
cause: mods that kick from a thread-pool thread (e.g. after an HTTP check inside a PlayerJoin
handler, the Nimbus.ServerMod pattern) crash the engine's own teardown -
ServerMain.FrameProfileris[ThreadStatic], so off the game threadDespawnEntitydies on
aNullReferenceExceptionafter the PlayerDisconnect event fired but before the client and
entity registries were cleaned - and the kicking mod's owncatchusually swallows the crash.
A real TCP client self-heals because its socket close re-runs the teardown on the game thread;
Atlas's dummy socket has no close semantics, so Atlas now supplies that second run itself
(KickedPlayerCleanup): when a dropped test player is still registered, the teardown is
re-run on the game thread, the player's TCP socket slot is released, and the joined-name claim
is freed so the scenario can rejoin under the same name. -
Missing-pdb preflight: a
VintagestoryAPI.dllcopied into the test output without its
VintagestoryAPI.pdbused to kill every scenario at server boot with an opaque
TypeInitializationException(NullReferenceExceptioninLoggerBase..cctor- the game's
logger derives source paths from pdb debug info in its static constructor). The boot now fails
fast with anAtlasSetupExceptionnaming the directory and the fix, andAtlas.E2E.targets
additionally warns at build time when the dll lands in the output without its pdb. -
Pre-boot data path seeding:
[AtlasDataFiles(...)](assembly- or class-level, repeatable)
copies fixture files or directory trees into the embedded server's scratch data path before
ServerMainlaunches, so mods that read their config once inStartServerSidevia
api.LoadModConfigsee the seeded file instead of booting unconfigured. Point a fixture folder
at a data-path subfolder ([AtlasDataFiles("fixtures/ModConfig", TargetPath = "ModConfig")])
or lay the fixture tree out like the data path and overlay it onto the root
([AtlasDataFiles("fixtures/serverdata")]). Assembly-level seeds apply first, then
class-level, so class-level files win on a name collision; missing sources and target paths
escaping the data path fail the boot withAtlasSetupException.samples/SampleConfigModplus
ConfigScenariosinsamples/Sample.Scenariosdemonstrate the end-to-end pattern.
v0.4.1
Fixed
- The embedded server now pins the process current directory to the Vintage Story install at
boot (issue #32). The engine's mod loader scans mod dlls with Mono.Cecil's default assembly
resolver, whose search path is the current directory: a test run launched from a directory
holding noVintagestoryAPI.dllcopy failed every base-game mod's ModInfo scan, loaded zero
mod systems, and crashed the boot inselectPlayStyle. Atlas resolves consumer mod paths
against the test assembly's location, never the current directory, so nothing else observes
the change.
v0.4.0
Added
- Concurrent test players (issue #26):
JoinPlayercan now be called multiple times on the same
world, one call per distinct player name, and the joined players coexist and act independently
(own connection, own inventory). Internally, each player rides its own dummy TCP socket: the
engine's packet loop iterates whatever socket array is installed, so Atlas grows the array by
one slot per player instead of multiplexing one slot; the single dummy UDP server the engine
hard-wires every singleplayer-type client to is shared. Joining twice under the same name still
throwsAtlasSetupExceptionup front - the server would treat the duplicate as the same
account reconnecting and kick the first player mid-scenario.
Changed
- Breaking:
IWorldSession.ExecuteCommandnow returnsTask<CommandResult>instead ofvoid
(issue #25).CommandResultcarriesOk, the localization-resolvedMessage, and the
engine's rawTextCommandResultas an escape hatch. Commands run as the engine's own console
caller (admin role, every privilege); deferred (async-parsing) commands are followed to their
final result; an unknown command completes withOk = falseinstead of throwing, so scenarios
can assert on intentional failures. Scenarios that drove a fixture mod through commands no
longer need the SaveGame side channel to read outcomes, and a slashless command now throws
ArgumentExceptioninstead of being silently misparsed by the engine's dispatch.