11 KiB
Plan: Get all game code written in C ("C-first")
Context
Pokémon Stadium is 89.60% decompiled by bytes. The remaining .text lives in
two forms:
- 190
GLOBAL_ASMfunctions across 68 files. Of these, 129 already have a C body behind#ifdef NON_MATCHING(logic understood, just non-matching), and 61 are "bare" — no C at all,GLOBAL_ASMincluded unconditionally. - Whole-file assembly: only two
asm-typed code segments remain —asm/us/boot.s(~716 words) andasm/us/exception_set.s(~8 words). Everything else in asm is eitherhasm(hand-written, intentionally kept as asm) or data.
The goal is C-first: every function has a readable C implementation so that
make NON_MATCHING=1 compiles the entire game from C with zero GLOBAL_ASM
assembled. Byte-matching is explicitly a later phase and must not be regressed
in the meantime.
Definition of done: make NON_MATCHING=1 builds green, and every GLOBAL_ASM
in src/ sits inside a NON_MATCHING guard (so none is reached in a NON_MATCHING
build). The plain make build still produces the byte-exact ROM
(md5 ed1378bc12115f71209a77844965ba50).
Locked decisions
hasmstays as hand-asm — out of scope (bootloader/entry, Yay0 decoder, audio decoder 0x517A0, abs, 49190, libleo bootstrap). Scope = the 190GLOBAL_ASMfunctions +boot+exception_set.- Preserve the byte-match — every new C body is wrapped
#ifdef NON_MATCHING <C> #else GLOBAL_ASM(...) #endif. Plainmakestays md5-exact throughout. If a function happens to match, drop the wrapper entirely. - Use m2c (
mips_to_c) to bootstrap first-draft C, withtools/m2ctx.pyfor context; hand-clean afterward.
Rust toolkit — the backbone of this campaign
The existing Rust tools (ps-status, ps-fdiff, ps-firstdiff) drive the whole
effort: worklist, progress metric, opportunistic match detection, and regression
guard. All measurement and verification go through the Rust toolkit, not the
Python scripts. Three additions are made up-front in Phase 0:
ps-status --c-coverage(new mode) — the campaign's primary metric. Reusesps-core::globalasmplus a newps-corehelper that detects whether eachGLOBAL_ASMsits inside an#ifdef NON_MATCHINGguard. Reports guarded (has C) vs unguarded (bare) counts, per-file and total. Campaign target: unguarded → 0. Supports--list(emit the exact bare functions + addresses, the worklist) and--json(drive scripting/CI).ps-fdiff --file <src>(new batch mode) — run the per-function diff across every function in a file and print a one-line match/'Nwords differ' summary each, ranked closest-first. Used to (a) spot functions that already match so the wrapper can be dropped, and (b) later prioritize the matching phase.ps-firstdiff(existing) — the fast regression guard after each change: exit 0 = still byte-exact, non-zero = first divergence located.
These three are the only tooling additions on the repo side; m2c is the sole external/Python dependency.
Work breakdown
Phase 0 — Tooling + green baseline
- Build the Rust additions above (
cargo build --release --manifest-path tools/rust/Cargo.toml), with unit tests for the--c-coverageguard detection on synthetic fixtures (mirroring the existingps-coretest style). - Generate the worklist:
ps-status --c-coverage --list→ the 61 bare functions with addresses, grouped by file. This is the tracked backlog. - Install m2c into the venv:
pip install git+https://github.com/matt-kempster/mips_to_c(confirm exact package/repo; expose asm2c/python3 -m m2c). Add it torequirements.txt(or arequirements-dev.txt) so it's reproducible. - Establish that
make NON_MATCHING=1builds today. The 129 existing#ifdef NON_MATCHINGC bodies may include stale/incomplete attempts that don't compile under current headers. Fix any compile errors first — this is the baseline the whole effort builds on. (On macOS, build withRUN_CC_CHECK=0and GNU libiconv, per CLAUDE.md.) - Confirm plain
makestill md5-matches, thenps-firstdiffexits 0 (baseline regression guard).
Phase 1 — Wrap the 61 bare GLOBAL_ASM functions
The bare functions are concentrated in 11 files:
fragments/1/fragment1_7F9A0.c 18 3D140.c 6
fragments/1/fragment1_86CB0.c 12 4A3E0.c 4
33FE0.c 11 fragments/23/fragment23_1AE680.c 3
fragments/31/fragment31_2558B0.c 2 fragments/23/fragment23_1A9780.c 2
48C60.c / 485C0.c / 46680.c 1 each
Per-function loop (repeatable, see below). Suggested order: the 3 single-function
files first (quick wins to validate the loop), then 3D140.c/33FE0.c (dense,
low-% files), then the two big fragments/1 files.
Phase 2 — Whole-file asm: exception_set then boot
- Change the segment type in
yamls/us/rom.yamlfromasm→c([0xC3D0, asm, exception_set]→c, likewise[0x0040, asm, boot]). make extractregeneratessrc/exception_set.c/src/boot.casGLOBAL_ASMstubs per function. Then apply the same per-function loop.- Do
exception_set(~8 words) first as a trivial proof, thenboot.
Per-function workflow (the core loop)
python3 tools/m2ctx.py src/<file>.c→ generatesctx.c(types/externs).m2c --context ctx.c --target mips-ido-c asm/us/nonmatchings/<file>/<func>.s→ first-draft C. If the function has a jump table (switch), m2c fails with "corresponding jump table is not provided" — pass the rodata.sthat defines thejtbl_*symbol as an extra argument, e.g.m2c --context ctx.c … func.s asm/us/data/rom_rodata_7D8D0.rodata.s. (14 of the remaining bare functions use jump tables.)- Hand-clean: fix types against
src/<file>.h/include/, name obvious args, keep struct offset/size comments. - Wrap in the file:
#ifdef NON_MATCHING <cleaned C> #else #pragma GLOBAL_ASM("asm/us/nonmatchings/<file>/<func>.s") #endif make NON_MATCHING=1must compile. Thenps-fdiff <func>(Rust) — if it already matches, delete the wrapper +GLOBAL_ASMoutright (best outcome: advances both C-coverage and the match). Otherwise keep the wrapper.python3 format.py src/<file>.c, thenps-status --c-coverageto confirm the unguarded count dropped, andps-firstdiffto confirm the matching build is still byte-exact.
At file boundaries, ps-fdiff --file src/<file>.c gives a closeness ranking of
all its functions — a free head-start on the later matching phase.
Workflow gotchas (discovered while validating the loop)
- Never do a full
make NON_MATCHING=1for verification — it rebuilds every object as a NON_MATCHING variant, and switching back only rebuilds what changed, leaving a mixed build whose ROM md5 is wrong (seen:964842af…). The matching build is unaffected by guarded C anyway. To check that a wrapped file compiles under NON_MATCHING, build just its object:gmake NON_MATCHING=1 RUN_CC_CHECK=0 build/src/<file>.o. If you ever do run a full NON_MATCHING build, restore withgmake clean && gmake(then re-rungmake diff-initto resetexpected/). - The matching build is the regression source of truth. Because every new C is
behind
#ifdef NON_MATCHING, plaingmakemust still producemd5 ed1378bc…; verify it andps-firstdiff(exit 0) after a batch. format.pyneeds clang-format-14, which isn't installed here (brew install llvm/ a clang-format-14). Until then, hand-format to.clang-format(4-space, pointer-left, attach-brace); the campaign C already follows it.- Pick smallest-first, but read the asm. Function size varies wildly (0x10 …
960 instructions). m2c drafts of non-trivial functions often carry artifacts
that will not compile —
M2C_ERROR(/* unset register */),void*/?types, and struct accesses at offsets outside the inferred type (e.g. aPosBlend0x18 struct accessed at+0x2C, which really belongs to the enclosingModelVertex). Treat m2c as a starting draft; resolve types/globals againstsrc/<file>.hand the raw.s. Empty stubs (func_81209690) and functions that mirror an existing sibling (func_812075C0↔func_812073B8) are the cleanest wins.
Progress so far (21 done) → bare 61 → 40, C-first 79.0%. The entire 33FE0 model
cluster is C: dispatcher (func_800361C4), per-vertex passes (func_80035E2C,
func_80035FA8, func_80035B20, func_800357F4), matrix/geometry deforms
(func_80034824, func_80034BD4), smooth normals (func_80034348), frame append
(func_80033D44), plus the small ones. Also done: fragment1_86CB0 (incl. jtbl
func_81207698/func_81208C08), fragment1_7F9A0, and 3D140 leaf funcs.
Hard-function notes: jtbl functions need the rodata .s passed to m2c (or hand-
tracing when m2c returns a degenerate draft); watch for m2c's byte-vs-element
pointer arithmetic and fields accessed beyond a mistyped struct (e.g. a count read
through a PosBlend* at +0x2C that is really ModelVertex.drawGroup). The
leftover-$f0 pattern (a callee declared void that ends in a bare sqrtf(...),
whose caller uses the $f0 result) is fixed by changing the callee to return
f32 + return — this reproduced identical asm and kept the byte-match, then
unblocked func_80035B20/func_800357F4.
Critical files
tools/rust/crates/ps-status/+tools/rust/crates/ps-core/src/globalasm.rs— new--c-coveragemode + guard detection.tools/rust/crates/ps-fdiff/— new--file <src>batch mode.src/*.c(11 bare-function files; representative:src/48C60.c,src/33FE0.c,src/fragments/1/fragment1_7F9A0.c) — add wrapped C bodies.yamls/us/rom.yaml— resegboot,exception_setfromasm→c.requirements.txt— add m2c.- Reuse:
tools/m2ctx.py(context),format.py(formatting), and the Rust toolsps-status/ps-fdiff/ps-firstdifffor all measurement and verification.
Verification (all via the Rust toolkit)
- Milestone check:
make NON_MATCHING=1builds green, andps-status --c-coveragereports 0 unguardedGLOBAL_ASM. - No-regression check (after every function): plain
makereproducesmd5 ed1378bc12115f71209a77844965ba50, andps-firstdiffexits 0 (new C is behind#ifdef NON_MATCHING, so the matching build is untouched). - Per-function compile check:
make NON_MATCHING=1after each wrap. - Opportunistic matches:
ps-fdiff <func>/ps-fdiff --file <src>— any function that already matches gets its wrapper dropped immediately. - Rust additions themselves:
cargo test+cargo clippygreen, with parity of--c-coverageagainst agrep-based ground truth (as was done for the existingps-statusbuild-free metric).
Out of scope / follow-up (later: the matching campaign)
Once every function is C, a separate matching campaign turns each
#ifdef NON_MATCHING body into a byte-match (delete the wrapper), tracked by the
existing ps-status --build-aware metric and prioritized by ps-fdiff --file
closeness rankings. Tooling to add then: the decomp permuter. hasm segments
and asm/us/data/* blobs remain as-is.