How game files become extracted/, how the game gets launched, and how the mech3ax fork is
maintained. For which archive types extract and how far each is validated, see
extraction; for the engine's own flags, cli.md.
Populated by the engine's extraction (below), mirroring the game's own ZBD folder structure:
top-level planes.zip (unzbd of planes.zbd), zrdr.zip, soundsh.zip/soundsl.zip, interp.json,
rimage.zip, plus per-chapter C1/gamez.zip, C1/texture.zip, C1/rtexture*.zip, C1/zrdr.zip,
and per-mission C1/IA1/zrdr.zip.
The viewer's defaults read planes.zip, C1/gamez.zip, the chapter's top texture tier (below),
zrdr.zip and soundsh.zip, preferring the unpacked sibling folder when present
(extracted/C1/rtexture15/ over C1/rtexture15.zip). Each loader takes a zip or a directory, and
an explicit --gamez=/--textures=/--zrdr=/--sounds= wins.
Chapter textures load from the top rtextureN tier, falling back to texture.zip
(SessionPaths.ChapterTextures), where N is a size budget in MB of video-card texture
memory. ⚠ The tiers are not mere downscales of texture.zbd, which a resolution comparison
misses: the top tier holds the same file set at the same dimensions, but hundreds of those files
differ in pixel content, and the tier copies are richer, never worse (see
formats/hud.md on the gauge needle). rimage.zip is not loaded at all.
extracted/rof/ is the extraction's second half and holds the unpacked .rof UI
archives plus ui_strings.json. PatternLibrary reads the paint patterns out of it (--rof=,
default extracted/rof).
One implementation, CSVM/src/Extraction/ (architecture/Extraction.md),
reached three ways: the Extract screen a player sees when extracted/ is missing or stamped under
another schema, the headless --extract=<install> flag (cli.md), and the repo-root
wrapper below. No script holds extraction logic.
The ZBD half walks the install's ZBD folder and runs unzbd cs <mode> on every archive as a
child process, with the mode for its type, writing to the mirrored relative path under
extracted/, basename kept:
| Source | Mode | Output |
|---|---|---|
interp.zbd |
interp |
.json |
planes.zbd, gamez.zbd |
gamez |
.zip |
soundsh/soundsl |
sounds |
.zip |
zrdr.zbd |
reader |
.zip |
rimage, texture, rtexture* |
textures |
.zip |
cam_anim, mis_anim |
anim |
.zip |
An output newer than its source is skipped unless forced. messages.json comes from a step
after the walk, since strings.dll sits at the install root outside it: unzbd cs messages,
skipped with a note when the DLL is absent, in which case the engine falls back to raw MSG_*
keys.
The .rof half covers the UI archives (GOSDATA/ASSETS/crimson.rof plus the crimptch.rof patch
overlay), the loose cinemas and the langui.dll/language.dll string tables, all into
extracted/rof/. It writes each member at its archive path in upper case, decodes each .BM texture to
<NAME>.PNG (the greyscale shading map) and <NAME>_MASK.PNG (the paint region masks, R/G/B =
paint slots 1/2/3), emits ui_strings.json, every UI string joined to its RESOURCE.H symbol, and
menu_layout.json, the decoded LAYOUT.CSV screens the runtime reads instead of the
originals. Formats: rof, strings,
menu layout.
A failure-free run stamps extracted/VERSION.json with the unzbd --version line verbatim, the
exe's SHA-256, the fork HEAD, the date, and ExtractionStamp.Schema, the one schema integer, which
the engine compares at boot and which any reader change that invalidates old extractions bumps.
.\Extract.ps1 [-Install <path>] [-DataRoot <path>] [-Unzbd <path>] [-Unzip] [-Force] [-NoBuild]
builds the solution, runs Godot --headless with --extract=<install> --data-root=<root> --unzbd=<tool> plus --extract-unzip / --extract-force, prints the engine's output, and exits
with its code. The install defaults to CrimsonSkiesGame, the tool to the fork build
(tools/mech3ax/target/release/unzbd.exe), both next to the script, else under
CSVM_DATA_ROOT as a worktree needs. -Unzbd pointing at the pinned
tools/mech3ax-v0.6.1-.../unzbd.exe rolls back to the old binary with no code change, because
the loaders read either extraction shape. -Unzip expands each .zip into a sibling folder, which
is what makes the viewer read loose files. ⚠ The data root defaults to the script's own folder,
never CSVM_DATA_ROOT, so a worktree run cannot overwrite the primary tree's extracted/.
RunGame.ps1, the play entry point. dotnet build, then Godot with no user args, so the
launchscreen (src/UI/Screens/LaunchMenu.cs, Mode → Chapter → Plane) shows. Args are forwarded verbatim,
so a content arg (--fly/--stunt/--plane=/--chapter=/--screenshot=) bypasses it.
RunDev.ps1, the dev helper, same build step but with console prompts: no args gives
interactive menus (plane roster, chapter) and then --fly, flight flags prompt for what is
missing, and the static views (--plane=, --chapter=, --damage=) pass through promptless
apart from --damage='s own plane prompt.
RunTests.ps1, the verification entry point. One command, one summary block, one exit code.
Stages, in order, each reported PASS / FAIL / SKIP / TODO:
| Stage | Runs / reads its verdict from / fails on |
|---|---|
build |
dotnet build CSVM/CSVM.sln; its exit code; a compile error, which also stops the run |
units |
dotnet test --no-build; the TRX log in .scratch/testresults/, never the console summary; any failed test |
engine |
Godot --run-tests in windowed processes for the suites that need a renderer or compile their shaders (LOG-8), -Shards headless ones and a synthetic one; each shard's JSON report; a failed suite, a missing report, a watchdog timeout (exit 124), a shader text no windowed process compiled |
goldens |
One --det Godot per manifest shot; the raw-pixel md5 on its [core] shot pixmd5=… line, not the PNG bytes (SHOT-6); a hash, frame or size off the manifest |
perf |
-Perf only: analysis/perf/scenarios.json; the [perf] lines, medianed into perf-history.jsonl; nothing, it records only |
hitch |
-Hitch only, last: two scripted launches; each launch's .hitches.jsonl sidecar; nothing, awareness only |
Switches: -Suite <name>[,<name>] (exact in-engine suite names), -Filter <substring>
(engine suite names), -UnitFilter <expr> (into dotnet test --filter), -Shards <n>,
-Quick, -SkipUnits, -SkipEngine, -SkipGoldens, -RegenGoldens,
-GoldenWorkers <n>, -Hitch, -SkipHitch, -Perf (+ -PerfLabel,
-PerfCompare, -PerfFilter, -PerfIterations, -PerfFrames), and -Graphics original|enhanced (default original, which appends nothing; enhanced appends
--graphics=enhanced to the perf and hitch launches only), -RealData (the ci tier on the
real extraction too, below), and -WaitQuiet (+ -QuietTimeoutSec, below).
-WaitQuiet is the one way to wait for a quiet machine. Before the build it waits until no
scripted CSVM Godot runs on the machine: any Godot whose --path names a CSVM folder, from any
tree, that holds a live ledger reservation or carries a scripted flag (--det, --run-tests,
--frames=, --shots=, --screenshot=, the set the ledger admits). An interactive session is
not waited on: the user's own play (RunGame.ps1 without a scripted flag, an editor's F5 run)
and an open editor are left out, since holding test runs while the user plays is gaming mode's job
(below); a play session started with --det counts as scripted. It prints each one it waits on as
pid <n> <ledger kind or unledgered> <worktree>, again when the set of worktrees changes and every
60 s. The memory ledger already queues every launch on memory; this waits out the CPU and GPU
contention of runs that fit side by side, which is what slows a shard and runs a golden shot past
its watchdog. Past -QuietTimeoutSec (default 1800 s) the run ends DEFERRED: quiet, exit 3,
without building. Wait-QuietMachine in MemoryLedgerCore.ps1 is the code, and MemoryLedger.ps1 -SelfTest checks
its census.
A rebuild under a run stops it. The build stage records CSVM.dll's write time and size;
every launch pool (shards, golden shots, perf, hitch, the golden retry) checks them once a second,
and reads each running launch's --log-file while it is under 64 KB for Cannot instantiate C# script, the line a launch that met a swapped assembly prints before it sits until its watchdog.
Either one prints DEFERRED: rebuild, <cause> ..., closes the run's job, which kills every launch
at once, and ends the run DEFERRED, exit 3: a stopped launch counts as never started, what finished
before it is still judged, and later Godot stages are skipped. The shard-hash compare (METHOD-6)
remains the proof after the fact. .\RunTests.ps1 -SelfTest checks the trigger against a scratch
DLL and log without building. A red summary names docs/verification.md's known environmental
reds.
Every test stage prints its wall time against a budget, and a budget never fails a run.
The numbers live in analysis/verification-budgets.json, one lane for the complete gate and one
for -Quick, and live nowhere else so they cannot drift; each is the slowest of three
back-to-back warm runs plus 50 %. A skipped stage is compared against nothing. Build carries no
budget, since only cutting features shortens it. There is no total budget: one set by the same
rule is never below the stage budgets' sum, so it could only trip after a stage had. The engine
budget is capped at 80 % of a shard's 1200 s ceiling, so no budget written into the file sits at
the kill line, where a stage would go red without printing over budget first.
A shard's watchdog judges progress, a golden shot's wall time. An engine shard is killed (exit
124) once its --log-file has not grown for 120 s ($EngineStallSec), or at a 1200 s ceiling
($EngineCeilingSec, about 1.7 times the slowest loaded shard measured) that ends one which hangs
while still logging; the stage names which, as sN: killed, its log did not grow for 120s or sN: killed, ran past its 1200s wall-time watchdog. A shard prints a [test] suite line per suite as
it finishes, and under another run's load it slows but keeps printing, where a hung one stops. A
golden shot keeps a 300 s wall-time watchdog ($GoldenWatchdogSec), since it logs nothing while
it renders its frames; the perf and hitch launches carry none. Gaming mode's watchdogFactor
scales all three numbers alike. Invoke-GodotPool polls the log's length
beside its rebuild check, and .\RunTests.ps1 -SelfTest drives the rule through the pool with
stand-in launches: one whose log keeps growing outlives the stall time, one that never writes is
killed at it, and one that never stops writing is killed at the ceiling.
A selection that matches nothing is a failure: -Suite, -Filter and -UnitFilter each fail
their stage naming the term, rather than reporting a green zero.
-Quick is the broad partial gate: build, the quick unit tier (--filter Tier=Quick), the
quick engine tier (--run-tests=tier:quick), no goldens and no hitch. Membership of both tiers is
checked in, as the [Trait("Tier", "Quick")] classes and SuiteCatalog.QuickTier. An explicit
-Suite/-Filter unions with the engine tier and a -UnitFilter replaces the unit tier. Quick
prints a not checked: line per omitted surface, and never satisfies the landing gate.
The ci engine tier (--run-tests=tier:ci) is what CI requires, and a skip in it fails. Its
membership is checked in as SuiteCatalog.CiTier, one name per line and sorted. A suite earns its
place by passing RunCiSuites.ps1 (below): headless, with no extraction but the --synthetic-data
tree of invented records, with no IPv6 loopback and no OS shell or real display, and by being shown
to go red when one of its inputs in that tree is broken. It joins when it stops needing the install,
and leaves rather than taking an exception when it starts needing one. The plane and Original-shell
suites skip without the switch, so the tier is only green with it. The suites on the headlessOnly
list in analysis/headless-limits.json (a unit test keeps them off the tier), enet-dual-stack,
enet-stable-ipv6-reply and build-stamp-focus are out for that reason.
The ENet and LAN discovery suites bound to 127.0.0.1 are in. A network match suite flies the
install's C1/MP1 (Capture the Flag C1/MP2) when the data root carries it, and otherwise
--stage=empty with its own spawn table and arena of flags and rearm nodes, and notes which. The tier carries
SuiteTier.SkipFails, so a listed suite that SKIPs reports FAIL with its skip reason and the run
exits nonzero; any other selector, including suite:<name> on a listed suite, keeps SKIP as a
non-failure, so the local battery is unchanged. The tier checks mechanisms on invented or absent
data and never replaces the full local battery. A suite whose install reads sit in a few legs
splits rather than staying off: those legs keep the original name and its RequireData gates, the
rest move to a <name>-core suite on the tier, and no leg runs in both, so the local battery still
makes every check once.
RunCiSuites.ps1 is the CI engine stage, and runs locally on Linux or macOS the same way. The
engine job in .github/workflows/checks.yml runs it on ubuntu-latest after dotnet build CSVM/CSVM.sln and one <godot> --headless --path CSVM --import, with Godot 4.7 .NET downloaded
from the official release, checked against the release's SHA512-SUMS.txt and cached per release.
A contributor runs those two commands and then ./RunCiSuites.ps1 -Godot <path to the mono build>
(-Selector for another selector than tier:ci, -NetPortBase, -TimeoutSec). It starts one
headless Godot with CSVM_DATA_ROOT at a fresh empty folder, --synthetic-data (so the run reads
the invented tree written for its process id, and the report must name that root and
syntheticData: true; -NoSyntheticData runs over the empty folder alone) and the XDG folders inside
.scratch/ci-suites/, which it wipes first and which receives godot.out, godot.err and a copy of
the screened engine log; the job uploads those, .scratch/test-report.json and .scratch/logs/ on
every outcome. ⚠ The verdict comes from the report, not from Godot's exit code. A headless
process prints engine error lines a windowed one does not, such as menu-player-setup-journey's
text-server lines. A headless harness adds the headlessEngineErrors patterns of
analysis/headless-limits.json to its allowlist, uncapped, and the script also excuses an
unexpected line matching one, the mechanism sandbox/LinuxRelease.ps1 uses; any other unexpected
line, an allowance over its cap, an unscreened log, a FAIL, a missing or stale report, or a crash
exit fails the run. It refuses to start on Linux without libfontconfig.so.1, for the reason the
Linux release check gives.
The engine stage runs the full catalog in concurrent Godot processes of three kinds, which the
harness's render:<kind> term selects (SuiteRender). Windowed runs every suite the
headlessOnly, headlessDiffers and headlessShaders maps of analysis/headless-limits.json name:
the suites that need a renderer, every suite that switches graphics modes (a switch makes texts for
every shader key its process holds, so what it makes depends on the shard), and the ones whose
shader texts no other windowed suite makes, so a windowed process still compiles every text the
catalog makes. Synthetic is one headless
process for SuiteCatalog.CiTier on the --synthetic-data tree, as CI runs it. Headless shards
run the rest on the real extraction; they compile no shader and upload nothing, so each holds about
a third of a windowed shard's memory and has its own ledger kind, engine-headless. Both headless
kinds run --render-thread safe: under the project's separate render thread the dummy renderer's
RID owners race (godot#121949), which prints a spread of engine errors at no fixed count. -RealData
also runs the ci tier on the real extraction, inside the headless shards, with the synthetic
process kept as an extra pass: pass it before a push, at a plan's close and on a merge to main.
-Shards <n> sets how many headless shards divide their share; the default is 6 for a full run and
1 whenever -Suite/-Filter/-Quick names a selection. Membership comes from the harness's
shard:<index>/<count> term over the per-suite weights at analysis/engine-suite-weights.json, so
the same tree always divides the same way; an unweighted suite is charged the default and printed
as not checked:. A suite the file lists under alone gets a windowed process of its own,
which keeps graphics-retext-compiles's first TAA frame out of a shard holding worlds: a full
run's default is one windowed shard, the alone shard, six headless shards and the synthetic
process, nine processes. A selection keeps one process per kind, since it never holds a full
shard's worlds. Regenerate the weights from the shard reports of one warm full run, as its
readme says.
A full run fails when a headless process made a shader text no windowed process compiled, and
names the suite: each report lists every suite's shader texts as hashes (ShaderCensus), and the
merge holds the headless shards' texts against the windowed ones'. The fix is to add that suite to
headlessShaders. The synthetic process is not held to it, since invented data makes texts the
game never does, so only a -RealData run checks the ci tier's texts.
Each shard gets its own log, streams, report and artifacts under .scratch/engine/owner-<pid>/;
what that does not isolate is a suite whose store sits outside .scratch/, so overlapping runs are
not safe (LOG-13). The stage's verdict is the merge of every shard's report, in registry order
via each suite's index: counts sum, the error allowlist's caps are re-checked against the SUMMED
counts, and a shard exiting 0 with no report FAILS the stage.
Every shard opens its sockets from its own --net-port-base. The stage first claims a slot, a
lock file under %TEMP%\csvm-net-ports\ held until every shard exits, so a second run from another
worktree takes another slot. Shard k of slot s gets base 40000 + (10 * s + k - 1) * 100: seven
slots of ten shards, below the shipped 47500/47501 a game played on this machine holds and below
Windows' ephemeral range. A run of more than 10 processes (every kind counted) is refused. Each report's shard.netPortBase must equal the base its shard was handed and differ from
every other shard's, or the stage fails (LOG-24).
test-report.json's schema is versioned ("schema", bumped when a field changes meaning or
goes). Besides the per-suite rows and their registry index, it holds a binary block
(the loaded CSVM.dll's path and MD5), the run's selector, a shard block and a phaseTotals
block splitting wall time into world build, disposal and the rest, so a report can be matched to
its build and the shard merge proved complete. Read the current field list from a report.
The golden stage is scripted, not an in-engine suite, because the --run-tests harness runs
every suite inside one _Ready call without yielding a frame, so no suite there can photograph
anything. A mismatch leaves the actual PNG and that shot's log in
.scratch/goldens/<shot>.{png,log}. -RegenGoldens re-renders every shot and rewrites
manifest.json in place, byte-identically apart from the hash lines that moved; GOLD-1 says when
that is the right answer.
A shot's frame is checked before its hash, off the capture's own frame=N clock=<sim|render>
field, because a shot that photographed a different moment is a clock regression and reads as
neither a pass nor a pixel change once it is folded into the hash compare. The capture names the
counter, never the harness: a run with a session reports its sim clock, which under --det advances
one step per rendered frame, and a screen with no session reports the rendered frames its countdown
waited, read off Godot's own counter rather than off the countdown itself. Both answer the same
question, so one manifest number covers a flight shot and a menu shot alike.
Golden shots launch concurrently, -GoldenWorkers of them at a time (default 4), in
registry-order batches, each with its own watchdog, process, log and PNG. The default is
the fastest worker count that stayed bit-identical on the one machine measured, and
-GoldenWorkers 1 is the serial reference path.
The hitch stage is opt-in (-Hitch), not part of the landing gate, because it never changes
the exit code: run it when landing a change to HitchMonitor.cs, HitchSidecar.cs or the hitch
tick in Launch/FrameInstruments.cs. -SkipHitch forces it off even when -Hitch is given, and -Quick
never runs it. A clean --frames=180 launch should
stay silent and --hitch-inject=50@300 --frames=310 should trip once on frame 300; it runs last so
its evidence is never taken beside another stage's load (LOG-13, PERF-12/13/14).
A duration here is a count of SIM frames, never wall seconds. Each scenario runs
--det --perf --no-vsync --mute plus --frames=N --screenshot=, and the saved-shot line prints
sim_frame=N so the count is proved rather than assumed (every perf scenario runs a session, so
its captures are always sim-clocked). --perf reports
a [perf] window … line per 60 rendered frames and a [perf] startup … line that closes
arithmetically over the build; the script parses both.
Scenario set (analysis/perf/scenarios.json, whose args are the literal command a human
re-runs): empty-stage (no gamez, the control a world or clutter change must leave alone),
c1-flight (the only moving camera), c2b-water, c4-terrain and c5-city. Defaults are 300 sim
frames × 3 launches, overridden by -PerfFrames and -PerfIterations; -PerfFilter narrows the
set.
Protocol and verdict. The first launch of each scenario is discarded, because a cold file cache
reshapes a startup profile instead of scaling it (PERF-7), and each kept launch drops its first
window, which carries shader compilation. The record stores medians plus commit, --dirty flag,
GPU string, the launch's "vsync" mode and the md5 of the loaded CSVM.dll (METHOD-6).
A paired A/B is the only verdict: -PerfLabel base, flip the one line under test (METHOD-5),
rebuild, then -PerfLabel change -PerfCompare base prints the ratios, calling identical hashes a
noise floor.
What is read and what is refused. The verdict metrics are render_cpu_ms, gpu_ms, the counts
draws / prims / nodes and the startup phases, and a row is marked * only when it clears
both a relative band and an absolute floor, each this machine's own noise (PERF-9…PERF-11).
Every other recorded metric prints as awareness only, each because its own instrument is unfit for
a ratio (PERF-1, PERF-2), with the reason in the manifest's notes.
Exit-code contract: 1 if any stage FAILED, 0 otherwise, and a skip is not a failure. A missing
dependency and -SkipUnits/-SkipEngine report SKIP at 0 with a not checked: line, so "the
data was not there" never reads as "the check held". -RegenGoldens reports REGEN, never PASS.
Worktrees. Extracted data and Godot are both found through CSVM_DATA_ROOT, so
$env:CSVM_DATA_ROOT = 'Z:\CSVM'; .\RunTests.ps1 from a worktree behaves identically to the
primary tree; without it the engine stage reports SKIP with the path it looked at. The unit tests
fall back to their own checkout, so a root holding no extraction skips the engine suites but not
the data-dependent units. The other variable a scripted run may want is
CSVM_TRACE_SISCRIPT=<node name>, which makes ScriptPlayback log a sitrace line in the anim
category with that node's per-step pose, dt and step distance every tick.
Stray Godots are killed before the engine, golden, hitch and perf stages, filtered to this tree's
project dir AND an argument only that stage's own launches carry (SHELL-2): --run-tests, or the
.scratch\goldens\ / .scratch\hitchcheck\ / .scratch\perf\ output paths. All quit by
themselves, so one still alive is stuck and ours; any other Godot here is left alone.
All three scripts set SDL_JOYSTICK_DIRECTINPUT=0, respecting a pre-set value, as the
controller-disconnect freeze workaround: Godot's bundled SDL hangs the main thread forever when a
255-button DirectInput device disconnects, and disabling that backend removes the phantom views while real pads keep working via XInput/HIDAPI. Removal conditions are in
backlog.mdunder "Drop theSDL_JOYSTICK_DIRECTINPUT=0workaround".
CSVM/export_presets.cfg (committed) holds one preset, "Windows Desktop": release export,
x86_64, embed_pck=true, so a single CSVM.exe with the pck inside, plus the self-contained
.NET publish output beside it as data_CSVM_windows_x86_64/. The exported build resolves every root
to the exe's own folder: it reads extracted/ from there and writes its logs to a plain logs\
beside the exe rather than to the .scratch\logs\ a repo run uses (Log.DirectoryFor is the one
switch); F12 screenshots go to a Screenshots\ folder beside the exe (CaptureDirector.ShotDirFor).
Per-user state is not in that folder at all: options, bindings, campaign profiles, scores
and custom planes are written through user://, which is %APPDATA%\Godot\app_userdata\CSVM.
One-time template install. The Godot export templates are user-global, not part of the repo's
pinned editor: extract the inner templates/ FILES of tools/godot-4.7-mono-export-templates.tpz
into %APPDATA%\Godot\export_templates\4.7.stable.mono\ (create the dir; do not keep the
templates/ level).
ExportRelease.ps1 (repo root) run with no parameters runs the whole Windows sequence: it checks the
export templates, the fork-built tools\mech3ax\target\release\unzbd.exe and the rest of the
payload exist (named errors up front), empties .scratch\export\,
builds, imports headless, exports, copies the payload in beside the output, and zips the folder to
.scratch\CSVM-v<version>-win64.zip. That folder is cleared because all of it is zipped, and the
clear refuses to run if it holds a junction, since PowerShell 5.1's recursive delete follows one
into its target. In a worktree, which has no tools\, it takes Godot, unzbd.exe and the mech3ax
checkout from the tree CSVM_DATA_ROOT names, as RunTests.ps1 does.
-Linux is opt-in and leaves the Windows zip as it is. After the zip it exports the
Linux/X11 preset into .scratch\export-linux\, stages the Linux payload (packaging/MANIFEST.md's
Linux table) and packs .scratch\CSVM-v<version>-linux-x64.tar.gz inside WSL (wsl -d Debian),
since only a tar written on Linux can carry the executable bit: the files are copied into the
distro's own filesystem, given 0755 (CSVM.x86_64, tools/unzbd, directories) or 0644
(everything else), and archived root-owned. The script reads both executables' modes back from the
archive and prints its SHA-256. Before the Windows build starts, the script builds the Linux
unzbd from the same tools\mech3ax checkout as unzbd.exe: cargo build --release --locked --target x86_64-unknown-linux-musl --bin unzbd inside WSL, with CARGO_TARGET_DIR at
~/cargo-target/mech3ax in the distro (a target dir on /mnt/z is slow and would put a Linux tree
in the checkout), then copies the static binary to
tools\mech3ax\target\x86_64-unknown-linux-musl\release\unzbd. -LinuxUnzbd <path> ships an
existing binary instead and skips the build. A missing toolchain, target or compiler, a missing
-LinuxUnzbd file, a missing Linux export template or an unreachable WSL distro is a named error
before the Windows build starts. One-time setup in WSL Debian: sudo apt install build-essential curl musl-tools, rustup from https://rustup.rs (into ~/.cargo/bin, where the script looks),
then rustup target add --toolchain 1.91.1 x86_64-unknown-linux-musl. The toolchain is the one
tools/mech3ax/rust-toolchain.toml pins; adding the target to stable does not reach it. The
musl unzbd of a given fork commit writes byte-identical archives to unzbd.exe's. The engine csproj sets
InvariantGlobalization, so the self-contained .NET runtime never loads libicu; without it, a
system lacking that library (the author's WSL Debian among them) aborts at startup with "Couldn't
find a valid ICU package installed on the system". sandbox\LinuxRelease.ps1 checks the tarball
this writes (see "The Linux release check in WSL" below), and PublishRelease.ps1 publishes
it beside the zip ("Publishing a release").
The version has one home: application/config/version in CSVM/project.godot. Bump it there
and nowhere else. The engine reads it at startup for the log's first line and the menu's corner
stamp; the Windows export preset stamps it into the exe's file and product version, which is what
application/modify_resources=true in the preset is for (off, the export succeeds and ships an exe
whose Properties pane still names Godot's own template); and ExportRelease.ps1 reads the key back
to name the zip. The script reads the stamp off the exported exe afterwards and throws if it is not
the version it started from, since nothing else about a missing stamp is visible. ⚠ The script
snapshots and restores project.godot around the export and is its only writer, the version is
read from that file, never written into it, and never stamped into the preset during a run.
The zip is built through System.IO.Compression, since Compress-Archive reports success after
writing nothing when a single file is locked.
Which number to bump is decided per release, against what changed since the last tag
(git log v<last>..HEAD), in the commit that is about to be published; builds between releases
keep stating the last released version, and BUILD-INFO.txt names the exact commit. The patch
number is for a release that only fixes, including a fix that brings behaviour closer to the
original; a player finds nothing new in it. The minor number is for a release that adds
something a player can see (a mode, a screen, an input device, a mechanic), which is where a
milestone lands; fixes shipped alongside a feature do not make it a patch. The major number is
the author's call that the remake stands in for the original end to end; until then the version
stays 0.x, and afterwards a major bump is reserved for a change that breaks saved profiles or
replaces a subsystem wholesale. ⚠ A patch release never changes the format of anything written to
user:// and never changes the network protocol, so builds that differ only in the patch number
read each other's profiles and can play in the same session. The network half is enforced at the
join: each lobby sends its major.minor of BuildVersion (Net/NetBuildVersion.cs), the host
refuses a mismatched guest through SessionClosed with both versions named, and the LAN games list
greys a game of another major.minor.
The payload is packaging/MANIFEST.md's table, copied from its repo sources on every export, which
keeps it byte-identical (the tarball's text files as git stores them, LF; see below).
packaging/README.md is the whole of what a downloader is told, written for someone who found
the zip on the releases page and knows nothing else about the project: where the download comes
from and how to check its SHA-256 against the release page, the requirements including the renderer
floor below, the in-game extraction on first start, the logs\ and user:// locations, what the other files
at the zip root are, and where a report goes. Four things in it restate facts that live in code or
in this file, and go stale silently when one of them moves: the renderer floor and the
[perf] gpu= line a below-floor machine writes, the log directory and the version on the log's
first line, the extraction screen's behaviour, and the payload list. ⚠ The author reviews it before
any release, since outward communication is theirs; it is the one payload file that is not
finished when it is correct.
packaging/README-linux.md is its Linux twin, shipped as README.md at the tarball root. It
drops the Windows-only material (SmartScreen, Direct3D 12) and adds the
community-tested label, the Vulkan-only requirement, unpacking with the executable bits, where the
original game's folder comes from (a copied Windows install; the Wine and Proton prefix search is
untested), the settings folder under ~/.local/share/godot/app_userdata/CSVM, and an "On Steam
Deck" section. It restates the same facts as the Windows README plus the install search places in
InstallLocator, and the author reviews it on the same terms.
Two of the zip's files are about the build rather than part of it.
LICENSE-thirdparty.txt carries the notices the payload's own contents oblige it to carry, which
LICENSE (CSVM, GPL-3) and LICENSE-unzbd (mech3ax, EUPL-1.2) do not cover: the Godot engine
linked into CSVM.exe, the engine's own third-party components, the self-contained .NET runtime in
data_CSVM_windows_x86_64\, and the Rust crates linked into unzbd.exe.
packaging/BuildThirdPartyNotices.ps1 assembles it, reading every text out of the shipped artefact
rather than off a website: ⚠ the Godot Windows distribution and the export-template archive ship no
LICENSE.txt or COPYRIGHT.txt on disk at all, so the script runs the pinned editor headless over
a throwaway project in TEMP and reads them back through Engine.get_license_text(),
get_copyright_info() and get_license_info(); the .NET half is LICENSE.TXT and
THIRD-PARTY-NOTICES.TXT from the Microsoft.NETCore.App.Runtime.win-x64 NuGet pack the publish
draws from, not the machine-wide dotnet install, which is usually a newer build; and the crate
half is cargo metadata --offline --filter-platform x86_64-pc-windows-msvc over the fork, with each
crate's licence text taken from the registry checkout it was built from and deduplicated by content.
Section 9 is the Rust standard library, which every Rust binary links (Apache-2.0 OR MIT, with
third-party crates of its own) and which cargo's crate list does not include. Its part A is the pinned
toolchain's share/doc/rust/COPYRIGHT-library.html as plain text, part B the licence texts that file
names for the library's own sources from share/doc/rust/licenses/, and part C the crates the
target's rust-std rlibs name as their sources (each rlib carries /rust/deps/<crate>-<version>/
paths) that the file does not list: none for msvc, std's backtrace crates (addr2line, adler2,
memchr, miniz_oxide, object) for musl, read from their crates.io releases, which the script
fetches into the cargo registry when absent. ⚠ The converter accepts only the tags that file uses
and refuses any other, and the script refuses an rlib that names neither a crates.io release nor an
in-tree library/ directory, so a changed layout stops the run instead of losing text.
The file's header states the Godot build, the .NET runtime version, the runtime pack, the crate
target and the cs-anim commit it was assembled for, and ExportRelease.ps1 re-checks all of them
against what it is packaging, so a stale notice is a build failure rather than a wrong claim inside
a shipped zip. Regenerate when one of them throws; ⚠ a moved cs-anim counts even when the fork's
own code did not change, because the crate list enumerates that commit's dependency tree.
The tarball ships its own notice, packaging/LICENSE-thirdparty-linux.txt under the same name
LICENSE-thirdparty.txt, written beside the zip's by BuildThirdPartyNotices.ps1 -Linux (which
rewrites both). Its .NET half is the Microsoft.NETCore.App.Runtime.linux-x64 pack of the same
version, its crate half is --filter-platform x86_64-unknown-linux-musl (the trees differ: libc,
addr2line, gimli and object on Linux, windows-sys and its companions on Windows), its
section 9 reads the WSL toolchain's files and musl rlibs, and a section 10 carries the musl C
library that Rust's musl target links statically into tools/unzbd, from packaging/LICENSE-musl
(the musl-1.2.3 release's COPYRIGHT, byte-identical; the bundled libc.a carries no text).
.gitattributes marks that file -text, so every checkout is upstream's LF bytes and can be
compared by hash. The Godot sections are the editor's, which is valid because the engine compiles its
licence tables from one COPYRIGHT.txt on every platform; the script runs the Linux template's
--version in WSL and refuses a template of another build. ⚠ The script also refuses a
rust-toolchain.toml pin other than Rust 1.91.1, because a new toolchain can bundle another musl
and section 10 would then name the wrong release, and section 9's converter was checked against
that release's file only. Neither notice may name the other platform's
runtime pack, crate target or file names: the script, ExportRelease.ps1 and
sandbox\LinuxRelease.ps1's payload stage each refuse one that does, so the zip's notice cannot
ship in the tarball. Neither notice has an SDL section; the zip carries SDL's own licence as
LICENSE-SDL2.txt, and the tarball ships no SDL2.
The tarball's text files are LF. Git stores every payload text file LF and core.autocrlf
checks it out CRLF, so the zip, a plain copy, carries CRLF, the Windows convention. The Linux
staging marks its text rows (README.md, LICENSE, LICENSE-unzbd, LICENSE-thirdparty.txt)
Lf in $LinuxReleaseFiles and drops the CR of every CRLF pair as it copies them, which gives the
committed bytes (git hash-object of the source equals git hash-object --no-filters of the staged
copy); BUILD-INFO.txt is written LF. This is done at staging rather than by a .gitattributes
eol=lf rule because LICENSE and LICENSE-unzbd also ship in the zip, and a rule would change
what every worktree checks out. sandbox\LinuxRelease.ps1's payload stage fails any top-level
tarball file without a NUL byte that contains a CR.
BUILD-INFO.txt is the one payload file generated rather than copied, because what it states is
different on every run: the CSVM commit and the mech3ax cs-anim commit the two shipped binaries
were built from, each with whether its worktree was clean, plus whether cs-anim is pushed. The
export records those qualifiers instead of refusing on them, since building off a dirty tree is the
normal development case and only a published binary makes a false source-correspondence claim to
anybody; PublishRelease.ps1 is where a qualifier becomes a refusal.
Two filters in the preset are required.
include_filter="data/*.json" keeps stock_loadouts.json/effect_pools.json, non-imported
resources the default export silently drops, without which every plane flies unarmed; their loaders
read them through Godot.FileAccess rather than GlobalizePath plus System.IO, which is what
makes the pck copies reachable. ⚠ exclude_filter="config.json" keeps the dev box's personal
tuning override out of every build. Smoke-test an export from a bare folder with a foreign
CWD and no CSVM_DATA_ROOT; either can mask a broken default root.
PublishRelease.ps1 (repo root) is the publish, from one run: it reads the version from
CSVM/project.godot, runs ExportRelease.ps1 -Linux, checks the zip and the tarball that came
out, computes their SHA-256s, creates the annotated tag on the commit that was built, pushes it, and
creates the GitHub release with both archives as its assets. Every release carries both platforms,
so publishing needs the WSL Debian toolchain that -Linux above describes. The pre-release flag
stays off, because a build that is hidden from the repository's Latest badge is not the one a
visitor lands on. Because the tag, the executables' stamped version, the archives' names, the
published checksums and the notes all come out of that single run, none of them can disagree with
another.
-NotesFile supplies the prose that goes above the generated sections; the script writes the
verification and provenance sections itself, so the file never states a checksum or a commit of its
own, and it refuses a notes file that does. The generated text restates BUILD-INFO.txt's two
commits as links, so the release page and the archive give the same account of what was built.
-DryRun runs every check and the export and stops before the tag. -TagSuffix rehearsal
publishes under v<version>-rehearsal instead: a real tag, upload and release to exercise the whole
path, deleted afterwards with the gh release delete ... --cleanup-tag command the run prints, so
the release version's own tag is still minted exactly once. -Yes skips the confirmation prompt,
which is otherwise the last point at which the tag and the upload can be called off.
The Linux tarball goes into the same release as the zip: one tag, two assets. It gets the
zip's checks (it exists and
postdates the run; its BUILD-INFO.txt, read out of the archive with Windows' own tar.exe, names
both commits and records no qualifier). Then sandbox\LinuxRelease.ps1 runs in full on the tarball
(see "The Linux release check in WSL" below), and a failure ends the run before the tag with nothing
created. The tree and HEAD re-check comes after that check, so it covers its minutes too. The
generated notes then list both downloads with their sizes and SHA-256 (Get-FileHash for the zip,
sha256sum for the tarball), link the Linux README's "On Steam Deck" section at the tagged commit
rather than restating it, and name both executables and both unzbd builds under the two commits;
the tag message carries both checksums and gh release create uploads both files. The Windows
Sandbox run (sandbox\PublicRelease.ps1) is not part of the publish; it is run by hand. The mech3ax checkout is
found the way ExportRelease.ps1 finds its tools, from CSVM_DATA_ROOT in a worktree, so both
scripts read the same cs-anim commit.
What it refuses. A dirty CSVM worktree, since the release says the zip was built from a commit.
A dirty tools/mech3ax cs-anim, or one that is not on its origin: unzbd.exe's source commit is
published as a fact about a binary in the zip, and a commit that exists only on this workstation is
not a source anyone can read. The CSVM commit needs no pushed-check of its own because pushing the
tag publishes it, though the script says so when HEAD is on no remote branch, since a reader who
opens the branch expects to find the release's commit in its history. It also refuses a BUILD-INFO.txt
that records a dirty or unpushed source, an existing release, and a tag that already exists either
locally or on the remote. ⚠ A tag is never re-pointed, which is why there is no -Force: it is
the source correspondence for binaries that may already be downloaded, and nothing on the
downloader's machine says it moved. The way to correct a bad release is another version, not another
meaning for this one. The tree and HEAD are re-read after the export for the same reason, since a
build takes minutes and an edit landing during one would tag a commit that is not what was built.
gh must be installed and authenticated (winget install GitHub.cli, then gh auth login with
the repo scope). ⚠ A shell started before the install does not have gh on PATH, so open a
fresh one; the script looks in winget's install locations before giving up, which is what keeps a
long-lived agent shell working. Every gh call passes --repo explicitly rather than letting it
infer the repository from the working directory, and the upload passes --verify-tag so gh cannot
invent a tag of its own when the push has not happened. ⚠ A gh or git call whose failure is a
normal answer, such as asking for a release that does not exist yet, goes through the script's
Invoke-Probe: PowerShell 5.1 turns a native command's redirected stderr into a terminating
NativeCommandError under $ErrorActionPreference = 'Stop', so 2>$null around one of those ends
the run instead of answering the question.
RunSandbox.ps1 (repo root) runs a release zip on a machine that has never seen this project.
It stages .scratch\sandbox\<timestamp>-<vgpu|novgpu>\ with an input\ folder (the zip plus the
driver script, mapped read-only) and a writable output\, writes the .wsb, starts Windows Sandbox
on it and waits for the driver's done.txt. -NoVGpu is the below-the-floor machine, -MemoryMB
its memory, -MapReadOnly adds host folders the zip does not carry (a retail install, an extraction
tree), -Networking gives the guest a network (off by default; the SmartScreen verdict on an
unknown download is fetched, so a run that records that prompt needs it), and -Driver chooses what
runs inside, so a later item can supply its own procedure without rebuilding the harness. The drivers
share sandbox/SandboxCommon.ps1, copied in beside the driver and dot-sourced by name: the step log,
the window census, screenshots, the machine facts and the watched launch of CSVM.exe. Everything
the run produced stays on the host in output\: screenshots, both streams, the build's own logs\,
a line-by-line steps.log and summary.json.
sandbox/RendererFloor.ps1 is the driver that answers "what does this machine do with this
build". It unzips to C:\CSVM, launches CSVM.exe the way a recipient double-clicks it, records
every window and dialog it sees (title, class, owning process and child-control text), screenshots
at eight seconds and at the end, copies logs\ back, and reports the renderer from the build's own
[perf] gpu= line. A launch counts as reaching the game only if it drew its own window, put no
dialog on screen, wrote a log and was still running when the watch ended; when the plain launch does
not, the driver repeats it with --rendering-driver d3d12, with --rendering-method gl_compatibility and with --rendering-driver opengl3, so a fallback that does work becomes a
documented troubleshooting line rather than a guess. With an extraction tree mapped it also flies a
chapter with --no-vsync, because whether a machine renders a menu says nothing about whether it
can fly.
sandbox/PublicRelease.ps1 is the driver that follows packaging/README.md literally on a zip
carrying the mark of the web, with a retail install mapped in (-MapReadOnly). The zip is copied
into Downloads as a browser leaves it, unzipped through the shell's own copy engine so the mark
propagates to the files inside, and then each double-click the README names is done twice: once
through Explorer, which is where the security prompt appears and is recorded, and once as a plain
process, which is what "Run anyway" leads to. CSVM.exe is started before the extraction for the
screen that offers to extract, the extraction runs as CSVM.exe --headless -- --extract=<install>
on the mapped install (the in-game button needs a click a script may not give, and both run the
same pipeline), and the menu and a C1 flight run on the data the machine extracted itself. The summary records the mark on the zip and on the extracted files, the extraction's
time, file count and size, the save folder, and each launch's windows and dialogs.
What the rig had to learn, none of it visible in a failed run:
- ⚠ The element is
<vGPU>. A<VGpu>spelling is ignored without an error, and the run then measures a machine with the host's GPU passed through, which is not a floor test at all. The check is the guest's own hardware, which the driver records first: below the floor it has only the "Microsoft Remote Display Adapter" and no registered Vulkan ICD, and with the vGPU on it has the "Microsoft Virtual Render Driver" and the host card's Vulkan. - ⚠ Ask the person to close the sandbox window; never close it from a script. Killing the
processes wedges the Container Manager until an elevated
Restart-Service CmService -Force; the window ignores a posted close; and an Alt+F4 is delivered into the guest, or, when the raise the script asked for is refused, into whatever window the person was working in. - One session at a time. A second session started while one is live comes up with no mapped
folders and no driver, so nothing runs and nothing is written. The process to test for is
WindowsSandboxRemoteSession. - WMI is access-denied to the sandbox account, so the machine facts come from the display-class
registry key rather than from
Win32_VideoController. - The engine's flags are user args, so a scripted run passes them after a bare
--. Before it they reach Godot, which ignores them, and a--flysilently becomes a plain menu launch. - The logon command can fire before the mapped folders mount, so it polls for them, and its
console is invisible, so it redirects. That redirect is still buffered when the session ends, which
is why the driver appends
steps.logline by line and reads its ownsummary.jsonback off the share before saying it finished. The append opens the file with every share flag, because a host that tails the log while the guest writes it otherwise makes every later append fail silently. - ⚠ A file carrying the mark of the web is not double-clicked with
Start-Process. ShellExecute does not return until the security prompt is answered, so the driver hangs there, and a prompt raised from a hidden helper process never reaches the screen at all. The driver hands the file toexplorer.exe, which returns at once and shows the prompt where a person would see it. - CIM is access-denied to the sandbox account too, and
Get-NetAdapterthrows a terminating error through-ErrorAction SilentlyContinue, which empties every machine fact gathered in the same expression. Network presence is read fromNetworkInterface.GetIsNetworkAvailable().
The renderer floor, as observed. The build does not refuse to start without Vulkan. On the
below-floor machine Godot reports Required Vulkan instance extension VK_KHR_surface not found,
then Your video card drivers seem not to support Vulkan, switching to Direct3D 12, and runs
Forward+ on Direct3D 12's Microsoft Basic Render Driver, which is the WARP software rasterizer.
No error dialog appears at any point, and nothing has to be passed to reach that fallback. The menu
and the no-game-data screen render normally. A flight does not: loading a chapter fills the software
device, buffer_create fails with 0x8007000e (out of memory) tens of thousands of times, and the
process dies of an access violation (0xC0000005) about fourteen seconds in, leaving no window and
no message. Guest memory is not the constraint, since 8 GB and 16 GB fail identically. So the floor
is a GPU with a working Vulkan or Direct3D 12 driver. What a machine below it shows a player is the
boot card and then nothing: with game data present the intro film's first 4 MB vertex buffer fails
with DXGI_ERROR_DEVICE_REMOVED (0x887a0005) and the process dies of the same access violation a
few seconds in, before any menu. Only the no-game-data screen survives on that machine, so a menu
observed without data says nothing about the floor.
sandbox/LinuxRelease.ps1 is the Linux sibling of sandbox/PublicRelease.ps1, run on the host
rather than inside a sandbox: it drives WSL Debian over the tarball ExportRelease.ps1 -Linux
built (-Tarball names another) and the author's install (CrimsonSkiesGame\ under this tree or
the one CSVM_DATA_ROOT names; -Install names another). It fails on any failure and prints
RunTests.ps1-style stage lines and one verdict; a full run takes about 2 min (extraction 25 s,
the suites 95 s at six shards). Three stages, each run even when an earlier one failed, where it still
can:
- payload: the archive's listing against
packaging/MANIFEST.md's Linux table, read from that file rather than restated: every named entry present (a folder name must hold a file, a name with a*must match one), nothing at the root the table does not name,CSVM.x86_64andtools/unzbdat-rwxr-xr-x, the notice stamped for the Linux payload, and no carriage return in any top-level text file (a file with no NUL byte). - extract: the unpacked
CSVM.x86_64 --headless -- --extract=<install>into a fresh data root with the player's defaults (zips only), which must exit 0 and stampVERSION.json. Atools/unzbdwithout its bit fails here too ("Permission denied" starting the process), and a missing runtime file indata_CSVM_linuxbsd_x86_64/fails the launch. - engine (skipped by
-NoSuites):--run-tests=shard:<i>/<n>over that same zips-only root, the shape every player's install reads, in-Shardsprocesses (default 6, the alone suite taking one of them), shardkwith--net-port-base=30000 + (k - 1) * 100, below Linux's ephemeral range. The suite list is the harness registry's and the division isanalysis/engine-suite-weights.json, copied in beside the exe where the harness looks for it; the merge refuses a missing report, a suite run twice, a coverage short of the registry, and an unexpected engine error line.
Everything in the distro sits under ~/csvm-linux-check, wiped when the next run starts, with
XDG_DATA_HOME, XDG_CONFIG_HOME and XDG_CACHE_HOME set per process inside it, so no run
touches the distro user's ~/.local/share/godot and parallel shards share no user://. The
listing, logs, reports and each shard's engine log are copied to .scratch\linux-check\<timestamp>\.
PublishRelease.ps1 runs this check on the tarball it just exported and stops before the tag
when it fails.
What the check had to learn:
- ⚠ The engine flags go before the bare
--and the game flags after it, as everywhere else; without the--an export ignores--run-testsand boots the menu. - ⚠ Pass no
--log-file. On Linux the managed side of an export cannot read Godot's own flags back (Environment.GetCommandLineArgsdoes not carry them there, where it does on Windows), so the harness would report the engine log unscreened. Without the flag, each process logs to its ownuser://logs/godot.logand the harness screens that. - Some suites cannot pass headless on any platform. They read back what only a renderer or a
display produces (mesh and MultiMesh instance data, viewport pixels, windows and screens).
analysis/headless-limits.jsonlists them with the reason each fails (headlessOnly), beside the engine error lines only a headless process prints (headlessEngineErrors); the script reads them as$HeadlessOnlyand$HeadlessEngineErrors, andRunCiSuites.ps1reads the same file. The same suites and the same error counts come out of the Windows export run headless, which is how an entry is admitted: a suite that fails on Linux alone is a Linux bug and never goes on the list. Listed suites still run, and one that passes is reported so a stale entry is seen. - ⚠ The shards run on the safe render thread (
--render-thread safe). Godot 4.7's headless dummy renderer keeps its mesh, material and texture RIDs in tables that are not thread-safe, and the project's separate render thread allocates them on the calling thread while the render thread initialises, reads and frees them. On the separate thread every full run logs null mesh and material errors (mesh_get_surface_count,material_set_shader,update_end), wrong or uninitialised RIDs, intermittent network-suite failures and a crash at exit in some shards; on the safe thread it logs none, and the Windows export run headless behaves the same. The fix is upstream in godotengine/godot#121958 (milestone 4.8, not in 4.7.2), so the flag goes on that upgrade. The render thread's hand-offs stay covered by the windowed battery, which uses the real renderer's thread-safe tables. - The suites read the player's zips-only tree, where the Windows battery reads unpacked
folders. A texture archive refuses a read after
Disposein both shapes alike, so a read of a closed archive fails the battery as it would fail here, rather than passing on the folders alone. - ⚠ A shard that exits with a signal after writing its report fails the stage. A render
instance still alive at exit (a mesh instance on a node nobody freed) crashes an exported build in
its teardown, on either platform: 139 or 134 on Linux,
0xC0000005or0xC0000374(heap corruption) from the Windows export. The editor survives the same leak and printsPages in use exist at exit in PagedAllocatoron stderr, whichRunTests.ps1's engine stage fails on, so the battery sees the leak before an export does. A player's quit takes the same teardown, which is why the check does not trust the report here. - One-time setup in WSL Debian:
sudo apt install libfontconfig1. Godot's Linux build loads it for system fonts and logs an engine error on every lookup without it; players' systems have it, a minimal WSL Debian does not, and the script refuses to start without it. The goldens are not run, since WSL cannot render them.
Downloaded binaries: mech3ax v0.6.1 (pinned pre-fork extractor, for rollback), the fork checkout
(below), the Godot 4.7 .NET editor at tools/godot/Godot_v4.7-stable_mono_win64/ (use
*_console.exe for CLI runs), and the SDL2 runtime at tools/sdl2/ (below).
The SDL3 inside Godot 4.7 enumerates no DirectInput-only device on the author's machine, so CSVM
reads flight sticks through the official SDL2 runtime instead (docs/architecture/Sticks.md).
InstallSdl2.ps1 (repo root) downloads SDL 2.32.10's SDL2-2.32.10-win32-x64.zip from the
libsdl-org GitHub release, checks it against a pinned SHA-256, and writes three files into
tools/sdl2/: SDL2.dll, the zip's README-SDL.txt, and SDL's zlib LICENSE.txt, which the
runtime zip does not carry and which is read from the release's own commit. Every file is hashed
against its pin in a staging folder before any installed file is replaced, and a run over an
install that already matches downloads nothing. -Root <checkout> installs into another tree,
-Force re-downloads, and -Verify installs nothing: it throws unless the three files match their
pins and otherwise returns the version, commit and DLL hash. The pins live in that script alone.
⚠ Moving the version means repeating the stick-detection check on real hardware, because 2.32.10
is the build that check passed on; a new hash alone says nothing about whether the sticks still
enumerate.
One install serves every worktree. Run it once in the primary checkout; a worktree finds the DLL
through CSVM_DATA_ROOT like it finds Godot, and needs no copy of its own.
Nothing puts the DLL on PATH. No launch script changes the environment or the DLL search
path for it; the game loads it by absolute path, taking the first of these that exists:
SDL2.dllin the running executable's own folder. This is the exported build, whereExportRelease.ps1puts it besideCSVM.exe.<repo root>/tools/sdl2/SDL2.dll, the repo root beingres://'s parent on disk (theLauncherrepo root of an editor-hosted run).$CSVM_DATA_ROOT/tools/sdl2/SDL2.dll, the fallback a worktree uses.tools/sdl2/SDL2.dllof the checkout that supplied the running Godot, found two folders above the executable (tools/godot/<build>/), so a worktree launched withoutCSVM_DATA_ROOTstill finds it.
The system's own DLL search is never consulted, since any SDL2.dll on PATH is an unpinned
build of unknown version. When no candidate exists, or the load fails, the game runs without
sticks and logs one line naming the paths it tried; a missing DLL never stops a launch, and
InstallSdl2.ps1 is not called by any launch script. ExportRelease.ps1 does require it: the
export runs InstallSdl2.ps1 -Verify before building, ships SDL2.dll and README-SDL.txt
beside the exe with the licence as LICENSE-SDL2.txt, and records the SDL version, commit and DLL
hash in BUILD-INFO.txt. With -ToolsRoot <checkout> a worktree's export takes the SDL2 files
from that checkout's tools/sdl2/, as it does Godot and the mech3ax fork.
Linux. The bridge is SDL2's joystick API alone. Sdl2Sticks uses no DllImport: it loads
one library through NativeLibrary and binds every export by name with TryGetExport, and
the 24 functions it calls exist unchanged in every SDL2 build, sdl2-compat included. The roster,
profiles, bindings, capture, prompts and glyphs never see the library. What differs off Windows:
- The library and where it comes from.
Sdl2Sticks.ForPlatformpicks the list. Off Windows it islibSDL2-2.0.so.0beside the executable, then the bare soname, whichLoadhands to the system loader (dlopen's search:LD_LIBRARY_PATH, the loader cache,/usr/lib). The system search is what the Windows rule above forbids, and Linux relies on it: libsdl-org publishes no Linux binary to pin, and a distribution's SDL2 is built against that system's libraries. SteamOS ships/usr/lib/libSDL2-2.0.so.0from sdl2-compat (SDL2's API over the system SDL3). The tarball ships no SDL2, and itsBUILD-INFO.txthas no SDL block. No library found is the same onesticks: off, no libSDL2-2.0.so.0 (tried ...)line and a launch without sticks as a missingSDL2.dll. A library that is present but cannot load carries the loader's reason instead,sticks: off, libSDL2-2.0.so.0 failed to load: libdep.so: cannot open shared object file: ..., read fromdlopen's errors in the load exception's message; an error naming any file but the soname is a missing dependency, not absence. A loaded one logs the file the loader chose, read from/proc/self/maps:sticks: SDL 2.32.4 from libSDL2-2.0.so.0 (system: /usr/lib/...). - The hints in
Sdl2Sticks.Loadare set on both platforms.SDL_JOYSTICK_HIDAPI=0matters on Linux too: it keeps SDL2 off the hidraw nodes, where it would handshake with the Deck's built-in controls and with pads Steam or Godot's SDL3 are driving. RawInput, WGI and XInput are Windows backends, and their hints do nothing elsewhere.SDL_NO_SIGNAL_HANDLERS=1keeps SDL2's SIGINT and SIGTERM handlers out of Godot's process. On Linux SDL2 reads evdev, where a second reader shares a device rather than taking it. - The gap-filler's Linux rules. Godot's SDL3 on Linux lists every joystick, flight sticks and
throttles included, so the model match would hand every stick to Godot as a raw pad, its axes
and buttons named as a gamepad's and every unit merged onto the one pad placeholder (issue #130).
StickRosteris built withgodotReadsGamepadsoff Windows and decides by kind instead: it skips a listing SDL2 maps as a gamepad (SDL_IsGameController) and any device of Valve's vendor id28DE(the Deck's built-in controls, a Steam Controller and Steam Input's virtual pad), and opens every other device whether or not Godot lists it. Each skip logs its reason on astick skipped:line. Windows keeps the model match alone. - Godot's view of an opened stick.
StickPumphands the opened models toPads.ClaimForSticks, andPads.Connectedleaves every Godot pad of a claimed model out of the pad roster, so the stick is read once, through its stick profile. A rawInputEventJoypadButtonhandler that does not askPadsstill sees its presses: the cutscene and cinema skips, and the extraction screen's install picker. - Device GUIDs are SDL's 16 bytes printed in memory order on both platforms. Their content differs (a Linux GUID carries the bus type, vendor, product and version), and only the log prints them; bindings and profiles key on the model.
- Windows only:
InstallSdl2.ps1, which pins thewin32-x64zip; the three SDL2 filesExportRelease.ps1ships in the zip, with the DLL's hash inBUILD-INFO.txt; andSDL_JOYSTICK_DIRECTINPUT=0in the launch scripts, a workaround for Godot's SDL3 (BL-033).
InstallWebRtc.ps1 (repo root) downloads webrtc-native 1.2.2's
godot-extension-webrtc_native.zip from the godotengine release, checks its SHA-256 against the pin
(GitHub's published digest), and unpacks the extension manifest, its licences and the Windows and
Linux x86_64 libraries (debug and release) into CSVM/addons/webrtc_native/, which is git-ignored.
It also adds the manifest to CSVM/.godot/extension_list.cfg, where a run that never opened the
editor finds extensions; an editor import writes the same line. Unlike SDL2 the extension must sit
inside the project folder Godot opens, so each checkout or worktree that should play over the
internet runs it once. Each installed file is pinned by SHA-256 too, so a run reinstalls over a file
that differs from its pin, and -Verify checks every file against its pin without installing.
The game needs it only for internet play through a master server (--master-server=,
docs/cli.md): Net/WebRtcTransport.cs reports Available false without it, the launcher logs that
at startup, and LAN and direct play are unchanged. ExportRelease.ps1 runs it into the exported
tree before every export, because Godot's export carries the platform's library only when the
extension is installed, and a build without it would list master-server games but never host or join
one. The export then throws unless the library sits beside the executable, ships the release's
licence files as LICENSE-webrtc/, and records the release and the library's SHA-256 in
BUILD-INFO.txt. The webrtc-transport suite skips without it.
server/ is the master server itself, a .NET 8 minimal API (server/MasterServer/) with its
xUnit project (server/MasterServer.Tests/), both in CSVM.sln, so dotnet build and the units
stage cover them. It compiles the game's CSVM/src/Net/MasterProtocol.cs rather than a copy. The
Dockerfile, docker-compose.yml (master, coturn, Caddy), the coturn configuration, a systemd unit
and the step-by-step deployment are in server/README.md.
Crimson Skies support lives in the fork, not upstream. Upstream removed it, so the fork is its home and upstream commits get merged into the fork. Upstream is dormant, so syncing is a check-occasionally rather than a routine.
- Remotes:
origin=git@github.com:Laeresh/mech3ax.git,upstream=https://github.com/TerranMechworks/mech3ax.git. - Branches, all three pushed to
origin, since the fork and not this workstation is the authoritative copy:cs-anim= integration, and what the release binary is built from;pr-cs-anim/pr-cs-gamez= the same work split by concern;main= an upstream mirror. - Sync:
git fetch upstream && git merge upstream/maininto the fork, then rebuildtarget/release/unzbd.exeand re-extract if the output shape moved.
The shelved upstream-contribution package is archived at upstream-pr/.
The game window is created without focus (display/window/size/no_focus in project.godot),
and a scripted session also hides its window through ScriptedWindow.Hide once
Launcher._Ready knows the flags, which leaves rendering untouched where minimizing would stop it
(SHELL-13, SHOT-16). A hand launch still shows a brief flash, because the window exists before
_Ready can run, so RunTests.ps1 instead runs every launch on a separate Windows desktop
(HiddenDesktop.ps1: CreateDesktop, then CreateProcess with STARTUPINFO.lpDesktop), which a
window can only join through the process that started it, so placement is decided before the
process runs. The summary line names the desktop used, and a refused desktop continues
visibly rather than failing; the rejected alternatives are in the retired analysis/hidden-desktop/
(git show analysis-archive:analysis/hidden-desktop/FINDINGS.md).
RunGame.ps1 and RunDev.ps1 hand the foreground to the new window themselves, finding it by pid
via EnumWindows (SHELL-13). Every RunTests.ps1 stage launches through its private
Invoke-Godot helper, which uses the non-console binary and redirects both streams to
<its --log-file>.out / .err (SHELL-10).
RunProbe.ps1, the same launch for ad-hoc runs. A hand-launched probe would otherwise inherit
both the window flash and the console scribble a bare & $GodotExe … sprays over the calling
terminal (SHELL-10): never invoke the Godot binary directly for a scripted run, go through
.\RunProbe.ps1 <user args>. It forwards every argument verbatim (no build step, so build
first), runs on its own hidden desktop (csvm-probe, falling back to a visible but still
redirected run if the OS refuses one), parks the streams beside the run's --log-file or in
.scratch/logs/probe-<stamp>.out/.err, and exits with Godot's code. -TimeoutSec (default
300, 0 = wait forever) kills the run and exits 124, so a probe that never quits cannot
hang a session. -Resolution WxH is forwarded as Godot's own --resolution ahead of the --,
which is the only way a scripted capture lands at a size a player runs: the project ships
1280x720, and a saved size cannot raise it because --screenshot implies --det, which drops
every saved option (DET-8). A resolution-sensitive artefact is invisible at the default size.
-EngineArgs forwards any other Godot option ahead of the --, as a string array:
-EngineArgs '--render-thread','safe' A/Bs the project's separate render thread, and
-EngineArgs '--log-file','<path>' keeps every native ERROR line in one file.
RunTests.ps1 and RunProbe.ps1 put every process they start into one Windows job object per
run, created with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE before the first launch and closed after
the summary (JobObjectCore.ps1, dot-sourced through HiddenDesktop.ps1; JobObject.ps1 is its
self-test). The only handle to the job
is the runner's, so the kernel closes it however the runner's process ends (a tool-call cap,
Stop-Process, a crash), and every Godot shard, golden shot, dotnet test
testhost and MSBuild node of that run dies with it. Killing a runner's PowerShell mid-engine-stage
leaves no Godot from it within 3 s. Without the job nothing else waits on those children, so they
run on beside the next run.
- Assigned before it runs. A hidden-desktop launch is created suspended, joined, then resumed.
dotnet build/dotnet testand the visible-desktop fallback are started throughProcessStartInfo, which cannot start a process suspended, so they join straight after the start; a child started in that window would run outside the job, and neither Godot nor the dotnet host starts one that early.Invoke-Dotnetwrites dotnet's stdout and stderr lines to the runner's output as they arrive, so a caller's pipe or redirection still receives them. - Shared build servers stay out (SHELL-22). The job allows silent breakaway while
dotnet buildruns, so only thedotnetprocess is a member and every child of the build (MSBuild nodes, the compiler server, anyExectask) runs outside the job and outlives the run as usual. Gaming mode is the exception: its build uses private nodes and compiler and stays in the job.dotnet testruns withMSBUILDDISABLENODEREUSE=1so its nodes belong to this run alone. - The runner itself is never a member, or the close at the summary would kill the shell it was started from. A second run in the same PowerShell session first closes a job an interrupted run left open, which kills that run's leftovers.
- Refused, it runs without it. A session whose own job forbids nesting gets one yellow line
and an unprotected run, never a failure;
Stop-StrayGodots(SHELL-2) remains the backstop. Nested assignment works under the job Claude Code runs its shell in, on Windows 11. - The seam for other limits is
[CSVMRunJob]::Handle: a limit set on the job reaches every member, testhosts included. A limit is read, modified and written back, asSetBreakawayandSetThrottle(gaming mode's priority and affinity) do, because a plain write replaces every flag, the kill on close among them. .\JobObject.ps1 -SelfTesttoggles breakaway on and off, starts a child that starts a grandchild, closes the job, and checks both are gone.
Every scripted Godot launch on the machine is admitted against one machine-wide ledger before it
starts (MemoryLedgerCore.ps1, dot-sourced by RunTests.ps1, RunProbe.ps1, RunGame.ps1 and
RunDev.ps1; MemoryLedger.ps1 is its command line and self-test), so concurrent sessions queue rather than run the machine out of memory. Always on,
no flag. A free-memory snapshot cannot be the test: an engine shard starts at a few hundred MB and
grows to about 6 GB over its share of the catalog (5.8 to 6.6 GB measured, hence the 7.0 GB
engine-shard seed; analysis/shard-memory/FINDINGS.md), so seven shards started together all see
enough free.
- The ledger is
%TEMP%\csvm-mem\: oneres-*.jsonper live launch,{pid, kind, estimateGB, worktree}, held open by the launching script (delete-on-close, no delete sharing) for the launch's lifetime. The open handle is the claim, as with the net-port slots, so a dead holder frees its reservation with its process; a named semaphore would leak its count on a crash. A file nobody holds is swept by the next reader.CleanScratch.ps1never touches it.HeldFile.ps1opens, rewrites and reads such files, for the ledger and for gaming mode's lock. - The admission rule, under the short machine-wide mutex
Global\csvm-mem-admission, held across the check and the reservation write only, never across the launch:available now - sum(max(0, estimate - current private bytes) over live reservations) >= own estimate + floor. "Available now" is physical memory available, which already counts everything outside CSVM. Current usage is private bytes, never the working set, which drops whenever Windows trims it. A reservation not yet given its pid owes its whole estimate; one whose process has exited owes nothing. The floor is 8 GB, or 16 GB while gaming mode is on. The engine checks only that the marker%TEMP%\csvm-gamingexists; it agrees with the scripts' expiry because the first script to read an expired marker deletes it. - Guaranteed progress. With no other live reservation a launch is admitted whatever its estimate, so a grown estimate can never block everything. The floor still holds: below it even an empty ledger waits, up to the wait cap.
- Per launch, not per run. Each engine shard and each golden shot is admitted on its own, so
with room for three, three run and the rest start as memory frees. Shard membership
(
shard:i/n) never changes, only start times. The watchdog starts at admission: waiting never counts toward a launch's watchdog (a shard's stall time and ceiling, a shot's wall time) orRunProbe.ps1 -TimeoutSec. - Learned estimates. Every launch admitted by a script records its peak private bytes (read
from a handle the ledger holds on the process, after it exits or is killed) under its kind in
history.json; the estimate is the highest of the kind's last 20 peaks plus 25 percent, and never below the kind's seed. Anengine-shardorengine-headlesspeak is recorded only from the windowed shard or a headless shard of a complete-catalogRunTests.ps1run under the default shard plan (the default-Shards, gaming mode off, no-RealData): a filtered shard stays small, any other plan hands a shard a different share of the catalog, and the alone shard and the synthetic process hold little. A launch the engine refused records nothing. The seed table is$MemSeedGBinMemoryLedgerCore.ps1; the kinds areengine-shard,engine-headless(theRunTests.ps1engine stage names it per launch; any other--run-testslaunch isengine-shard),golden-shot,perf,hitch,probe,capture-enhanced(--graphics=enhanced) andcapture-xr(--xr*).estimates.jsonbeside it, written from the seeds when the ledger is first used, is what the engine reads. - DEFERRED. A launch still waiting after
$MemMaxWaitSec(1800 s) ends its runDEFERRED: memory, needs X GB, Y GB admissible, held by <worktree> pids ..., neither PASS nor FAIL:RunTests.ps1marks the stage DEFERRED, skips the later Godot stages and exits 3 ($MemDeferredExitCode);RunProbe.ps1exits 3 without launching. What did run is still judged: the shards and shots that ran are merged and scored, and a FAIL outranks DEFERRED in both the stage and the run result. Three other waits and stops end a run the same way, exit 3: gaming mode's lock wait past its ownmaxWaitSec(also 1800 s by default; Gaming mode, below), and a rebuild under the run and a-WaitQuietwait past-QuietTimeoutSec(Launch scripts, above). - The engine's side (
src/Tooling/MemoryAdmission.cs, called once fromLauncher._Ready, Windows editor builds only). A non-interactive launch (--det,--run-tests,--frames=,--shots=,--screenshot=) reads physical memory available as the ledger does (GlobalMemoryStatusEx, never Godot's own "available", which is commit headroom including the page file), logsmemory: X GB physical available, floor 8 GB, and refuses to start below the floor: it logsmemory: X GB available is below the 8 GB floorand exits 75, whichRunTests.ps1andRunProbe.ps1report as DEFERRED. An unreadable figure never refuses. One started withoutCSVM_MEM_RESERVATION(the path every admitting script hands its child) registers itself, under the same mutex, with its kind's estimate fromestimates.json(the largest seed when none is written yet), and does not wait: it cannot be held, but every other admission counts it. - Interactive play stays out.
RunGame.ps1/RunDev.ps1without a scripted flag neither waits nor registers; its memory is counted through "available now". Both, andRunProbe.ps1, admit through one helper,Request-MemLaunch. - The hook.
CheckGodotCommand.ps1, called by all three harnesses' pre-tool hooks, blocks an agent shell command that invokesGodot_v4*.exedirectly and points atRunProbe.ps1. A mention of the name (Get-Process,Test-Path, a grep) passes, and so does an editor--import, which builds no session (CheckUidSidecars.ps1's fix).-SelfTestruns its cases. .\MemoryLedger.ps1 statusprints the live reservations (pid, kind, current private bytes against the estimate, worktree), the waiters, and the estimate per kind. A waiting launch prints the same table once, then one line every 30 s..\MemoryLedger.ps1 -SelfTestchecks the admission rule, handle-held liveness across a killed holder, the empty-ledger progress rule and its floor, the estimate update and its seed floor, and that anengine-shardpeak is recorded only when the caller marks the launch a full shard (-LearnShardPeak), against a private ledger directory..\RunTests.ps1 -SelfTestchecks which shards of which plans are so marked.- Test-only overrides, for proving admission on a shared machine:
CSVM_MEM_AVAILABLE_GBreplaces the machine's available memory with a simulated figure, less the private bytes of every live reservation, so the simulated machine fills as launches grow (the engine's floor check reads it as is);CSVM_MEM_MAX_WAIT_SECshortens the wait cap. With theengine-shardseed of 7 GB,CSVM_MEM_AVAILABLE_GB=20fits one shard at a time.
With several sessions testing at once the machine is unusable for a game. Gaming mode makes test runs from every worktree queue behind one machine-wide lock and run throttled on a few CPU threads, slower than budget, leaving the rest of the machine to the game.
- The switch is the marker file
%TEMP%\csvm-gaming, outside every worktree, written by.\GamingMode.ps1 on(4 hours;-Hours N, or-Forever) and deleted by.\GamingMode.ps1 off. It is a file, not an environment variable, because a running agent session never sees a variable set after it started. An expired marker reads as off everywhere, and the first script to read one deletes it, unless it was rewritten since that read; a marker deleted mid-read reads as off..\GamingMode.ps1 statusprints the time left, the tunables, the lock holder and the waiters. The code isGamingModeCore.ps1, which takes no parameters so that dot-sourcing it binds nothing in the caller;GamingMode.ps1is the command line over it. - The tunables live in the marker (JSON), with these defaults:
shards2 (shared engine shards, with the alone shard beside them),goldenWorkers1,dotnetCpus2 (dotnet build/test -m:2and the test runner'sRunConfiguration.MaxCpuCount),priorityBelowNormal(Idlecan starve a run under a CPU-heavy game),threads4 (the affinity: the last N logical processors, 12-15 on the 16-thread development machine),watchdogFactor3 (a shard's 120 s stall time becomes 360 s and its ceiling 3600 s, a golden shot's 300 s watchdog 900 s) andmaxWaitSec1800. Edit the file to change one; a missing or invalid field takes its default, andonagain keeps the edits. An explicit-Shards/-GoldenWorkersabove the tunable is capped.-Shards 1is not the throttled setting: one shared shard with all but the alone suite at low priority beside a game can outrun even the scaled ceiling. - The lock, only while gaming mode is on.
RunTests.ps1andRunProbe.ps1take it before their first heavy stage and hold it to the summary: an exclusive delete-on-close file%TEMP%\csvm-gaming.lockthat records the holder's worktree, pid and start time and is released with its process, as the net-port slots and the memory ledger are; afinallyin each runner also releases it when the script throws in a shell that lives on. A waiter printswaiting for gaming-mode lock, held by <worktree> (pid N) since hh:mm, registers acsvm-gaming.wait-*file forstatus, and polls every 1 to 3 s with jitter; there is no strict FIFO. - Stage boundaries. The marker is read before every stage. Switched on mid-run, the next heavy stage queues for the lock and runs throttled; a stage in flight finishes at full speed. Switched off, the holder releases the lock and runs its remaining stages unthrottled, and a waiter stops waiting at its next poll.
- Throttling goes through the run's job object (
Set-RunJobThrottle, which setsJOB_OBJECT_LIMIT_PRIORITY_CLASSandJOB_OBJECT_LIMIT_AFFINITYby read, modify and write), so every Godot,dotnetand testhost of the run, and every child they start, runs at the priority on the threads. A child that breaks away from the job keeps the priority but not the affinity, so a throttled build stage turns breakaway off and builds with-nodeReuse:false -p:UseSharedCompilation=false: its MSBuild nodes and compiler are its own children, and the job's close kills nothing shared. - DEFERRED. A lock wait past
maxWaitSecends the runDEFERRED: gaming mode, lock held by ..., exit 3, the memory ledger's code and path: before the build the run stops there, and later the stage reads DEFERRED and the Godot stages after it are skipped. - Wall time is not judged. Budgets are not compared (
budgets: not compared (gaming mode)), and-Perfand-HitchreportDEFERRED (gaming mode)instead of running, so no throttled record reachesperf-history.jsonland no clean hitch run is judged beside a game; the rest of the run still passes or fails on its own stages. - The summary line:
gaming mode: ON (until hh:mm), waited m:ss, shards 2, workers 1, threads 12-15, orgaming mode: off. - The format hook throttles but never waits.
FormatBeforeTests.ps1runsdotnet formatand its Rebuild at once under the same priority, affinity and build arguments, without the lock: a wait would overrun the hook's timeout, and a timed-out hook does not block the command, so the gate would be skipped silently. Taking the lock there too would let a run lose its place between format and test. The hook's timeout is set in.claude/settings.json;FormatBeforeTests.ps1 -SelfTestholds.pito the same value and Codex's one hook for every gate to at least the format and content timeouts together. A throttled gate measured 170 s on a busy machine against 69 s unthrottled; past 240 s in gaming mode the gate prints one warning on stderr. - Not covered:
RunGame.ps1andRunDev.ps1(the user plays through them),ExportRelease.ps1,Extract.ps1.-WaitQuietis separate: it waits for scripted Godots whatever the mode. - Goldens that move under the game's GPU load are a determinism defect, owned by #114; gaming mode reduces contention between test runs and is not a fix for one.
- Off Windows it is a no-op and says so once.
.\GamingMode.ps1 -SelfTestchecks expiry, tunable parsing and the lock across processes (waiting, naming the holder, DEFERRED pastmaxWaitSec, switching off mid-wait, a killed holder) against a private marker.CSVM_GAMING_MARKER(test-only) moves the marker, the lock and the waiter files, so a test never switches the machine into gaming mode; the engine's floor reads only the fixed path.