The Nintendo Switch side of Arkchemy: it takes
the C emitted by conquertron and
builds it into a real Switch homebrew .nro.
No PowerPC emulation runs at runtime — the recompiled game is compiled to native ARM64 and linked against libnx like any other homebrew app.
This repository contains no game code and no game assets. You supply your own legally-dumped copy.
| Path | What it is |
|---|---|
game/ |
The full-game build: Makefile, regenerate.sh, and source/main.c (the harness/entry point). The recompiled generated_*.c are build artefacts and are not committed. |
native/ |
Smaller native test programs |
gx2_test/ |
GX2 → deko3d graphics experiments |
src/start.s |
Bare-metal ARM64 startup used by the earliest no-libnx milestone |
link.ld, build.sh |
Linker script and build script for that same bare-metal path |
test-results/ |
Dated real-hardware test logs |
docs/ |
Historical milestone notes |
Requires devkitPro with devkitA64, libnx, and
deko3d, plus a checkout of
conquertron — it supplies
ppc_runtime.h, the cafeos_*.h shims and cafeos_state.c that the
generated C compiles against.
You do not have to fetch it yourself. tools/fetch-conquertron.sh resolves
conquertron in this order:
- an explicit
make CONQUERTRON=/path/to/conquertron - a sibling checkout next to this repo
- otherwise, a copy fetched into
deps/conquertron, pinned byconquertron.lock
A sibling checkout deliberately beats the fetched copy. conquertron and jouster are developed together, and a fetched tree silently shadowing local recompiler edits would mean the next hardware run tests the wrong code. An already-present copy is used with no network access at all, so builds work offline.
The side-by-side layout, if you want it:
some-dir/
conquertron/
jouster/
Two helpers, since a sibling and a vendored copy look identical in build output and building against the wrong one is silent:
make -C game conquertron-info # resolved path, commit, local modifications
make -C game conquertron-update # update deps/conquertron and re-pin the lockexport DEVKITPRO=/opt/devkitpro
cd game
make -jIf your checkout is laid out differently, point it at conquertron explicitly:
make -j CONQUERTRON=/path/to/conquertronThat produces game/Jouster.nro. Copy it to your Switch's SD card under
/switch/Jouster/.
The generated C is not in the repository — game/regenerate.sh drives
conquertron against your own dump to produce it before building.
tools/setup-windows.ps1 installs devkitPro and the portlibs in one go, then
checks that every library game/Makefile links against is actually on disk
and names the ones that are not — a missing portlib otherwise surfaces as an
undefined symbol at the link step, twenty minutes in.
powershell -ExecutionPolicy Bypass -File tools\setup-windows.ps1
Then build as above with export DEVKITPRO=/c/devkitPro, using
/c/devkitPro/tools/bin/make.
Note that install-devkitpro.sh in the parent directory is the Linux route
only, and its claim that jouster needs no portlibs stopped being true when
ffmpeg and curl went in. It will leave you with a toolchain that cannot link
this project.
Delivery differs too. tools/courier.sh drives the console's MTP mount
through gio, which is gvfs and therefore Linux-only. Windows reaches the
same MTP device through the shell namespace instead, so there are two options:
tools/mtp-courier.ps1 — over USB, no network needed. The default.
powershell -ExecutionPolicy Bypass -File tools\\mtp-courier.ps1
Three MTP quirks it exists to handle:
CopyHereis asynchronous and reports nothing — no progress, no completion, no errors — so every copy is followed by polling the destination until the size settles.- It will not overwrite, so the old
Jouster.nrois deleted and the delete confirmed first. Otherwise the copy silently becomesJouster (2).nroand the console keeps booting the old build while this end reports success. - Deleting it must not go through
InvokeVerb('delete'), which always raises "permanently delete this file?" and waits for a click — once per build.tools/mtp-delete.ps1usesIFileOperationwithFOF_NO_UIinstead.
That last one has a wrinkle worth knowing before touching it.
IFileOperation needs an IShellItem, and SHCreateItemFromParsingName
resolves the device but fails with E_INVALIDARG on everything inside it —
measured: the device parses, SD Card and switch do not. An MTP item's path
ends in object IDs like {00000026-0000-…}, which are not parseable display
names. So the device is parsed and everything below it is reached by
enumerating children and matching names, which needs no parsing at all.
tools/ftp-courier.sh — over wifi, via ftpd. Needs ftpd running on the
console, which MTP does not, so the console is doing nothing else meanwhile.
echo 192.168.1.42 > tools/switch-address.txt # once
./tools/ftp-courier.sh # push, then pullBoth push a build and pull back the log and run-tally.txt. Neither will
re-save a log it already has — the hash decides, not the clock.
The devkitPro toolchain also ships as a container image, which builds everything here without touching your system package manager (podman or docker, run from the directory that holds both checkouts):
podman run --rm -v "$PWD":/work:z -w /work/jouster/native --userns=keep-id \
docker.io/devkitpro/devkita64 \
bash -lc 'export PATH=$DEVKITPRO/devkitA64/bin:$PATH; make -j'Swap native for gx2_test or game to build the others; the image
already carries libnx and deko3d, so nothing else needs fetching.
tools/setup-runner.ps1 registers the build machine as a self-hosted GitHub
Actions runner, after which every push builds the NRO and couriers it to the
console over USB with nothing typed. Run it once, from a jouster checkout:
powershell -ExecutionPolicy Bypass -File tools\setup-runner.ps1
It builds in the existing working copy, because game/source/generated_*.c is
not in git and only that machine has it, and it refuses rather than resets if
there is uncommitted work. The NRO is never uploaded as a build artifact:
artifacts on a public repository are readable by anyone, and a built
Jouster.nro is generated game output, which section 6 of the licence says
must not be redistributed.
A self-hosted runner executes whatever the workflow says. build.yml therefore
has no pull_request trigger, and fork pull-request workflows should be left
requiring approval under Settings, Actions.
game/source/main.c is not a normal entry point; it is a diagnostic harness.
It runs the recompiled game on a worker thread while the main thread logs
periodic checkpoints to sdmc:/switch/Jouster/game-results.log, including
memory-allocation events, watched function arguments, and a stall detector
that exits early if execution stops making forward progress. Test duration can
be overridden by writing a number of seconds to
sdmc:/switch/Jouster/test-seconds.txt.
The log is written through a 32KB buffer and flushed every 256 lines. That
interval can be overridden by writing a line count to
sdmc:/switch/Jouster/log-flush-lines.txt: 1 flushes every line, as the
harness did before 2026-09-17, and 0 leaves only the explicit flushes from
the deko3d error sink and the exception handler. The value is recorded in the
log's first line and in each run-tally.txt entry, so a sweep over it can be
read back afterwards without guessing which run was which.
Those logs are how nearly every bug in this project has been found; dated
examples live in test-results/.
Early, and not playable. The engine starts, runs its 114 static initialisers and gets partway through its reflection registration before stalling, so it never reaches level or asset loading.
What does work:
- Video and audio playback.
bash.movplays start to finish, 526 frames at 29.97fps with audio in sync, decoded by ffmpeg from devkitPro's portlibs. A native Bink shim also serves the game's ownBinkOpen/BinkDoFrameAPI, delivering all 720 luma rows per frame. - The Wii U boot presentation — the
bootTvTex.tgasplash and the 18.9-secondbootSound.btsndjingle, from the game's ownmeta/files. - Filesystem access. All 22 coreinit FS imports the game calls are
implemented, and a boot self-test opens
/vol/content/alchemy.xml,content:/alchemy.xml, a bare relative path and a nestedpermanent/bootstrap.bldthrough the same translation the engine uses.
What does not:
-
The engine boot. Registration reaches 124 of about 1,007 classes, in exactly the retail order, and then the boot stops making progress.
Registration is demand-driven: Alchemy builds a class's metaobject the first time something asks for it, and retail asks 13,567 times during boot against our 105. So 124 is a measure of how far the boot gets, not a fault in registration, and nothing is wrong with the class at the frontier. An earlier version of this file named
igFileandigVirtualStorageDeviceas classes that never register; both are dependencies of classes beyond the frontier, and were never skipped.The current fault is one unchecked null allocation.
igPool::allocateBucketasks for 6,625 elements of 24 bytes, the allocation returns null, nothing checks, and the element constructor writes through a null base until element 563 lands on the engine's memory-context global. On hardware the first of those writes would fault at the bug; guest-memory masking makes every one of them legal, so it surfaced 6,236 calls later as an unrelated null pool. Containing that loop took execution from 710,046 to 1,337,074 calls and dispatch misses from 73,308 to 53 -- what looked like four problems was one.The allocation fails because we run the same three bucket allocations six times. Retail, under a debugger, runs them once, with identical counts and element sizes.
-
Game rendering. Graphics calls are honest no-ops pending a Switch backend; nothing the game itself draws reaches the screen.
Progress is tracked run by run in test-results/, including the wrong turns.
Several confident theories in there were later disproved and the records say
so rather than being quietly rewritten -- the pool allocator, for one, was
chased for hours before it turned out to be reading a block header out of
address 0.
docs/registration-order-real.txt-- 965 classes in the retail game's own registration order, captured from Cemu by breaking onappendToArkCore. Diffing our order against it is how the frontier is located.
Cemu with --enable-gdbstub is the reference for anything about what the
retail game does. Two things make it usable rather than misleading: gdb needs
set architecture powerpc:750 and set endian big, without which
breakpoints silently never fire; and the stub's port is hardcoded to 1337, so
if something else owns it gdb reports "Remote connection closed" as though the
stub were broken. Guest addresses need no rebasing.
See LICENSE — Arkchemy Free & Source-Available License v2.0. It is
not an OSI-approved open source licence and some uses require permission,
so please read it before reusing anything here. Contact details and the
project Discord are in llms.txt.
Contributors are listed in CONTRIBUTORS.csv; the codename
scheme is explained in CODENAMES.md.
| Document | What it covers |
|---|---|
ROADMAP.md |
What has to work, in the order it has to work |
docs/graphics-plan.md |
Rendering: what is known, what it costs, what is blocked |
docs/boot-chain.md |
Where boot actually gets to, measured, and what stops it |
docs/SDLC.md |
Development lifecycle: methodology, requirements, testing, risk |
docs/schedule.md |
Gantt chart, critical path and milestones |
test-results/ |
One note per hardware run, newest first, each with its build hash |