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.