25 KiB
§6 Daily command crib
§6.1 Running builds (native, in the WSL clone)
Claude Code runs inside WSL, so builds are plain native commands — no wsl.exe wrapper, no cross-shell quoting:
cd ~/bfm-decomp
make -j$(nproc) build # exit status is the build result; nonzero = failed
- The shell exit code (
$?) carries the build result directly — no launcher layer to distinguish from a real failure. - Output is native UTF-8; logs capture/pipe cleanly with no encoding workaround.
- The clone always lives on ext4 under
~/bfm-decomp, so builds never accidentally touch a/mntdrvfs path.
§6.2 Canonical compile pipeline (one object)
mipsel-linux-gnu-cpp -lang-c -Iinclude -undef -Wall -fno-builtin \
-Dmips -D__GNUC__=2 -D__OPTIMIZE__ -Dpsx -D_PSYQ -D_MIPSEL -D_LANGUAGE_C src/foo.c \
| bin/gcc-2.7.2-psx/cc1 -quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker \
| python3 tools/maspsx/maspsx.py --aspsx-version=2.56 \
| mipsel-linux-gnu-as -Iinclude -march=r3000 -mtune=r3000 -no-pad-sections -O1 -G0 -o build/foo.o
Modern cpp preprocesses → vintage cc1 compiles to asm → maspsx emulates ASPSX quirks → modern GNU as assembles. Then mipsel-linux-gnu-ld with the splat-generated linker script, objcopy -O binary to the PS-EXE, SHA1-compare. cc1 path/flags above reflect the §5.4 first candidate — the exact flag set is pinned only after Phase-6 fingerprinting (-funsigned-char, -fpeephole, etc. are decided then; the cpp defines list is the sotn convention, adjust as evidence dictates).
§6.3 Planned make targets (Phase 5 builds these; names fixed now)
As-built at P33 (B8):
make helpis the live list. Added since this table:bootstrap,disc-extract,extract-all,check-all(the R22 contract proof:make clean && make extract-all && make check-all→check-all: 218 passed, 0 failed of 218),sdk-dual,report,tools-health, thesig-*andaudit-*families,print-<VAR>. The public recipe with every expected last line isdocs/verification.md.
| Target | Does |
|---|---|
make extract |
splat split per config/splat.us.*.yaml → asm/, linker scripts |
make build |
full pipeline → build/us/SLUS_007.26, auto-runs the SHA1 check |
make check |
standalone SHA1 manifest verification (byte-for-byte = the only "OK") |
make expected |
snapshot build/us → expected/build/us (asm-differ baseline) |
make check-env |
toolchain preflight, exit 0 = environment sane (§4.9) |
make clean |
mandatory after ANY config/ change, before re-extract |
As-built (Phase 5, 2026-06-14): all five implemented in the root Makefile. The code is 100% assembly (the phase's "all-asm byte-match"; the cpp→cc1→maspsx→as c path is wired-but-dormant until Phase 6). make extract && make build && make check → build/us/SLUS_007.26 SHA1-identical to the original. Config config/splat.us.exe.yaml (platform psx, compiler PSYQ, subalign 2, gp_value 0x80074750, main segment align: 4 so the text→data boundary isn't 16-byte-padded); committed checksum config/check.us.sha. Build chain = as -march=r3000 -mtune=r3000 -no-pad-sections -O1 -G0 → ld -T <splat .ld> -T undefined_syms_auto.txt -T undefined_funcs_auto.txt --no-check-sections → objcopy -O binary.
Fail-closed recipes (Phase-27 T2, 2026-07-15). The Makefile sets .SHELLFLAGS := -ec — because .ONESHELL sends each whole recipe to ONE bash -c, so without -e a recipe's exit status is its LAST command's only, and every earlier failure is silently swallowed. That had made make report's middle gates (lint_symbol_refs, progress --audit, difficulty, dup_report) into non-gates — the 26-A audit's own thesis (a loud failure nobody counts is as invisible as a silent one) biting the audit's infrastructure. Consequences of the flag, now standing:
make reportis genuinely fail-closed — any of its gates failing exits non-zero (verified by a negative control: the same broken gate exits 0 under the old-c, non-zero under-ec).make check-all/extract-allassert COVERAGE (pass == N), not the absence of a failure marker — the oldfail == 0form was a vacuous pass on an empty pipeline.check-all'spass=$(grep -c …)carries|| true(grep -c exits 1 on zero matches, which-ewould otherwise treat as fatal — it would fail check-all exactly when nothing failed).make check-envopts OUT (set +eat the top of its recipe) — its contract is accumulate-every-failure-and-report, which-ewould truncate at the first missing tool. It is the only intended opt-out; addset +eto a recipe only with the same justification.make tools-health(new) = regenerate the byte-derived sigs (sig-overlays+sig-resident) then runaudit-corpus+audit-cdecl+audit-binaries+report+audit-digest, fail-closed — the deliberate pre-matching ritual the roadmap's standing invariant names. Deliberately NOT a prerequisite ofreport/build(audit-cdecl cross-compiles every C declaration through real gcc, ~minutes).audit-cdecl≈ several minutes;audit-corpus≈ 7 s.make sig-resident(Phase-27 T10; P31 T0 ELF-seeded — bootstrap's linear partition fused the +0 data word with the first fn and dropped the last, 144→the true 145; S45 nm-seed pattern, bootstrap fresh-clone fallback) signs the resident flat blob withsig_image(byte-derived) somake audit-corpus's second boundary oracle (R34) covers the resident — probed clean (0 phantom/truncated).sig-overlaysderives its payload list fromconfig/overlays.mk(not a0.4.decglob, which dropped the 4 SC07 index-1 overlays).progress.py --fleetreports a separate MAIN game-code weighted line (provisional) — the metrics-contract "main in the denominators", honestly un-folded.make atlas(P31 T5) — the Frontier Atlas regen chain:family_hseq→family_cousins(+both card emitters) →tools/atlas_features.py(per-fn feature layer: 363k rows / 93k distinct bodies in ~21 s; §172b tell detectors live here as importable functions —li_norm_toks/extpair_count/dupselect_count/sign_mix/magic_div_count, one implementation R33) →tools/atlas.py(the survey: cousin units + T1.5 h_seqn merges + calibrated warm tier + seed sweep vs the matched-skeleton pool + kNN graph + evidence joins + lever labels →.run/atlas.json+ committeddocs/frontier-atlas.md, partition-asserted).tools/atlas.py --calibratefreezes THRESH_WARM/KNN_FLOOR from measured recall/false-accept (seeded RNG, regenerable);--targets N [--lever L] [--cat C]emits crack slates. Main joins at the ATLAS layer only (family maps stay non-main by design — four enumerated silent-skip hazards in their consumers).make sig-main— REWRITTEN P33 A2 (S86, 2026-09-06): signs ALL of main's game-code functions at build-true lengths, Ghidra-free and splat-free:tools/main_seed_ends.py --map build/us/SLUS_007.26.mapreads each game-code object's.textinput section from the link map and slices it at the object's ownnmfunction symbols (the rodata islands and the LINKED PsyQ blocks sit BETWEEN objects, so every slice is exact — tiling asserted), emits0xVRAM NINSseeds, andsig_image --seedshashes the ORIGINAL EXE bytes at those boundaries →.run/sig.main.jsonl(809 fns / 45,150 ins). Needs a built main; without the map it leaves the file alone and says so (R51).progress.pyweighs main by this sig (falling back to the legacy Ghidra sig, and EXITING non-zero when neither exists — it used to print "217 binaries" and MAIN 0/0 silently, R32);dup_report.pyreads it for main;tools-healthandmake report BINARY=mainregenerate it first. Corrected denominator: the Ghidra sig's flow-derived boundaries left 3,628 words of real game code owned by no function (switch tails after unresolved jump tables, 2–4-ins thunks, andSaveLoadRoutine= thecase 0:body insidefunc_8002b0b4), so MAIN game-code weighted is 45,150 / 45,150, not 41,534 (P31 S79 had caught one instance, +22). Proof (S86): the derived sig tiles the 15 game-code objects'.textexactly (45,150 words, no overlaps), is never shorter than Ghidra's for any shared function, and Ghidra covers 41,522 words all inside that text. The fleet totals moved accordingly (instr 13,488,497 → 13,492,113; distinct 5,816,589 → 5,820,205); the digest's oracle clause is now DERIVED at render time (progress.main_oracle_line, was a literal). (Historical P31 T3 text follows.)make sig-main(P31 T3 — SUPERSEDED above) signed main's 2,002 game-code stubs at splat-true lengths:corpus.py main --seed-endsemits0xADDR NINSper stub (corpus.s_ins_count, the same counter audit() uses) andsig_image --seedstreats a seeded nins as authoritative ([addr, addr+4·nins), nofunc_endheuristic — which mis-sliced 3/40 main samples). Verified by a full word cross-check (2,002/2,002 EXE slices ==.swords; note the.sword field is byte-order hex, not the LE value). Deliberately splat-SEEDED — the atlas needs the boundaries a match must hit; main's independent second oracle stays scoped + deferred indocs/second-oracle.md(sig_is_independent("main")remains False).family_remap.vram_of/img_pathspecial-case"main"(derived fromsplat.us.exe.yaml: file0-vram = code-segvram − start= 0x8000F800; target_path), sostream_words("main", …)works fleet-wide (verified 25/25 vs.s).
§6.4 asm-differ + baseline discipline
.venv/bin/python3 tools/asm-differ/diff.py -mwo3 <function> # -m rebuild, -w watch, -o vs object, -3 three-way
- Watch mode works only with source and build outputs on ext4, modified from inside Linux (§1).
- Re-snapshot
expected/only on green: runmake expectedexclusively after a build whose check passed. A staleexpected/makes asm-differ silently diff against the wrong baseline — the classic "phantom regression/phantom match". - Diff score 0 = matched; anything else is not matched, no matter how close.
§6.5 decomp.me settings for BFM
- Platform: PlayStation (
ps1); Compiler:gcc2.7.2-psx. The project's preset —docs/decompme-preset.md(P33 E1): flags-O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker -Wa,--aspsx-version=2.56,--expand-div; nameBrave Fencer Musashi (SLUS-00726); requested from decomp.me's maintainers via their GitHub issue template (there is no create button in the UI and no owner delete) by Drew after the flip, with a proving scratch attached — proven before it is requested (docs/decompme-preset.md§5 carries the ready-to-paste issue). - decomp.me does NOT run our binaries (measured 2026-09-07 from
decompme/compilers): its image is old-gcc 0.13 + maspsx86ccd7d8withas= a maspsx--run-assemblerwrapper (so-Wa,args reach maspsx); we run old-gcc 0.17 + maspsx874855c5. Both deltas measured text-identical on the probe; the maspsx delta is gated on aspsx < 2.30 anyway.tools/decompme_replica.shrebuilds decomp.me's toolchain under.run/decompme/and proves a function through it locally (PASS onfunc_80018F20, 26/26 words);--upstreamreports when decomp.me's pins drift. - Do NOT use the SOTN preset (
Castlevania: Symphony of the Night/gcc 2.6.3-psx/psyq_263_221) — wrong era, guaranteed near-miss diffs. - decomp.me's API is Cloudflare-challenged (403 to scripts) — scratch searches/uploads needing the API must be done manually in a browser (the one-time manual BFM search is ledger row 14, closed by the E1 browser session).
- Outcome (P34 task 2, 2026-09-08 — Drew's browser session): the proving scratch https://decomp.me/scratch/mIu4d (
func_80018F20, target = the--gaspaste, 100% / score 0 on the first compile after the flags were entered); the preset request https://github.com/decompme/decomp.me/issues/2106 ("[PRESET] Create Compiler Preset - Brave Fencer Musashi (SLUS-00726)", opened 2026-09-08 18:05Z, state open); the manual search (row 14) found no other BFM scratch. Pending on decomp.me's maintainers: the preset's creation — record its id / URL here when it appears (https://decomp.me/api/preset).
§6.6 Matching a function (INCLUDE_ASM → C; the NON_MATCHING guard) — As-built Phase 6
Phase 6 flipped the text segment to splat's c type: src/800.c is one
INCLUDE_ASM("asm/nonmatchings/800", <fn>); stub per function (file-scope __asm__, pulls
the per-function asm/nonmatchings/800/<fn>.s in at assembly time). The build is byte-identical
at 100% INCLUDE_ASM; matching replaces stubs with C one function at a time. Harness as-built:
include/common.h (committed prelude), diff_settings.py (asm-differ, arch mipsel, object mode
vs expected/), tools/decompile.py (m2c wrapper), -Map build/us/SLUS_007.26.map for symbol lookup.
📓 Consult docs/matching-cookbook.md before/while matching — the evolvable catalog of reusable
compiler idioms (asm↔C) and "what makes gcc emit X" techniques. These recur across nearly every
function; shaping the C toward them up front saves asm-differ rounds. Add to it as you learn.
Cross-refs (HOW-TO lives in the cookbook / PhaseEnds, not duplicated here): per-module -O0
overrides — cookbook §6; the rodata island — cookbook §8; PsyQ library linking — cookbook
§9.1–§9.5; symbol curation (rename in Ghidra + config/symbols.us.txt, re-extract) — rule R15.
The loop (per function):
-
Scaffold:
tools/decompile.py <fn>(m2c) — or Ghidraget_codevia MCP for complex ones. -
Replace the
INCLUDE_ASM(... <fn>);line with the C function body — in the TU that owns the function's address (P31 S72; main's game code is THREE TUs, not one):vram TU asm path owns .rodataspan0x800123F0-0x8002B0B4src/800.casm/nonmatchings/800A 0x80072A38-0x80072C700x8002B0B4-0x80035270src/800_b.casm/nonmatchings/800_bB 0x80072E44-0x800731400x80035270-0x8003A444src/800_c.casm/nonmatchings/800_cC 0x800732A0-0x8007344CThis is load-bearing for any function with a
switch: one code object contributes exactly ONE contiguous.rodatarun, so the TU decides which jump-table span the body's table lands in. Put a span-B function insrc/800.cand you re-create the double-emit that cookbook §426 exists to describe. Declarations shared across the three TUs live insrc/800_shared.h(§431). -
Iterate:
.venv/bin/python tools/asm-differ/diff.py -mo <fn>until score 0 (-mrebuilds;-wwatch,-3three-way). decomp-permuter for stubborn near-misses. -
make checkmust stay SHA1-green (the whole-binary gate); commit-accumulate (R8). -
If the symbol name changes, rename in Ghidra +
config/symbols.us.txtand re-extract (R15/G6/R9).
Matched → the C replaces INCLUDE_ASM directly (byte-identical, no guard).
Correct-but-not-yet-matched C → keep it OUT of the default build behind the guard (G4):
#ifdef NON_MATCHING
/* correct-but-unmatched C */
#else
INCLUDE_ASM("asm/nonmatchings/800", <fn>);
#endif
The default build (no -DNON_MATCHING) links the asm, so make check never goes red on
non-matching C (G4). M2CTX/PERMUTER builds are already handled in include/include_asm.h.
expected/ is the asm-differ baseline (a green-build snapshot = original bytes) — re-make expected
only after a green build (§6.4), never mid-match.
§6.7 Binary-agnostic toolchain (Phase 9) — make build BINARY=<alias>
The toolchain builds any binary, not just the EXE. The Makefile holds a data-driven BINARIES
list of alias keys; each alias has a namespaced <alias>_* variable set, and
make build [BINARY=<alias>] selects one (default main). main = the retail EXE SLUS_007.26;
its artifact paths are preserved verbatim (build/us/, config/splat.us.exe.yaml,
config/check.us.sha, config/symbols.us.txt, .run/sig.SLUS_007.26.jsonl) so its rebuild is a
byte-exact no-op. make report/asm-differ are binary-selectable too (see below).
Adding a second binary (Phase 10+): append the alias to BINARIES and define its <alias>_*
block. New binaries use the clean convention — config/splat.<bin>.yaml, build/<bin>/,
config/check.<bin>.sha, config/symbols.<bin>.txt, .run/sig.<bin>.jsonl — plus per-binary
<bin>_VRAM_BASE (the fileoff→vram delta; overlays are not 0x8000F800-based) and
<bin>_TEXT_LO/HI. The EXE-only steps (the 9 PsyQ psyq_integrate calls, ld_interleave) are
gated under ifeq ($(BINARY),main); a second binary supplies its own.
Required parameters — no EXE default an overlay could inherit (the phase's #1-risk mitigation; a miss fails loud, never a silent wrong-address-later):
psyq_link.py/psyq_identify.py/psyq_link_lib.py/psyq_link_region.py:--vram-base <hex> --exe <path>psyq_integrate.py:--vram-base --exe --symbols <file>(flags go BEFORE the positionals)gen_lib_subsegs.py/make_snd_used.py/make_apicard_used.py:--vram-base --exe(EXE-curation tools — these CLI flags default to the EXE's values for convenience, but thread explicit values down to the now-required pipeline)ld_interleave.py:--order <address-ordered leaf list>for main since P31 S72 — its island is a 7-piece sandwich that--front/--tailcannot express (a*.data.oleaf contributes its(.data), a code-object leaf its(.rodata)carve). Overlays keep--front <obj> --tail <obj>(the sandwich.dataobjects) +--section .<binary>(Phase 26: default.main= the EXE; overlays with a §8 jtbl-rodata carve pass their own section — derives the<binary>_TEXT/DATA/RODATA/DATA2/BSSsymbol prefix)split_src_region.py:--symbols <file>- report scripts (
progress.py/difficulty.py/dup_report.py):--binary <alias>(defaultmain) - asm-differ: select via the
BFM_BINARYenv var (defaultmain);diff_settings.pymaps alias →{baseimg, myimg, mapfile}
Proof it's a no-op: the EXE rebuilds SHA1 143dbb89… through the parameterized path with AND
without the SDK objects, make report reproduces the counts, and a deliberately wrong
--vram-base (e.g. make build main_VRAM_BASE=0x8000F804) diverges to a non-143dbb89 hash
(the negative control — proves the param is load-bearing, not accepted-and-ignored).
First instantiation — resident (Phase 10): the always-resident engine blob
(extracted/retail/MAIN.CD.dir/FILE_010.dir/1.1, 365,404 B, vram 0x800CEDF8, type-1 uncompressed)
is the project's second binary — make build BINARY=resident → 8e17e02f… at 100% INCLUDE_ASM.
The reusable flat-blob recipe (every Gen2 overlay follows it):
- Per-binary source roots + OBJS prune — main lives at the repo-level
asm/+src/; a second binary nests atasm/<bin>/+src/<bin>/(<bin>_ASM_DIR/<bin>_SRC_DIR). TheOBJSglob is scoped to the active root with a$(BINARIES)-derived prune (-not -path 'asm/<sibling>/*', guarded by$(if $(filter $(ASM_DIR)/%,…))) so main's root doesn't sweep in nested siblings. build_path: buildin the<bin>yaml (NOTbuild/<bin>) — splat writes the.ld's object paths under$(build_path), and the Makefile pattern rules build them atbuild/asm/**+build/src/**; onlyelf_path/ld_script_path/output live underbuild/<bin>/. Per-binaryundefined_*_auto_pathunderbuild/<bin>/(splat options) +<bin>_UNDEF_SYMS/FUNCSaliases keep main's at the root verbatim.- Flat-image splat config — NO
headersegment (overlays carry no PS-X EXE header), NOgp_value(-G0; verify zero($gp)in the disasm), singlecodesegment atvram: <base>, stackedsymbol_addrs_path: [config/symbols.us.txt, config/symbols.<bin>.txt](the shared EXE globals the blob references + blob-local names). Iterate text/data boundaries againstmake check(Phase-5 method). - A leading data word before the code (e.g. the resident's 1-word header
0x00000036at the very base, code at +0x04) fightssection_order: [.rodata,.text,.data,.bss](which puts.dataafter.text). Emit it asrodata(no-dot type → asm rodata, placed FIRST) — a 1-word analogue of main's rodata-island, nold_interleaveneeded. - §8 jtbl-rodata carve (Phase 26 — only when a jr-function is matched): an overlay's gcc switch jump
tables sit in a contiguous
.rodataisland at the TAIL of the blob. Matching a jr-function makes its C emit that jtbl into.rodata(floated to the front bysection_order) while the raw copy stays in the data tail → duplicate. Fix = carve the fn's jtbl into a dotted[.rodata, <code-subseg>]subseg + set<bin>_JTBL_INTERLEAVE := --front <pre>.data.o --tail <post>.data.o …inconfig/overlays.mk(a$(strip)-guardedmake extractbranch then runsld_interleave --section .<bin>). The C body needscanon_sig_reconcilefirst. Full recipe + gotchas: cookbook §8a. (No carve ⇒ this is a no-op.) - Per-binary
<bin>_GHIDRA_PROG→make sig-refresh BINARY=<bin>;diff_settings.py+ the three report scripts gain a<bin>entry;make expectedis per-binary-safe (merge-copy, no sibling clobber).
Module-class binaries — md_* (P30 S44/S45; the §S44 loader table, docs/memory-map.md):
the small type-1 payloads (per-actor modules, the SC07 endgame pair) load at their OWN statically
derived slots (A 0x800CAE08 · B 0x800CCB1C · boot/resident 0x800CEDF8 · SC07 0x801A00D8),
not the shared overlay slot. Onboard with tools/new_binary.sh <alias> <payload> <VRAM> [TEXT_LO] (the generalized new_overlay.sh; registry config/modules.mk / MODULE_BINARIES).
Module-specific facts the recipe encodes:
- TEXT_LO ≠ 0 (the §154 module-id law: payload word0 is a global module id, sometimes followed
by a fn-ptr table and/or data): derive per payload from the first-prologue scan (
27BDxxxx) and the min fn-ptr-table target — NOT min-table alone (functions can precede the lowest table entry: the SC07 pair's real code start is 0xFC/0x158, their min table targets 0x930/0x370). - The header carve is a dot-typed
.rodataPAIRED with the c segment (same name), never a standalonerodata, hdrobject and neverbin: a module header can hold a function's JUMP TABLE, whose.Llabels only resolve when jtbl and function assemble in the SAME object (the EXE[0x63238,.rodata,800]precedent);binassets link in the data block (wrong placement). - A4 symbol-window law: a module whose window lies INSIDE another binary's symbol region must
NOT stack that binary's symbol file — the boot trio (
0x800CEDF8) omitssymbols.resident.txt(DsMix @0x800D1BD8 minted a phantom fn boundary in md_MAIN_011 before this). make sig-modulessigns every module at its own vram/TEXT_LO, seeding from the built ELF'sfunc_*symbols when a build exists (bootstrap's linear partition glues adjacent functions around jtbl dispatch); fresh-clone fallback is--bootstrap, self-healing on the next run.
§6.8 Cross-binary dedup & code-sharing (Phase 11) — "one match unlocks many"
Full how-to in docs/matching-cookbook.md §11. Command crib:
make sig-overlays— Ghidra-FREE sign all 134 location overlays (SCxx 0.4.dec) at the shared overlay vram0x80128158viatools/sig_image.py→.run/sig.ov_<SCxx>_<nnn>.jsonl(gitignored; ~27 s). Re-run when overlays change. (make sig-refreshstill does the Ghidra-imported EXE/resident sigs.)make report(gatedBINARY=main) runstools/dup_report.py --cross→docs/duplicates.cross.md: cross-binary duplicate groups across main + resident + all overlay sigs, ranked by collapsible bytes (the Phase-12/13 work queue), +tools/dedup_integrate.py --check(the byte-honesty gate — fail-closed if a registered share's sig hash drifts).- Share a matched fn across binaries: author the body ONCE as a macro in
src/shared/<fn>.h, instantiate at each site in each binary's.c, register the group inconfig/dedup.us.yaml({id, tier, hash, source, func, members:[{binary, vram, name}]}). Byte-gate = per-binarymake check.h_exact= risk-free;h_norm= candidate (accept only if every claiming binary stays byte-identical). NOT an object swap — game-code fns are interior to one object per binary (cookbook §11 / deviation D1). tools/sig_image.py:h_exactbyte-matches the Ghidra dumper (validated 100% on the resident contiguous set);h_normis self-consistent within the overlay fleet (not Ghidra-byte-exact — D2); overlay boundaries via linear partition +detect_code_end(BFS fails — overlays dispatch via function-pointer tables, notjal).- PsyQ provenance (R24): the resident is PsyQ 4.7 (
tools/psyq/conv47/, sha-recorded intools/psyq_CHECKSUMS.sha256) — Phase 12 links its embedded SDK code from 4.7, not the EXE's 4.0 libs.