Files
BFM-decomp/Makefile
T

1230 lines
82 KiB
Makefile
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Makefile — Brave Fencer Musashi decompilation (SLUS-00726, USA)
# =============================================================================
# Phase 4 deliverable. The ONLY live target is `check-env` (the Phase-4
# milestone: toolchain preflight). The split/build/check/expected/clean targets
# have their NAMES fixed here per docs/SETUP.md §6.3, but are loud-failing stubs
# until Phase 5 implements them. Run builds natively from the ext4 clone (H2/R2).
# =============================================================================
SHELL := /bin/bash
.ONESHELL:
# FAIL-CLOSED BY DEFAULT (Phase-27 T2). Without `-e`, .ONESHELL sends the WHOLE recipe to one
# `bash -c`, so a recipe's exit status is its LAST command's only — every earlier failure is
# silently swallowed. That made `report`'s lint_symbol_refs / progress --audit / difficulty /
# dup_report non-gates (dedup-check "worked" purely by being last), i.e. exactly the defect the
# 26-A audit exists to kill: a loud failure nobody counts is as invisible as a silent one (R32).
# `-e` makes every recipe line load-bearing. Deliberate opt-out: `check-env` (see its recipe).
.SHELLFLAGS := -ec
# DELETE A FAILED TARGET (Phase-30 S29). `as` reads a pipeline stream, so when an upstream stage
# dies mid-stream (e.g. jtbl_rodata_pads' fail-loud table-count guard) `as` has already written a
# TRUNCATED .o. make reports the error correctly — and then leaves the corpse on disk, newer than
# its .c. The NEXT build considers it up to date and LINKS it, turning a loud, attributable compile
# error into `undefined reference to $L105` / `func_8013C938` one build later. That is precisely how
# the `JR-PAIR-IN-ONE-O0-OBJECT` "wall" was manufactured (§132). Same family as the §42b/§130
# stale-object traps: an artifact that outlives the command that failed to produce it.
.DELETE_ON_ERROR:
.DEFAULT_GOAL := help
# --- paths & tooling ---------------------------------------------------------
PYTHON := python3
VENV := .venv
VENV_PY := $(VENV)/bin/python
MIPS_PREFIX := mipsel-linux-gnu-
AS := $(MIPS_PREFIX)as
LD := $(MIPS_PREFIX)ld
OBJCOPY := $(MIPS_PREFIX)objcopy
CC1_PSX := tools/bin/gcc-2.7.2-psx/cc1
CC1_CDK := tools/bin/gcc-2.7.2-cdk/cc1
MASPSX := tools/maspsx/maspsx.py
# =============================================================================
# Binaries — data-driven (Phase 9). Each binary is an alias key in BINARIES with a
# namespaced <alias>_* variable set. `main` is the retail EXE SLUS_007.26 (the FIRST
# instance); its artifact paths are PRESERVED VERBATIM (build/us/, *.us.* config) so
# its rebuild stays a byte-exact no-op. The clean <bin> path convention (config/
# splat.<bin>.yaml, build/<bin>/, config/check.<bin>.sha, config/symbols.<bin>.txt,
# .run/sig.<bin>.jsonl) is documented now but first INSTANTIATED by Phase 10's second
# binary. Select with `make build BINARY=<alias>`; defaults to the EXE.
# -----------------------------------------------------------------------------
# Overlay binaries (Phase 13): each location overlay is registered as an alias in the
# GENERATED config/overlays.mk (it defines OVERLAY_BINARIES + the per-<ov> var blocks),
# kept out of this hand-maintained file so tools/new_overlay.sh never edits the Makefile
# body. The `-include` is silent when absent (fresh clone / no overlays onboarded yet) ->
# OVERLAY_BINARIES expands empty -> BINARIES stays `main resident` and every byte-locked
# build is unchanged. Must precede the `:=` BINARIES line (simply-expanded -> read now).
-include config/overlays.mk
# P30 S44: module-class binaries (own load address, resident-shaped — the md_* small actor modules
# + the SC07 endgame pair + the raw-stored overlays' registry). Same contract as overlays.mk:
# generated by tools/new_binary.sh, silent when absent, must precede the `:=` line.
-include config/modules.mk
BINARIES := main resident $(OVERLAY_BINARIES) $(MODULE_BINARIES)
BINARY ?= main
$(if $(filter $(BINARY),$(BINARIES)),,$(error BINARY='$(BINARY)' not in BINARIES='$(BINARIES)'))
# --- main (retail EXE SLUS_007.26) — values preserved from Phases 4-8 ---------
main_EXE := extracted/retail/SLUS_007.26
main_NAME := SLUS_007.26
main_OUT_DIR := build/us
main_OUT := $(main_OUT_DIR)/$(main_NAME)
main_ELF := $(main_OUT).elf
main_MAPFILE := $(main_OUT).map
main_LD_SCRIPT := $(main_OUT).ld
main_SPLAT_YAML := config/splat.us.exe.yaml
main_CHECK_SHA := config/check.us.sha
main_SYMBOLS := config/symbols.us.txt
main_SIG := .run/sig.SLUS_007.26.jsonl
main_GHIDRA_PROG := SLUS_007.26
# The fileoff->vram relation: text loads at file 0x800 / vram 0x80010000, so
# base = 0x80010000 - 0x800 = 0x8000F800. NOT a universal PS1 constant — overlays
# differ. Threaded into the tools as a REQUIRED param starting T5; defined here now.
main_VRAM_BASE := 0x8000F800
main_TEXT_LO := 0x80010000
main_TEXT_HI := 0x800629DC
# Source roots + undefined-sym outputs: main lives at the repo root (verbatim).
main_ASM_DIR := asm
main_SRC_DIR := src
main_UNDEF_SYMS := undefined_syms_auto.txt
main_UNDEF_FUNCS := undefined_funcs_auto.txt
# --- resident (engine blob MAIN.CD/FILE_010/1.1, vram 0x800CEDF8 — Phase 10) ----
# The always-resident engine blob: extracted type-1 (uncompressed) payload, load
# address RAM-proven in Phase 3 (T6b). Flat image (no PS-X EXE header); fileoff 0 ->
# vram 0x800CEDF8 so VRAM_BASE = 0x800CEDF8 (NOT main's 0x8000F800). 365,404 B (0x5935C)
# -> end vram 0x80128154. Its splat output NESTS under asm/resident + src/resident +
# build/resident, kept disjoint from main's asm/ + src/ by the per-binary OBJS glob below.
resident_EXE := extracted/retail/MAIN.CD.dir/FILE_010.dir/1.1
resident_NAME := resident
resident_OUT_DIR := build/resident
resident_OUT := $(resident_OUT_DIR)/$(resident_NAME)
resident_ELF := $(resident_OUT).elf
resident_MAPFILE := $(resident_OUT).map
resident_LD_SCRIPT := $(resident_OUT).ld
resident_SPLAT_YAML := config/splat.resident.yaml
resident_CHECK_SHA := config/check.resident.sha
resident_SYMBOLS := config/symbols.resident.txt
resident_SIG := .run/sig.resident.jsonl
resident_GHIDRA_PROG := resident
resident_VRAM_BASE := 0x800CEDF8
resident_TEXT_LO := 0x800CEDF8
resident_TEXT_HI := 0x80128154
resident_ASM_DIR := asm/resident
resident_SRC_DIR := src/resident
resident_UNDEF_SYMS := build/resident/undefined_syms_auto.txt
resident_UNDEF_FUNCS := build/resident/undefined_funcs_auto.txt
# --- selected-binary aliases (resolve $(BINARY) -> the active instance) -------
EXE := $($(BINARY)_EXE)
NAME := $($(BINARY)_NAME)
OUT_DIR := $($(BINARY)_OUT_DIR)
OUT := $($(BINARY)_OUT)
ELF := $($(BINARY)_ELF)
MAPFILE := $($(BINARY)_MAPFILE)
LD_SCRIPT := $($(BINARY)_LD_SCRIPT)
# Phase-26 §8: overlay jtbl-rodata carve args (empty = no carve). $(strip) so a per-binary var
# that is unset stays EMPTY (a trailing comment on the := line would leave whitespace -> non-empty
# -> the extract branch would misfire on every binary; caught on resident).
JTBL_INTERLEAVE := $(strip $($(BINARY)_JTBL_INTERLEAVE))
SPLAT_YAML := $($(BINARY)_SPLAT_YAML)
CHECK_SHA := $($(BINARY)_CHECK_SHA)
SYMBOLS := $($(BINARY)_SYMBOLS)
VRAM_BASE := $($(BINARY)_VRAM_BASE)
TEXT_LO := $($(BINARY)_TEXT_LO)
TEXT_HI := $($(BINARY)_TEXT_HI)
ASM_DIR := $($(BINARY)_ASM_DIR)
SRC_DIR := $($(BINARY)_SRC_DIR)
UNDEF_SYMS := $($(BINARY)_UNDEF_SYMS)
UNDEF_FUNCS := $($(BINARY)_UNDEF_FUNCS)
GHIDRA_PROG := $($(BINARY)_GHIDRA_PROG)
# cc1 smoke flags — the §5.4 first-candidate set; the real triple is pinned only
# after Phase-6 fingerprinting. Used here purely to prove cc1 executes.
CC1_SMOKE_FLAGS := -quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# binutils regression line (docs/SETUP.md §4.5): >= 2.38 is a WARN (PS1-matching
# regression suspect; 2.35 known-good). The verdict is revisited in Phase 5.
BINUTILS_WARN_MAJOR := 2
BINUTILS_WARN_MINOR := 38
.PHONY: help bootstrap check-env disc-extract extract build check expected clean report sig-refresh sig-overlays sig-resident sig-main sdk-dual build-all check-all audit-corpus audit-cdecl audit-binaries audit-text-sources audit-digest audit-frontier tools-health
# -----------------------------------------------------------------------------
help:
@echo "BFM-decomp — make targets (218 binaries: main + resident + 141 overlays + 75 modules; BINARY=<alias> scopes a target)"
echo " make bootstrap fresh-clone setup: apt check (printed), venv, submodules, the two cc1 tarballs, then check-env"
echo " make check-env toolchain preflight (python/venv/cc1/maspsx/binutils/headers; the extracted EXE + payload census)"
echo " make disc-extract regenerate extracted/ from YOUR redump dump in disks/ and verify it against the committed manifest (H1: no ROM in the repo)"
echo " make extract splat split one binary -> asm/, the linker script (runs disc-extract if its payload is absent)"
echo " make extract-all disc-extract + splat split every binary (main first, then parallel)"
echo " make check build one binary and compare its SHA1 to config/check.<alias>.sha (build is an alias)"
echo " make check-all build + SHA1-check every binary in parallel (build-all is an alias)"
echo " make sdk-dual main byte-identical WITH and WITHOUT the (optional, user-supplied) PsyQ objects"
echo " make report regenerate docs/ progress + difficulty + duplicate digests (BINARY=main also the fleet roll-up)"
echo " make tools-health the pre-work ritual: fresh sigs, both boundary oracles, every audit, report, digest assertion"
echo " make sig-main | sig-main-oracle | sig-overlays | sig-resident | sig-modules the byte-derived function sigs (.run/sig.*.jsonl)"
echo " make audit-corpus | audit-binaries | audit-text-sources | audit-digest | audit-disc | audit-frontier the oracles"
echo " make clean remove build/, expected/, asm/, assets/ (fleet-wide; then: make extract-all && make check-all)"
echo "The R22 contract proof: make clean && make extract-all && make check-all (expects: check-all: 218 passed, 0 failed of 218)"
# -----------------------------------------------------------------------------
# Phase 7 reports: deterministic, committable docs/ digests. progress/difficulty/dup_report
# are Ghidra-free; sig-refresh regenerates dup_report's input from the saved Ghidra DB.
GHIDRA := $(or $(GHIDRA_INSTALL_DIR),$(HOME)/ghidra_12.1_PUBLIC)
GHIDRA_PROJ := $(or $(BFM_GHIDRA_PROJ),$(CURDIR)/ghidra) # P33 B5: repo-relative (override: BFM_GHIDRA_PROJ)
# The corpus oracle (Phase 26-A, R32/R33). A SECOND, INDEPENDENT oracle: it cross-checks splat's
# function boundaries against sig_image's, which are derived from the ORIGINAL bytes without splat.
# The byte-gate is structurally BLIND to a bad boundary (the .s halves are pasted back verbatim, so
# the image stays byte-identical) — only an oracle that can DISAGREE can see it. GREEN since A4
# (0 phantom + 0 truncated; was 193 unmatchable slices from one bad symbol line).
audit-corpus:
$(VENV_PY) tools/corpus.py --all --audit
# The C-declaration oracle (Phase 26-A; R33 BEFORE R32). ONE parser, replacing fifteen regex models
# of what a C declaration is — models that disagree with each other and are blind, all fifteen, to
# fn-ptr / sized-array / multi-declarator decls.
#
# It is a GATE, not just a capability, because this phase paid to learn that a loud failure NOBODY
# COUNTS is exactly as invisible as a silent one (build_engine_types failed loudly for four phases
# while hard-exiting on 81% of its own corpus). So: run it, and count it.
#
# Coverage is asserted from the C GRAMMAR itself — at file scope C admits nothing but declarations,
# so the candidate set is every depth-0 statement, and there is no hand-maintained candidate regex
# to rot. The real cross-gcc then adjudicates BOTH the parse and the residue (R34): it compiles each
# declaration beside this parser's reconstruction of it, and a statement gcc also rejects is not C.
# SAMPLED BY DEFAULT (P31 S70). This is a HEALTH check, not a regression suite. The full pass
# re-parses every declaration in all 4,168 TUs and hands each to real gcc: ~787s of pure-Python
# collection before the first cc1 call, and it made `make tools-health` unrunnable (>15 min, killed
# twice). The exhaustive form still exists as `audit-cdecl-full` — run it when cdecl.py itself
# changes, not on every health check.
CDECL_AUDIT_TUS ?= 60
audit-cdecl:
$(VENV_PY) tools/cdecl.py --audit --gcc --limit $(CDECL_AUDIT_TUS)
audit-cdecl-full:
$(VENV_PY) tools/cdecl.py --audit --gcc
# Binary-citizenship gate (Phase-28 T7, R36 via R32). Asserts every onboarded binary
# (main + resident + every config/splat.ov_*.yaml) is a full citizen of every consumer that
# enumerates binaries: present in dup_report.BINARIES, has a sig, and (overlays) includes the shared
# engine-core header so shared bodies can reach it. The 4 SC07 overlays were byte-clean yet invisible
# to four consumers for a month; this is the loud assertion that makes the NEXT onboarding wire the
# binary in or fail here, before matching is built on a binary half the tools cannot see. Cheap
# (config + text scans; no build), so unlike audit-cdecl it CAN sit in the fast lane.
audit-binaries:
$(VENV_PY) tools/audit_binaries.py
# P30 S43 (Drew's directive): the DENOMINATOR's completeness gate. Walks the DISC IMAGE, not our
# configs, and asserts every byte lands in exactly one bucket — residue is a DEFECT (R32). This is
# what makes "there was more code all along" a finding the tools report rather than a surprise we
# trip over: three such surprises (the 0.4.dec glob, disc_code_sweep's raw-only decode, the 4,096-word
# window) were each a tool correct about its subset and silent about the rest.
# NOT in tools-health: it needs disks/, which a fresh clone does not have (H1 — the dump is ignored).
audit-disc:
$(VENV_PY) tools/disc_audit.py
# P30 S1e: the committed fleet digest must still describe the CURRENT tree. A digest generated from
# a working tree that later changed (work reverted before the commit landed) is BYTE-INVISIBLE —
# check-all stays 140/140 over it — and the next honest regeneration then reads as a REGRESSION that
# never happened. That cost a session-opening false alarm and gated the best-performing lever on a
# phantom. R34: the byte-gate is a null oracle for documents, so this is a second one that disagrees.
audit-digest:
$(VENV_PY) tools/audit_digest.py
# P33 D3: the published DATA must describe the current tree too — docs/progress.json, the README's
# generated block and the badge files (the same numbers as the digest; R51: never typed)
$(VENV_PY) tools/progress.py --json --readme --check
# P30 S39 (Drew's MASTER_REMAINING proposal, derived form — docs/decision-log.md 2026-08-04):
# "what's left" is answered by six artifacts, each individually derived and NONE ever checked
# against the others. That gap cost T0 a hand-reconciliation (family_hseq 29,961 vs progress 28,296).
# This is the R34 move: a SECOND view that can DISAGREE with the corpus oracle, loudly.
# DELIBERATELY NOT in tools-health yet — additive until Drew has seen it; wiring is one line.
audit-frontier:
$(VENV_PY) tools/audit_frontier.py
# P30 S28: every tracked C source must be TEXT. A raw NUL inside a char literal (`'<NUL>'` instead
# of `'\0'`) COMPILES — the fleet stayed byte-identical — but grep treats the file as BINARY and
# reports nothing, silently, so the file vanishes from every grep-based audit and hand-search. The
# byte-gate is structurally blind to it (R34: correct bytes, nothing to say). Found when a
# `grep -rn func_8013C08C src/` came back empty for a function defined right there; a templated body
# had then carried the NUL into 137 overlays in this same session. Its own oracle, fail-closed.
audit-text-sources:
$(VENV_PY) tools/audit_text_sources.py
# The tool-health ritual (Phase-27 T2). Before the 26-A audit the two oracles above had NO dependent
# — nothing invoked them, so "run the audits" was a manual habit, and a habit nobody automates is a
# gate nobody counts (R32). This is that dependent: `make tools-health` runs both derived oracles and
# the report gates (lint_symbol_refs + dedup-check) together, and under the global -e ANY one failing
# aborts it. It is deliberately NOT a prerequisite of `report`/`build` — audit-cdecl cross-compiles
# every C declaration through real gcc (minutes), so it belongs to a deliberate pre-matching ritual,
# not the inner harvest loop. Matches the roadmap's standing invariant (audit-corpus · audit-cdecl ·
# report green before matching).
tools-health:
# Regenerate the byte-derived boundary oracles FIRST (they're gitignored/regenerable), so the
# audit checks CURRENT sigs and never crashes on an absent one — the resident audit (T10) needs
# the sig_image resident sig, and a fresh clone has neither it nor the overlay sigs.
$(MAKE) --no-print-directory sig-overlays
$(MAKE) --no-print-directory sig-resident
$(MAKE) --no-print-directory sig-modules
# main's independent oracle (P31 S77, contract §1.3). Regenerated here for the same reason as
# the others: it is derived and gitignored, and an oracle wired into nothing runs for nobody —
# neighbor_ref sat MANUAL from S68 to S77 while the playbook called it the biggest cost lever
# in the wave. Without this line sig_is_independent("main") silently reverts to False on a
# fresh clone and main's boundary blind spot comes back with the audit still green.
$(MAKE) --no-print-directory sig-main-oracle
# P33 A2: main's build-derived game-code sig — the one the fleet digest weighs main by. Regenerated
# here for the R51 reason: progress.py prefers it, so a stale copy would be a stale denominator.
$(MAKE) --no-print-directory sig-main
# P33 A3: the with/without-SDK dual (contract §1.2). Skipped, loudly, on a machine without the SDK
# objects — there every build already IS the without leg, and running it twice would prove nothing.
if [ -d "$(LIBCD_ELF)" ] && [ -d "$(LIBPAD_ELF)" ]; then
$(MAKE) --no-print-directory sdk-dual
else
echo "[skip] sdk-dual: no SDK objects on this machine — the default build IS the no-SDK leg"
fi
$(MAKE) --no-print-directory audit-corpus
$(MAKE) --no-print-directory audit-cdecl
$(MAKE) --no-print-directory audit-binaries
$(MAKE) --no-print-directory audit-text-sources
$(MAKE) --no-print-directory report BINARY=main
# AFTER report (which regenerates the digest), so this asserts the freshly-written digest agrees
# with the tree — and, on a tree whose digest was committed stale, says so instead of staying green.
$(MAKE) --no-print-directory audit-digest
# The cookbook index is DERIVED (R33) and self-asserts its coverage (R32). Stale = agents can't
# find documented idioms and re-derive them at full token cost (measured, P30 wave 1).
$(VENV_PY) tools/cookbook_index.py --check
$(VENV_PY) tools/gccmap_cites.py --check
# P33 B5: the Ghidra roster is DERIVED from config/ghidra/*.jsonl (R33); a stale roster misreports
# which programs' RE work is tracked as text. Pure text check, no Ghidra needed.
$(VENV_PY) tools/ghidra_roster.py --check
# P33 D5: every relative link in the public-facing docs resolves (pending pages are listed, and must be gone by gate 2)
$(VENV_PY) tools/doc_links.py
$(VENV_PY) tools/wiki_render.py --selftest
$(VENV_PY) tools/xsig/tests/test_xsig.py 2>&1 | tail -1 | grep -q '^OK' && echo 'xsig tests: OK (8)' || { echo 'xsig tests: FAIL'; exit 1; }
# Behavioural guards (P31 S70): tools-health audits DATA integrity; these assert that a tool
# ACTUALLY DID the work it reports. A guard that is not running is not a guard (R54).
$(VENV_PY) tools/work_evidence.py --selftest
# P31 S72: a code subseg owning raw jump tables in >1 non-adjacent span makes every switch
# function outside the one carveable span UNBANKABLE — `main` sat in that state from Phase 7 to
# Phase 31 and eleven functions were written off as "PROVEN gate-rejects" because of it. The
# evidence is derivable from the raw image on day one; nothing was comparing it. 3.7s fleet-wide.
$(VENV_PY) tools/split_indicator.py --self-test
# P31 S74: A HARD GATE NOW, exactly as the informational form said it would become. The four
# violations it was waiting on (ov_SC01_084, ov_SC02_005, ov_SC02_011, ov_SC03_105 — 16 open
# fns / 3,613 ins) are split, so the fleet is 213/213 OK and any NEW subseg owning raw tables
# in >1 non-adjacent span is a regression that must fail here rather than be echoed past.
$(VENV_PY) tools/split_indicator.py --quiet
echo "tools-health: OK — sigs fresh; corpus(+resident) + cdecl + binaries + report(lint+dedup) + cookbook-index all green."
report:
$(VENV_PY) tools/progress.py --binary $(BINARY) --audit
$(VENV_PY) tools/difficulty.py --binary $(BINARY)
$(VENV_PY) tools/dup_report.py --binary $(BINARY)
# Cross-binary dedup report (Phase 11): binary-spanning, run ONCE (not per-binary), so it
# only fires for the default binary — avoids `make report BINARY=resident` rewriting the
# identical file. --cross ignores --binary and scans every sig in BINARIES.
ifeq ($(BINARY),main)
# P33 A2: refresh main's build-derived sig before anything weighs main by it (a no-op message when
# main is not built; progress.py then falls back to the legacy Ghidra sig or fails loudly — R32).
$(MAKE) --no-print-directory sig-main
$(VENV_PY) tools/dup_report.py --cross
# Fleet roll-up (Phase 15): deterministic per-binary table + fleet totals -> docs/progress.fleet.md.
$(VENV_PY) tools/progress.py --fleet
# P33 D1/D3: the same numbers as DATA — docs/progress.json + the README block (never typed by hand)
$(VENV_PY) tools/progress.py --json --readme
# Backlog compaction (Phase 29): the near-miss log is append-only, so it fills with already-banked
# noise (measured 6,867 rows, 98% banked). prune rewrites .run/backlog.jsonl to the open near-misses
# (drop-now-matched P9 + best-per-addr) so the ledger tracks reality instead of drifting stale.
$(VENV_PY) tools/backlog.py prune
# Rename-drift gate (Phase 26-A): fail-closed if a symbols.us.txt rename left a func_<ADDR>
# ref dangling in committed src/ or src/shared/*.h — the R22 failure mode an incremental build
# masks (stale .o) but a genuinely-clean rebuild fails on. The ONLY detector for it.
$(VENV_PY) tools/lint_symbol_refs.py
# Byte-honesty gate (Phase 11): fail-closed if any registered code-share drifted from its
# recorded signature hash. Last in the recipe, so a stale share fails `make report` (P9).
$(VENV_PY) tools/dedup_integrate.py --check
endif
sig-refresh:
@if ss -tln 2>/dev/null | grep -qE ':8080([^0-9]|$$)'; then
echo "sig-refresh: ERROR — Ghidra MCP serving on :8080; run tools/ghidra_mcp_stop.sh first."; exit 2
fi
"$(GHIDRA)/support/analyzeHeadless" "$(GHIDRA_PROJ)" bfm -process $(GHIDRA_PROG) -noanalysis -readOnly \
-scriptPath tools/ghidra_scripts -postScript DumpFunctionSignatures.java
# sig-overlays (Phase 11): Ghidra-FREE — sign every location-overlay payload (the 134 SCxx 0.4.dec)
# at the shared overlay vram with tools/sig_image.py, so `make report` (--cross) can find cross-overlay
# duplicates. Each -> .run/sig.ov_<SCxx>_<nnn>.jsonl (gitignored; regenerable). Re-run when overlays
# change; not part of `make report` (it scans whatever ov_* sigs exist, like sig-refresh).
OVERLAY_VRAM := 0x80128158
# Derived from config/overlays.mk's <ov>_EXE payloads (the SINGLE source of truth, R33) — NOT a
# `find … 0.4.dec` glob, which silently dropped the 4 Phase-27 SC07 overlays whose code is at PAC
# entry 1 (1.4.dec). `<alias>:<payload>` pairs built at Make level so every onboarded overlay signs.
OVERLAY_SIG_JOBS := $(foreach a,$(OVERLAY_BINARIES),$(a):$($(a)_EXE))
sig-overlays:
# PARALLEL (P31 S70). 211 independent per-overlay invocations that each write ONLY their own
# .run/sig.<alias>.jsonl (sig_image has exactly one write path, verified) — embarrassingly
# parallel, and it was a serial `for` loop on a 32-core box while extract-all/check-all in this
# same file already fan out. MEASURED, correcting my first claim: this was only ~52s of
# tools-health, NOT the bulk — audit-cdecl's pure-Python collection pass (~787s) is the real cost.
# Still worth it (52s -> 3.9s, 141/141 outputs byte-identical) and it is Drew's standing bar:
# a slow gate is a BUG, nothing serial.
@mkdir -p .run; : > .run/sig-overlays.txt
echo "$(OVERLAY_SIG_JOBS)" | tr ' ' '\n' | sed '/^$$/d' | xargs -P$(JOBS) -I{} sh -c '\
job="{}"; alias=$${job%%:*}; f=$${job#*:}; \
if [ ! -f "$$f" ]; then echo "[WARN no payload] $$alias ($$f)"; \
elif $(VENV_PY) tools/sig_image.py --image "$$f" --vram-base $(OVERLAY_VRAM) --bootstrap --name "$$alias" >/dev/null 2>&1; \
then echo "[ OK ] $$alias"; else echo "[SIG FAIL] $$alias"; fi' | tee .run/sig-overlays.txt
n=$$(grep -c "^\[ OK \]" .run/sig-overlays.txt || true)
bad=$$(grep -c "^\[SIG FAIL\]" .run/sig-overlays.txt || true)
echo "sig-overlays: signed $$n overlays -> .run/sig.ov_*.jsonl (of $(words $(OVERLAY_BINARIES)) onboarded)"
# The serial form had NO failure detection at all — a sig_image crash just vanished (R32).
if [ "$$bad" -ne 0 ]; then echo "[FAIL] sig-overlays: $$bad overlay(s) failed to sign"; exit 1; fi
# sig-resident (Phase-27 T10; P31 T0 seed fix): sign the resident flat blob with sig_image — the
# Ghidra-FREE, byte-DERIVED signer — so `make audit-corpus` gains a second boundary oracle for the
# resident (R34; corpus.sig_is_independent trusts it, same standing as the S45 ELF-seeded modules).
# P31 T0: --bootstrap's linear partition produced a WRONG denominator (144 rows vs the true 145) via
# two boundary artifacts — it fused the +0 data word with the first function (row 0x800CEDF8 nins=18
# instead of func_800CEDFC) and glued/truncated the tail pair (func_800D33E0 missing). Fixed by the
# S45 pattern: SEED from the built ELF's T symbols (unique 4-aligned addrs inside the
# resident_TEXT_START/END markers — reproduces exactly 145, matching progress + the source defs).
# Fresh-clone fallback (no build yet) stays --bootstrap and self-heals on the next run after a build.
# h_exact is raw-byte SHA1 so it is format-independent — weighted_metrics is unaffected. R23-free.
sig-resident:
elf="build/resident/resident.elf"
if [ -f "$$elf" ]; then
mipsel-linux-gnu-nm "$$elf" | awk '
$$2=="T" { a=strtonum("0x" $$1); sym[$$3]=a; if (a%4==0) addr[a]=1 }
END { lo=sym["resident_TEXT_START"]; hi=sym["resident_TEXT_END"];
for (a in addr) if (a>=lo && a<hi) printf "0x%08X\n", a }' \
| sort -u > .run/seeds.resident.txt
$(VENV_PY) tools/sig_image.py --image $(resident_EXE) --vram-base $(resident_VRAM_BASE) \
--seeds .run/seeds.resident.txt --name resident
echo "sig-resident: signed the resident (ELF-seeded, $$(wc -l < .run/seeds.resident.txt) fns) -> .run/sig.resident.jsonl"
else
$(VENV_PY) tools/sig_image.py --image $(resident_EXE) --vram-base $(resident_VRAM_BASE) --bootstrap --name resident
echo "sig-resident: signed the resident (bootstrap fallback — re-run after a build for seeded boundaries)"
fi
# atlas (P31 T5): the full Frontier Atlas regen chain — family maps -> cards -> features -> the
# partition-asserted atlas (every open stub in exactly one lever-labeled crack group). Run at
# session T0 and after crack batches; ~10-15 min, zero tokens. Standalone atlas.py runs tolerate
# maps that are stale only in the banked-since direction; this chain makes the normal path fresh.
atlas:
$(VENV_PY) tools/family_hseq.py >/dev/null
$(VENV_PY) tools/family_cousins.py >/dev/null
$(VENV_PY) tools/family_cousins.py --adapt-cards >/dev/null
$(VENV_PY) tools/family_cousins.py --aprop-cards >/dev/null
$(VENV_PY) tools/atlas_features.py
$(VENV_PY) tools/atlas.py
echo "atlas: chain complete -> .run/atlas.json + docs/frontier-atlas.md"
# sig-main (P31 T3): sign main's game-code STUBS with sig_image at SPLAT-TRUE lengths. main has a
# 0x800 EXE header (file0-vram = $(main_VRAM_BASE)), interleaved data islands, and LINKED PsyQ
# regions, so --bootstrap/func_end both mis-slice (measured 3/40 nins drift vs the .s truth);
# instead `corpus.py main --seed-ends` emits `0xADDR NINS` per stub (the .s count a C match must
# reproduce) and each slice is exactly [addr, addr+4*nins) — no heuristic, no contiguity assumption
# beyond each function's own slice. This sig is deliberately splat-SEEDED (the atlas needs the
# boundaries a match must hit); it is NOT main's independent second oracle
# (docs/second-oracle.md — sig_is_independent stays False for main).
# P33 A2 — REWRITTEN: main's GAME-CODE sig at BUILD-TRUE lengths, Ghidra-free and splat-free. The stub
# seeding above became a sig of nothing at 100% (0 stubs), and the whole-EXE alternative was the
# 2026-08-05 Ghidra sig (.run/sig.SLUS_007.26.jsonl), which a public clone cannot regenerate and whose
# flow-derived boundary on FUN_80023bf0 was 22 ins short (P31 S79). Seeds now come from the linked
# objects themselves — tools/main_seed_ends.py reads each game-code object's .text section from the
# link map and slices it at its own nm symbols (the data islands and the LINKED blocks sit BETWEEN
# objects, so every slice is exact); sig_image then hashes the ORIGINAL EXE bytes at those boundaries.
# This is the sig progress.py weighs main by. Needs a built main (map + objects): without one it
# leaves any existing .run/sig.main.jsonl alone and says so — never a heuristic sig under the same
# name (R51).
sig-main:
if [ -f "$(main_MAPFILE)" ]; then
$(VENV_PY) tools/main_seed_ends.py --map $(main_MAPFILE) --out .run/seeds.main.txt
$(VENV_PY) tools/sig_image.py --image $(main_EXE) --vram-base $(main_VRAM_BASE) --seeds .run/seeds.main.txt --name main
echo "sig-main: signed $$(wc -l < .run/seeds.main.txt) main game-code fns (build-true lengths, from the link map + objects) -> .run/sig.main.jsonl"
else
echo "sig-main: no main build ($(main_MAPFILE) absent) — run 'make check BINARY=main' first; .run/sig.main.jsonl left as is"
fi
# sdk-dual (P33 A3) — main byte-identical WITH the real PsyQ objects AND WITHOUT them (roadmap contract
# §1.2, the fresh-clone fallback invariant), both legs against config/check.us.sha, as ONE target:
# leg 1 extract + check -> the SDK objects linked (map must list build/psyq/libcd/)
# leg 2 extract + rm build/psyq + check NO_SDK=1 -> the INCLUDE_ASM stub tiles (map must list
# build/src/libcd1.o and no build/psyq/)
# leg 3 extract + check -> the tree back in its default (WITH) state
# `extract BINARY=main` sits between the legs because psyq_integrate rewrites build/us/SLUS_007.26.ld
# IN PLACE and extract regenerates it (the incremental trap tools/gate_main.py documents). Refuses to
# run when any SDK object dir is absent — running the same leg twice and calling it a dual is exactly
# the false green R32 forbids; a public clone's every `make check BINARY=main` already IS leg 2.
# Run as `make -j$(nproc) sdk-dual` (the sub-makes inherit the jobserver). Maps kept for the record.
# (recursively expanded — the *_ELF variables are defined further down the file)
SDK_ELF_DIRS = $(LIBCD_ELF) $(LIBGS_ELF) $(LIBETC_ELF) $(LIBGPU_ELF) $(LIBMCRD_ELF) $(LIBC2_ELF) $(LIBGTE_ELF) $(SND_ELF) $(APICARD_ELF) $(LIBAPI42_ELF) $(LIBPAD_ELF)
SDK_DUAL_DIR := .run/P33/verify
sdk-dual:
@set -e
for d in $(SDK_ELF_DIRS); do
if [ ! -d "$$d" ]; then
echo "[FAIL] sdk-dual: the WITH leg cannot run — $$d is absent (tools/fetch_psyq.sh); refusing to run one leg twice and call it a dual (R32)"
exit 1
fi
done
mkdir -p $(SDK_DUAL_DIR)
echo "sdk-dual: leg 1 — WITH the PsyQ objects"
$(MAKE) --no-print-directory extract BINARY=main
$(MAKE) --no-print-directory check BINARY=main
cp $(main_MAPFILE) $(SDK_DUAL_DIR)/main_with_sdk.map
grep -q 'build/psyq/libcd/' $(SDK_DUAL_DIR)/main_with_sdk.map || { echo "[FAIL] sdk-dual: leg 1 did not link build/psyq/libcd/ — not a WITH build"; exit 1; }
echo "sdk-dual: leg 2 — WITHOUT (NO_SDK=1, build/psyq removed)"
$(MAKE) --no-print-directory extract BINARY=main
rm -rf build/psyq
$(MAKE) --no-print-directory check BINARY=main NO_SDK=1
cp $(main_MAPFILE) $(SDK_DUAL_DIR)/main_no_sdk.map
if grep -q 'build/psyq/' $(SDK_DUAL_DIR)/main_no_sdk.map; then echo "[FAIL] sdk-dual: leg 2 linked build/psyq/ — not a WITHOUT build"; exit 1; fi
grep -q 'build/src/libcd1.o' $(SDK_DUAL_DIR)/main_no_sdk.map || { echo "[FAIL] sdk-dual: leg 2 did not link the libcd1 stub tile"; exit 1; }
echo "sdk-dual: leg 3 — restoring the default (WITH) state"
$(MAKE) --no-print-directory extract BINARY=main
$(MAKE) --no-print-directory check BINARY=main
echo "sdk-dual: OK — main $$(cut -d' ' -f1 $(main_CHECK_SHA)) byte-identical WITH and WITHOUT the PsyQ objects (maps: $(SDK_DUAL_DIR)/main_with_sdk.map, main_no_sdk.map)"
# sig-main-oracle (P31 S77) — MAIN'S INDEPENDENT SECOND ORACLE (roadmap contract §1.3).
# Distinct from `sig-main` above, which is splat-SEEDED on purpose. This one signs the ORIGINAL EXE
# bytes with NO splat symbols: `--vram-base 0x8000F800` puts file offset 0 at vram (so the 0x800
# PS-X EXE header simply falls below the first range), and `--segments` derives the game-code ranges
# from the splat yaml's SEGMENT TYPES — coarse structure, never splat's FUNCTION boundaries, which
# is the thing the oracle must stay free to disagree with. Entries inside each range are found by
# byte-derived jal-closure, because seeding from splat's symbols would make every phantom look real
# (docs/second-oracle.md names that trap). LINKED PsyQ blocks are excluded: real library objects,
# outside the game-code denominator, and auditing them here would report ~960 phantoms that are
# artefacts of comparing two oracles that never measured the same thing.
sig-main-oracle:
@$(VENV_PY) -c "import sys;sys.path.insert(0,'tools');import progress;progress.set_binary('main');print(','.join(sorted(progress.LINKED_SEGS)))" > .run/main_linked_segs.txt
$(VENV_PY) tools/sig_image.py --image $(main_EXE) --vram-base 0x8000F800 \
--segments config/splat.us.exe.yaml --exclude-subsegs "$$(cat .run/main_linked_segs.txt)" \
--bootstrap --out .run/sig.main.oracle.jsonl
@$(VENV_PY) -c "import sys;sys.path.insert(0,'tools');import corpus;r=corpus.audit('main');print('sig-main-oracle: main is now INDEPENDENT — %d in-domain stubs, %d PHANTOM, %d TRUNCATED, %d PAD-TAIL'%(r['stubs'],len(r['phantom']),len(r['truncated']),len(r['pad_tail'])))"
# sig-modules (P30 S44): sign every module-class binary at ITS OWN vram (from modules.mk) with its
# own TEXT_LO (the §154 module-id-word law: code starts past the header; bootstrap from offset 0
# yields 0 functions). Same derived-jobs shape as sig-overlays (R33). Empty MODULE_BINARIES = no-op.
# S45: SEED from the built ELF's text symbols when the build exists (R33 — splat's post-link
# boundaries are the finer oracle; --bootstrap's linear partition GLUES adjacent functions around
# jtbl-dispatch code, which read as 24 TRUNCATED slices in audit-corpus). Fresh-clone fallback
# (no build yet) stays --bootstrap; the next sig-modules after a build self-heals.
MODULE_SIG_JOBS := $(foreach a,$(MODULE_BINARIES),$(a):$($(a)_EXE):$($(a)_VRAM_BASE):$($(a)_TEXT_LO))
sig-modules:
# PARALLEL (P31 S70) — same rationale as sig-overlays. Each job writes only its own
# .run/seeds.<alias>.txt and .run/sig.<alias>.jsonl, so the fan-out is safe.
@mkdir -p .run; : > .run/sig-modules.txt
echo "$(MODULE_SIG_JOBS)" | tr ' ' '\n' | sed '/^$$/d' | xargs -P$(JOBS) -I{} sh -c '\
job="{}"; alias=$${job%%:*}; rest=$${job#*:}; f=$${rest%%:*}; rest=$${rest#*:}; \
vram=$${rest%%:*}; tlo=$${rest#*:}; \
if [ ! -f "$$f" ]; then echo "[WARN no payload] $$alias ($$f)"; exit 0; fi; \
elf="build/$$alias/$$alias.elf"; \
if [ -f "$$elf" ]; then \
mipsel-linux-gnu-nm "$$elf" | awk -v lo=$$(($$tlo)) '"'"'$$2=="T" && $$3~"^func_" { a=strtonum("0x" $$1); if (a>=lo && a%4==0) printf "0x%X\n", a }'"'"' | sort -u > ".run/seeds.$$alias.txt"; \
$(VENV_PY) tools/sig_image.py --image "$$f" --vram-base "$$vram" --seeds ".run/seeds.$$alias.txt" --name "$$alias" $${tlo:+--text-lo "$$tlo"} >/dev/null 2>&1 \
&& echo "[ OK ] $$alias" || echo "[SIG FAIL] $$alias"; \
else \
$(VENV_PY) tools/sig_image.py --image "$$f" --vram-base "$$vram" --bootstrap --name "$$alias" $${tlo:+--text-lo "$$tlo"} >/dev/null 2>&1 \
&& echo "[ OK ] $$alias" || echo "[SIG FAIL] $$alias"; \
fi' | tee .run/sig-modules.txt
n=$$(grep -c "^\[ OK \]" .run/sig-modules.txt || true)
bad=$$(grep -c "^\[SIG FAIL\]" .run/sig-modules.txt || true)
echo "sig-modules: signed $$n modules (of $(words $(MODULE_BINARIES)) onboarded)"
if [ "$$bad" -ne 0 ]; then echo "[FAIL] sig-modules: $$bad module(s) failed to sign"; exit 1; fi
# print-<VAR> (P33 B5): echo one Makefile variable so tools derive per-binary facts from the ONE registry
# (R33) instead of re-parsing overlays.mk/modules.mk — e.g. `make -s print-EXE BINARY=ov_SC01_077`,
# `make -s print-VRAM_BASE BINARY=resident`, `make -s print-BINARIES`.
print-%:
@echo '$($*)'
# bootstrap (P33 B3): the fresh-clone setup — apt presence (printed, never run), the venv from
# requirements-python.txt, the submodules, the two cc1 tarballs verified + extracted, then check-env.
bootstrap:
@tools/bootstrap.sh
# -----------------------------------------------------------------------------
# check-env: assert every Phase-4 toolchain component. Runs ALL checks (does not
# stop at the first failure) so the report is complete, then exits nonzero if any
# hard check failed. binutils >= 2.38 is a WARN, never a FAIL (§4.5).
check-env:
# DELIBERATE opt-out from the global `-e` (.SHELLFLAGS, Phase-27 T2). This recipe's contract is
# "run EVERY preflight check, print EVERY [FAIL], exit with the accumulated status" — it manages
# its own `fail` and exits 1 at the end. Under `-e` a probe assignment (e.g. `pyver=$$(python3
# ...)` on a box without python3) would abort at the FIRST problem and hide the rest, turning a
# diagnostic into a stop-on-first-error. Accumulate-and-report is correct here; nowhere else.
@set +e
fail=0
echo "== BFM-decomp environment preflight (Phase 4 check-env) =="
echo
# 1) Python >= 3.12 (system python3 drives tooling + the EXE-hash import)
pyver=$$($(PYTHON) -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null)
if $(PYTHON) -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3,12) else 1)' 2>/dev/null; then
echo "[PASS] python3 $$pyver (>= 3.12)"
else
echo "[FAIL] python3 $${pyver:-not-found} (need >= 3.12)"; fail=1
fi
# 2) venv present + splat importable
if [ -x "$(VENV_PY)" ]; then
if $(VENV_PY) -c 'import splat' 2>/dev/null; then
sv=$$($(VENV_PY) -c 'import importlib.metadata as m; print(m.version("splat64"))' 2>/dev/null)
echo "[PASS] venv 'import splat' OK (splat64 $${sv:-?})"
else
echo "[FAIL] venv present but 'import splat' failed (run: $(VENV_PY) -m pip install 'splat64[mips]>=0.41.0,<1.0.0')"; fail=1
fi
else
echo "[FAIL] $(VENV_PY) missing (run: $(PYTHON) -m venv $(VENV) && $(VENV)/bin/pip install 'splat64[mips]>=0.41.0,<1.0.0')"; fail=1
fi
# 3) cc1 candidates executable — R12-clean smoke (stdin -> /dev/null, no temp file)
for cc1 in "$(CC1_PSX)" "$(CC1_CDK)"; do
if [ -x "$$cc1" ] && echo 'int _ce(){return 0;}' | "$$cc1" $(CC1_SMOKE_FLAGS) -o /dev/null 2>/dev/null; then
echo "[PASS] cc1 runs: $$cc1"
else
echo "[FAIL] cc1 not runnable: $$cc1 (see docs/SETUP.md §4.7)"; fail=1
fi
done
# 4) maspsx submodule populated
if [ -f "$(MASPSX)" ]; then
echo "[PASS] maspsx present: $(MASPSX)"
else
echo "[FAIL] $(MASPSX) missing (run: git submodule update --init)"; fail=1
fi
# 4b) the other three submodules (P33 B3) — matching tooling, not build inputs: WARN, not FAIL
for sub in tools/asm-differ tools/m2c tools/decomp-permuter; do
if [ -n "$$(ls -A "$$sub" 2>/dev/null)" ]; then
echo "[PASS] submodule populated: $$sub"
else
echo "[WARN] submodule empty: $$sub (matching tooling only; run: git submodule update --init)"
fi
done
# 4c) the four TRACKED splat preset headers (P33 B1) — assembled into every object
for h in include/include_asm.h include/macro.inc include/labels.inc include/gte_macros.inc; do
if [ -f "$$h" ]; then
echo "[PASS] preset header present: $$h"
else
echo "[FAIL] $$h missing (tracked since P33 B1; 'make extract' regenerates it)"; fail=1
fi
done
# 5) mipsel binutils on PATH (as / ld / objcopy)
for t in $(AS) $(LD) $(OBJCOPY); do
if command -v $$t >/dev/null 2>&1; then
echo "[PASS] $$t: $$($$t --version | head -1)"
else
echo "[FAIL] $$t not on PATH (apt install binutils-mipsel-linux-gnu)"; fail=1
fi
done
# 5b) binutils regression line: >= 2.38 -> WARN (not FAIL); §4.5
asver=$$($(AS) --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
if [ -n "$$asver" ]; then
amaj=$${asver%%.*}; amin=$${asver##*.}
if [ "$$amaj" -gt $(BINUTILS_WARN_MAJOR) ] || { [ "$$amaj" -eq $(BINUTILS_WARN_MAJOR) ] && [ "$$amin" -ge $(BINUTILS_WARN_MINOR) ]; }; then
echo "[WARN] mipsel binutils $$asver >= 2.38 — PS1-matching regression suspect (2.35 known-good); revisit in Phase 5 (docs/SETUP.md §4.5)"
else
echo "[PASS] mipsel binutils $$asver (< 2.38)"
fi
fi
# 6) the extracted EXE hash == EXPECTED_EXE_SHA1 (reused constant). P33 B1: the EXE is no longer
# committed — it is regenerated from the user's disc by `make disc-extract`, so its absence is a
# WARN with the instruction, not a FAIL; a PRESENT but wrong EXE is still a FAIL.
if [ -f "$(EXE)" ]; then
want=$$($(PYTHON) -c 'from tools.bfm_extract.extract_exe import EXPECTED_EXE_SHA1 as h; print(h)' 2>/dev/null)
got=$$(sha1sum "$(EXE)" | cut -d' ' -f1)
if [ -n "$$want" ] && [ "$$got" = "$$want" ]; then
echo "[PASS] $(EXE) sha1 $$got == EXPECTED_EXE_SHA1"
else
echo "[FAIL] $(EXE) sha1 $${got:-none} != expected $${want:-unknown}"; fail=1
fi
else
echo "[WARN] $(EXE) absent — run 'make disc-extract' with your redump dump in $(DISC_DIR)/ (docs/SETUP.md §4.4)"
fi
# 7) the committed manifest oracle is self-consistent (manifest.sha1 == sha1(manifest.jsonl)); ms.
if [ -f "$(EXTRACT_ROOT)/manifest.jsonl" ] && [ -f "$(EXTRACT_ROOT)/manifest.sha1" ]; then
mgot=$$(sha1sum "$(EXTRACT_ROOT)/manifest.jsonl" | cut -d' ' -f1)
mwant=$$(cat "$(EXTRACT_ROOT)/manifest.sha1")
if [ "$$mgot" = "$$mwant" ]; then
echo "[PASS] $(EXTRACT_ROOT)/manifest.sha1 == sha1(manifest.jsonl) ($$mgot)"
else
echo "[FAIL] $(EXTRACT_ROOT)/manifest.sha1 ($$mwant) != sha1(manifest.jsonl) ($$mgot) — the committed oracle is inconsistent"; fail=1
fi
else
echo "[FAIL] $(EXTRACT_ROOT)/manifest.jsonl + manifest.sha1 missing (the committed extraction oracle)"; fail=1
fi
if [ -f "$(DISC_TRACK1)" ]; then echo "[INFO] disc dump present: '$(DISC_TRACK1)'"; else echo "[INFO] no disc dump under $(DISC_DIR)/ (needed only to (re)generate extracted/)"; fi
# 8) the extracted payload census (P33 B3): how many of the fleet's inputs exist (INFO — disc-extract makes them)
present=0; for p in $(foreach b,$(BINARIES),$($(b)_EXE)); do [ -f "$$p" ] && present=$$((present+1)); done
echo "[INFO] extracted payloads present: $$present / $(words $(BINARIES)) binaries$$( [ "$$present" -eq $(words $(BINARIES)) ] || echo ' — run: make disc-extract')"
echo
if [ "$$fail" -ne 0 ]; then
echo "check-env: FAIL — see the [FAIL] lines above."
exit 1
fi
echo "check-env: OK — Phase-4 toolchain ready."
# -----------------------------------------------------------------------------
# Phase-5 build: splat split -> assemble -> link -> objcopy -> SHA1 check.
# The code is 100% assembly (the "all-asm byte-match" milestone). The cpp->cc1->
# maspsx->as path is documented below but dormant until Phase 6 adds `c` segments.
SPLAT := $(VENV_PY) -m splat
CPP := $(MIPS_PREFIX)cpp
# (SPLAT_YAML / OUT_DIR / OUT / ELF / MAPFILE / LD_SCRIPT / CHECK_SHA are per-binary
# aliases in the "Binaries" data block near the top of this file — Phase 9. UNDEF_SYMS /
# UNDEF_FUNCS / ASM_DIR / SRC_DIR joined them per-binary in Phase 10: a second binary
# writes its undefined_*_auto under build/<bin>/ and nests its sources under <bin>/.)
# P33 A3 — NO_SDK=1 builds main from splat's INCLUDE_ASM stub tiles even when the SDK object dirs
# exist: the WITHOUT leg of the with/without-SDK dual (roadmap contract §1.2 — the fresh-clone
# fallback invariant). Skips every psyq_integrate rewrite below AND the -T externals fragments, so
# the link is exactly what a public clone without Sony's objects performs. `make sdk-dual` runs both
# legs and asserts both SHA1s; until now the WITHOUT leg was only ever exercised by hand
# (`mv .run/obj40 .run/obj40.off`) and it regressed once unnoticed (config/symbols.us.txt:248).
NO_SDK ?=
# Phase 7 (Task 2'): link the real PsyQ libcd SDK objects in place of the libcd-region asm stubs.
# tools/psyq_integrate.py rewrites the splat .ld (swap stub objects -> build/psyq/libcd/*.o + NOLOAD
# data placement, no carving) and emits the externals defsym fragment. Conditional on the SDK ELF
# objects being present (gitignored, SDK-derived, via tools/psyq_build_libs.sh LIBCD); a fresh clone
# without them builds byte-identically via the stubs.
LIBCD_ELF := .run/obj40/libcd
LIBCD_OBJDIR := build/psyq/libcd
LIBCD_SYMS := build/psyq/libcd_externals.ld
# libgs (Phase 7 Task #9, FULL integration; S78 #3/#4): 34 libgs objects in 8 blocks linked in place
# of the libgs1..libgs8 block stubs (libgs7 = 2D_BG0/2D_BG1, S78 #3; libgs8 = GS_001 via the link-prepare
# .bss split, S78 #4; the 4 remaining gaps are libgte objects, libgte27-30). Same conditional/
# idempotent model as libcd. The curated object dir is SDK-derived (gitignored), regenerated by
# tools/make_libgs.sh (needs the LIBGS ELF from psyq_build_libs.sh LIBGS). GS_106 (block 4) anchors
# uniquely only within the libgs window, so the integrate call passes 0x8005080C 0x80057928.
LIBGS_ELF := .run/obj40/libgs_used
LIBGS_OBJDIR := build/psyq/libgs
LIBGS_SYMS := build/psyq/libgs_externals.ld
# libetc (Phase 8): 5 objects (VSYNC/INTR/INTR_VB/INTR_DMA/VMODE) in ONE contiguous block at the tail
# of the old 800 subseg (ends at libcd1). Single stub "libetc"; no placement window needed (all 5
# anchor uniquely over the full text window). Same conditional/idempotent model as libcd/libgs.
LIBETC_ELF := .run/obj40/libetc
LIBETC_OBJDIR := build/psyq/libetc
LIBETC_SYMS := build/psyq/libetc_externals.ld
# libgpu (Phase 8; S78 #4): EXT+PRIM (block `libgpu`) + SYS.o (block `libgpu2`, 3109 ins). SYS.o was
# EXCLUDED from Phase 8 to P31 S78 as scattered-.bss (cookbook §9.1, the GS_001 class) and lived in 800c
# as hand-matched SDK C + verbatim frags; psyq_integrate now splits such a .bss into per-base NOLOAD
# pieces at link-prepare (tools/psyq_bss_split.py, cookbook §489), so the raw psyq_build_libs.sh LIBGPU
# output links directly — no curated dir (psyq_identify drops the 8 objects the EXE does not link).
LIBGPU_ELF := .run/obj40/libgpu
LIBGPU_OBJDIR := build/psyq/libgpu
LIBGPU_SYMS := build/psyq/libgpu_externals.ld
# libmcrd (Phase 8): 2 objects (LIBMCRD.o = the 55 LIBMCRD_OBJ_* + _card_* memcard I/O; USERFUNC.o), 2
# non-adjacent blocks. Clean (.bss commons all recovered). NB: these are the libmcrd SDK objects; the
# GAME's SaveLoadRoutine/Q#5 save logic is a separate Phase-12 item.
LIBMCRD_ELF := .run/obj40/libmcrd
LIBMCRD_OBJDIR := build/psyq/libmcrd
LIBMCRD_SYMS := build/psyq/libmcrd_externals.ld
# libc2 (Phase 8): C stdlib, 17 objects, 2 blocks (16-obj main run libc2_1 + STRCAT.o libc2_2). Clean
# (PRNT.o's printf-format jtbl resolves via NOLOAD .rodata).
LIBC2_ELF := .run/obj40/libc2
LIBC2_OBJDIR := build/psyq/libc2
LIBC2_SYMS := build/psyq/libc2_externals.ld
# libgte (Phase 8): GTE math, 53 objects in 22 blocks across the 800b region; S78 +4 blocks (libgte23..26: the
# MSC/SMP_00/FGO/PATCHGTE objects the §485 psyq_identify fix located in the former 800b* 'game code' gaps) + libgte9 re-derived as SMP_05 (subseg lines generated by
# tools/gen_lib_subsegs.py). The integrate window 0x4787C..0x51804 restricts placement to 800b so it
# sees 22 blocks (excludes the 5 deferred libgs-gap objects MTX_05/07/11/REG03/REG11; gsgap1/2/4/5 stay
# stubs). Clean (no scattered .bss). stub list = libgte1..libgte22.
LIBGTE_ELF := .run/obj40/libgte
LIBGTE_OBJDIR := build/psyq/libgte
LIBGTE_SYMS := build/psyq/libgte_externals.ld
LIBGTE_STUBS := libgte1,libgte2,libgte3,libgte4,libgte5,libgte6,libgte7,libgte8,libgte9,libgte10,libgte11,libgte12,libgte13,libgte14,libgte15,libgte16,libgte17,libgte18,libgte19,libgte20,libgte21,libgte22,libgte23,libgte24,libgte25,libgte26,libgte27,libgte28,libgte29,libgte30
# Combined libspu+libsnd sound region (Phase 8; S78 #3/#4): the two SDK sound libs interleave in
# 0x3A444..0x4239C so they link as one 63-object region (snd1..snd12). Curated dir .run/obj40/snd_used
# built by tools/make_snd_used.py (3 addresses excluded as cross-object-common/false-positive stubs;
# VM_F rejoined in S78 #4 via the link-prepare .bss split, cookbook §489). Window arg below.
SND_ELF := .run/obj40/snd_used
SND_OBJDIR := build/psyq/snd
SND_SYMS := build/psyq/snd_externals.ld
SND_STUBS := snd1,snd2,snd3,snd4,snd5,snd6,snd7,snd8,snd9,snd10,snd11,snd12
# Combined libapi+libcard 800c2 region (Phase 8; S79 #5): 26 objects in 7 blocks (apicard1..7) tiling
# 0x80061F38..0x80062888 with no game code left between them. Curated dir .run/obj42/apicard_used
# (tools/make_apicard_used.py: libapi from tools/psyq/lib421 = 4.2, the EXE's real libapi; libcard 4.0).
# Window 0x61F38..0x62888. The former 800c2/800c2_2/800c2_3 "game code" rows were FIRST.o / PAD.o /
# PATCH.o+CHCLRPAD.o (libapi 4.2 C objects) — apicard5/6/7.
APICARD_ELF := .run/obj42/apicard_used
APICARD_OBJDIR := build/psyq/apicard
APICARD_SYMS := build/psyq/apicard_externals.ld
APICARD_STUBS := apicard1,apicard2,apicard3,apicard4,apicard5,apicard6,apicard7
# The libapi 4.2 + libpad 4.2.1 band (S78 #12 named it, S79 #13 found the archive, S79 #5 wired it):
# 0x8005CE18..0x8005FC68 = 33 interleaved Sony objects, all byte-identical from tools/psyq/lib421
# (SCE's 1998-02-26 "libpad.lib 4.2.1 for the DUAL SHOCK" patch + libapi.lib 4.2; ELF regenerated into
# .run/obj42/{libapi42,libpad421} by psyq_lib_split.py + psyq-obj-parser — see docs/SETUP.md). Two raw
# dirs, two calls, each windowed to its own objects and tiling its own stubs (libapi1/libapi2,
# libpad1/libpad2). This was the src/800c3.c "REORDER_TUS island" — 129 hand-matched "C", 62 verbatim
# bodies, 19 stubs incl. the four §332 "%lo-in-a-delay-slot walls": Sony code assembled in reorder mode.
LIBAPI42_ELF := .run/obj42/libapi42
LIBAPI42_OBJDIR := build/psyq/libapi42
LIBAPI42_SYMS := build/psyq/libapi42_externals.ld
LIBAPI42_STUBS := libapi1,libapi2
LIBPAD_ELF := .run/obj42/libpad421
LIBPAD_OBJDIR := build/psyq/libpad
LIBPAD_SYMS := build/psyq/libpad_externals.ld
LIBPAD_STUBS := libpad1,libpad2
# Assembler flags (docs/SETUP.md §6.2). -G0 is confirmed by the disassembly
# (ledger #8: zero $gp-relative addressing). -no-pad-sections keeps section ends
# un-padded so the link reproduces the original layout.
ASFLAGS := -Iinclude -march=r3000 -mtune=r3000 -no-pad-sections -O1 -G0
# maspsx ASPSX version — ALWAYS explicit (G8). Inert for the all-asm build; the
# real pin is Phase 6. (Only used on the future cpp->cc1->maspsx `c` path.)
ASPSX_VERSION := 2.56
# Extra maspsx flags. --expand-div is PINNED (Phase-6 fingerprint): the original
# emits the full aspsx div sequence (divu + bnez + break 0x7 zero-check); without it
# maspsx leaves a bare divu and div/rem functions never match. Only affects div/rem,
# so the all-INCLUDE_ASM build and div-free functions are unchanged.
MASPSX_FLAGS := --expand-div
# Phase-29 §8e: per-object jump-table pad spec (tools/jtbl_rodata_pads.py). Set ONLY as a
# target-specific var by tools/jtbl_carve.py in config/overlays.mk for multi-table .rodata
# carve spans; the file-scope empty default shields the recipe from an inherited environment
# variable accidentally arming the filter fleet-wide (a plain `JTBL_PADS=... make` would
# otherwise become a global make var). Unset => the compile pipeline is byte-unchanged.
JTBL_PADS :=
# Object set must match the splat linker script's references. After the Phase-6 asm->c
# flip the text subseg is src/800.c -> build/src/800.o; the per-function
# asm/nonmatchings/<seg>/*.s are TEXTUALLY .include'd by the .c (via INCLUDE_ASM) at
# assembly time, so they are NOT separate objects and must be excluded from the glob.
# header.s and the data subseg stay asm. Globbed at parse time -> run the canonical
# `make extract && make build`.
# Per-binary object scoping (Phase 10): main's sources live at the repo-level asm/ + src/;
# a second binary (resident) nests at asm/<bin>/ + src/<bin>/. The active binary's roots are
# $(ASM_DIR)/$(SRC_DIR). main's roots CONTAIN the nested siblings, so they must be pruned from
# main's glob (else resident's .s/.c contaminate main's OBJS and the link). The prune list is
# DERIVED FROM $(BINARIES) — the $(filter $(ASM_DIR)/%,...) guard prunes only a sibling whose
# root is genuinely nested under the active root, so it self-balances as binaries are added.
OTHER_BINS := $(filter-out $(BINARY),$(BINARIES))
ASM_PRUNE := $(foreach b,$(OTHER_BINS),$(if $(filter $(ASM_DIR)/%,$($(b)_ASM_DIR)),-not -path '$($(b)_ASM_DIR)/*'))
SRC_PRUNE := $(foreach b,$(OTHER_BINS),$(if $(filter $(SRC_DIR)/%,$($(b)_SRC_DIR)),-not -path '$($(b)_SRC_DIR)/*'))
ASM_SRCS := $(shell find $(ASM_DIR) -name '*.s' -not -path '$(ASM_DIR)/nonmatchings/*' $(ASM_PRUNE) 2>/dev/null)
# -not -name '.*': a tool's LIVE dotfile probe (masked_diff's src/.masked_diff_probe.<pid>.c, written and deleted
# within one process) must never enter the object list — it was present at parse time and gone at compile time,
# and gate_main's clean rebuild died on "No rule to make target build/src/.masked_diff_probe.N.o" (P32 S83).
C_SRCS := $(shell find $(SRC_DIR) -name '*.c' -not -name '.*' $(SRC_PRUNE) 2>/dev/null)
OBJS := $(ASM_SRCS:%.s=build/%.o) $(C_SRCS:%.c=build/%.o)
# Header-dependency tracking (Phase 15): now that shared headers (src/shared/*.h, common.h)
# are build inputs, the cpp stage emits a .d per C object (-MMD, below) so editing a #included
# header triggers a recompile — incremental `make check` stays trustworthy (R22). .d files live
# under build/ (gitignored); -include ignores them on the first build. No effect on output bytes.
C_DEPS := $(C_SRCS:%.c=build/%.d)
-include $(C_DEPS)
# splat `bin` subsegs (raw byte regions — e.g. an overlay's trailing non-word-aligned bytes that
# spimdisasm's data path drops, since it won't emit a <4-byte partial word). splat extracts them to
# assets/<alias>/*.bin and references build/assets/<alias>/*.o in the .ld; wrap each raw .bin into a
# linkable object (bytes verbatim in .data). Per-binary: asset_path is scoped to assets/<alias> so
# overlays' same-named `trailing.bin` never collide; main/resident have no assets -> empty.
ASSET_BINS := $(shell find assets/$(BINARY) -name '*.bin' 2>/dev/null)
ASSET_OBJS := $(ASSET_BINS:assets/%.bin=build/assets/%.o)
# -----------------------------------------------------------------------------
# disc-extract (P33 B1): the rom->decoder step. The repository ships NO ROM bytes (H1): every
# payload under extracted/ — the EXE, the .CD archives, the PAC entries, the LZSS-decoded 0.4.dec
# overlays and module blobs — is regenerated from the USER'S OWN redump dump by
# tools/bfm_extract/extract.py, and the result is compared against the COMMITTED oracle
# extracted/retail/manifest.jsonl (1,801 rows; manifest.sha1 is its hash). The oracle is never
# written by a build step (--expect-manifest compares; a plain `extract.py` run is how the oracle
# was made). Idempotent: a tree that already matches the oracle is a ~5 s hash probe and a no-op.
# The oracle lists the 3 .DA audio files from Tracks 2-4, so the canonical input is the 4-track
# BIN/CUE; a Track-1-only dump is accepted with an explicit PARTIAL verdict (never a silent pass).
DISC_DIR ?= disks
DISC_TRACK1 := $(DISC_DIR)/Brave Fencer Musashi (USA) (Track 1).bin
DISC_TRACK2 := $(DISC_DIR)/Brave Fencer Musashi (USA) (Track 2).bin
EXTRACT_ROOT := extracted/retail
disc-extract:
@set -e
audio=""
if [ ! -f "$(DISC_TRACK2)" ]; then audio="--allow-missing-audio"; fi
if $(PYTHON) tools/bfm_extract/extract.py --verify --out "$(EXTRACT_ROOT)" $$audio >/dev/null 2>&1; then
echo "disc-extract: up to date — $(EXTRACT_ROOT)/ matches the committed manifest (sha1 $$(cat $(EXTRACT_ROOT)/manifest.sha1))"
exit 0
fi
if [ ! -f "$(DISC_TRACK1)" ]; then
echo "[FAIL] disc-extract: no disc dump at '$(DISC_TRACK1)'"
echo " Stage your own redump dump of Brave Fencer Musashi (USA) — the 4-track BIN/CUE (Track 1 = data;"
echo " Tracks 2-4 = the .DA audio) — under $(DISC_DIR)/ (docs/SETUP.md §4.4). The repository ships no ROM bytes (H1)."
exit 2
fi
# 1) the dump is the canonical one: full-track SHA1 + CRC32 vs redump (refuses a non-canonical dump)
$(PYTHON) tools/bfm_extract/extract_exe.py --bin "$(DISC_TRACK1)" --verify-disc
if [ -n "$$audio" ]; then echo "disc-extract: NOTE — '$(DISC_TRACK2)' absent: the 3 .DA audio files are not extracted; the result is PARTIAL"; fi
# 2) extract everything and COMPARE against the committed oracle (writes nothing on a match)
$(PYTHON) tools/bfm_extract/extract.py --bin "$(DISC_TRACK1)" --out "$(EXTRACT_ROOT)" --expect-manifest "$(EXTRACT_ROOT)/manifest.jsonl" $$audio
# 3) the idempotency probe of the tree that was just written
$(PYTHON) tools/bfm_extract/extract.py --verify --out "$(EXTRACT_ROOT)" $$audio
echo "disc-extract: OK — $(EXTRACT_ROOT)/ regenerated from '$(DISC_TRACK1)' and verified against the committed manifest (sha1 $$(cat $(EXTRACT_ROOT)/manifest.sha1))"
# extract: splat split -> asm/, the linker script, include/ macros, undefined_*_auto.txt.
extract:
@mkdir -p $(OUT_DIR)
# P33 B1: the payload is the user's — regenerate extracted/ from the disc when it is absent (H1).
if [ ! -f "$(EXE)" ]; then $(MAKE) --no-print-directory disc-extract; fi
# A re-extract REWRITES every .s — and an object's assembly arrives through INCLUDE_ASM, which
# expands to a `.include` consumed by maspsx/as AFTER cpp. So `.o <- .s` is NOT a dependency make
# can see (-MMD tracks headers only), and an incremental build after an extract silently links
# STALE OBJECTS. That is not merely slow: INCLUDE_ASM pastes the ORIGINAL assembly, so a stale
# object still contributes the original bytes — the image stays byte-identical and SHA1 goes GREEN
# while the split that was just changed is never exercised. A broken config/ change can therefore
# be "verified" by an incremental build. (Found live in Phase 26-A: 8 of 136 binaries linked
# against stale objects; they failed loudly only by luck, because the dead symbol happened to be
# an undefined reference. A merely-different-but-valid split would have gone green on all 136.)
# R22/H3 already legislate this ("clean rebuild"; "make clean after any config/ change") — but a
# rule that depends on a human remembering is not a gate. Make it structural: invalidate here.
ifeq ($(BINARY),main)
# main's objects are TOP-LEVEL (build/src/*.o, build/asm/*.o); every other binary lives in its own
# subdir. -maxdepth 1 so `make extract` for main cannot delete an overlay's objects.
find build/src build/asm -maxdepth 1 -type f \( -name '*.o' -o -name '*.d' \) -delete 2>/dev/null || true
else
rm -rf build/src/$(BINARY) build/asm/$(BINARY)
endif
$(SPLAT) split $(SPLAT_YAML)
ifeq ($(BINARY),main)
# Phase 7 (LZSS): reorder splat's section-major .main into the real
# .data(front) -> .rodata -> .data(tail) sandwich, so the migrated LZSS
# jtbl_80072A38 (800.o .rodata) lands at 0x80072A38 between 531DC.data and
# 6324C.data. Idempotent; keyed off splat's exact output (re-run = no-op).
# EXE-only (overlays have no rodata island) — gated to BINARY=main; --front/--tail
# name the sandwich .data objects (cookbook §8).
# P31 S72: main's island is now a 7-PIECE sandwich (three .rodata carves, one per
# jtbl-span-owning code object), so --front/--tail can no longer express it. --order
# takes the address-ordered leaf list: a *.data.o leaf contributes its (.data), a code
# object leaf contributes its (.rodata) carve.
$(PYTHON) tools/ld_interleave.py --order 53198.data.o,800.o,63470.data.o,800_b.o,800_b_2.o,63940.data.o,800_c.o,63C4C.data.o $(LD_SCRIPT)
endif
# Phase-26 §8: overlays that carve a jr-function's jtbl into a dotted .rodata subseg run
# ld_interleave to place the migrated .rodata between the pre/post data-tail chunks (the
# data->rodata->data sandwich; cookbook §8). <bin>_JTBL_INTERLEAVE holds the --front/--tail
# object basenames (set per overlay in config/overlays.mk). Empty for overlays with no carve.
# NOTE (P31 S72): this --front/--tail form is the OVERLAY path only. main is driven by the
# --order call in the BINARY=main branch above — its island is 7 pieces, which --front/--tail
# cannot express (cookbook §426/§431).
ifneq ($(JTBL_INTERLEAVE),)
$(PYTHON) tools/ld_interleave.py --section .$(BINARY) $(JTBL_INTERLEAVE) $(LD_SCRIPT)
endif
# The linker script is an `extract` output, not produced by `build` — guard with a
# friendly message instead of make's raw "No rule to make target".
$(LD_SCRIPT):
@echo "make: $(LD_SCRIPT) missing — run 'make extract' first."; exit 1
# Assemble one splat .s (all-asm path).
build/asm/%.o: asm/%.s
@mkdir -p $(dir $@)
@echo " AS $@"
@$(AS) $(ASFLAGS) -o $@ $<
# Wrap a splat `bin` asset (raw bytes) into a linkable object: assemble a one-line stub that
# .incbin's the raw file into .data (format-safe — same mipsel-as as everything else, no objcopy
# -I binary arch guessing). The .ld pulls it by path. The --set-section-alignment forces .data to
# 1-byte align (as defaults it to 16) so ld places the 1-3 trailing bytes at the exact word-floor
# offset and does NOT pad the image up to a 16/8-byte boundary (that added a stray byte otherwise).
build/assets/%.o: assets/%.bin
@mkdir -p $(dir $@)
@echo " INCBIN $@"
@printf '.section .data\n.incbin "%s"\n' "$<" | $(AS) $(ASFLAGS) -o $@
@$(OBJCOPY) --set-section-alignment .data=1 $@
# C path (Phase 6): modern cpp -> vintage cc1 -> maspsx -> modern as. Each src/*.c is
# splat-generated INCLUDE_ASM stubs (file-scope __asm__ .include of the per-function
# asm/nonmatchings/<seg>/<fn>.s); as we match, stubs are replaced by real C. The flags
# below are the docs/SETUP.md §5.4 FIRST-CANDIDATE set — provisional until the Phase-6
# fingerprint ladder PINS the triple (then this block + ASPSX_VERSION are updated, G8).
CPPFLAGS := -lang-c -Iinclude -undef -Wall -fno-builtin -Dmips -D__GNUC__=2 -D__OPTIMIZE__ -Dpsx -D_PSYQ -D_MIPSEL -D_LANGUAGE_C
CC1FLAGS := -quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# The optional jtbl_rodata_pads stage (Phase-29 §8e) is inserted only when the object has a
# JTBL_PADS target-specific var (written by tools/jtbl_carve.py for multi-table .rodata carve
# spans): it replaces cc1's per-table `.align 3` with the ORIGINAL's exact pad bytes, so a merged
# span reproduces the original packing regardless of section-start parity. Unset => stage absent,
# pipeline byte-identical to pre-§8e.
# MODULES (P31 S62 T3a): every md_* object runs the filter in --derive mode instead — the pads are
# derived at build time from the retail island + the maspsx stream (cookbook §303), so no spec is
# stored and nothing can drift. A stored JTBL_PADS still wins if one is set.
# §332b — THE REORDER ISLAND. `800c2` / `800c3` were originally assembled in REORDER mode (the
# assembler filled the delay slots). maspsx force-emits `.set noreorder`, which makes that
# unreachable and made a whole class there look like a permanent compiler wall (§332). For these
# TUs only, swap maspsx for tools/reorder_passthrough.py + `as -O2` — the pipeline
# tools/oracle_reorder.py proved byte-exact (0 diffs on func_80061FA8 vs 57 on the pinned path).
# The whole-binary SHA1 gate is the arbiter: if this were wrong the build simply fails.
# The §332b -O2 reorder island. THE STEMS MUST BE LISTED INDIVIDUALLY: `$(filter $*,...)` is an
# exact match, so `800c2` does NOT cover `800c2_2`/`800c2_3` — those TUs were assembled through
# maspsx while their siblings went through reorder_passthrough, which is why func_80062388's
# `lui at / jr ra / sw a0,lo(at)` read as COMPILER-INEXPRESSIBLE (P31 S75): a probe showed cc1 +
# reorder_passthrough + `as -O2` emits exactly that sequence. It was a build-config gap, not a
# gcc limit (cookbook §452 corrected).
# S79 #5: the island is EMPTY — all four TUs (800c2, 800c2_2, 800c2_3, 800c3) were libapi 4.2 / libpad 4.2.1
# objects and are LINKED now (apicard5-7, libapi1/2, libpad1/2). The mechanism stays for any future
# reorder-assembled TU (cookbook §332b); match_one/rtu_match read this list and tolerate it empty.
REORDER_TUS :=
ASFLAGS_REORDER := -Iinclude -march=r3000 -mtune=r3000 -no-pad-sections -O2 -G0
build/src/%.o: src/%.c
@mkdir -p $(dir $@)
@echo " CC $@"
@set -o pipefail; $(CPP) $(CPPFLAGS) -MMD -MP -MT $@ -MF $(@:.o=.d) $< | $(CC1_PSX) $(CC1FLAGS) | $(if $(filter $*,$(REORDER_TUS)),$(VENV_PY) tools/reorder_passthrough.py | $(AS) $(ASFLAGS_REORDER) -o $@,$(VENV_PY) $(MASPSX) --aspsx-version=$(ASPSX_VERSION) $(MASPSX_FLAGS) $(if $(JTBL_PADS),| $(VENV_PY) tools/jtbl_rodata_pads.py --pads $(JTBL_PADS),$(if $(filter md_% main,$(BINARY)),| $(VENV_PY) tools/jtbl_rodata_pads.py --derive $(BINARY) --tu $(notdir $*))) | $(AS) $(ASFLAGS) -o $@)
# Per-module optimization override (SETUP §5.5 — per-module compiler mixing). The boot/
# main/game-mode-dispatch module (src/boot.c, vram 0x80010000-0x800123F0) was compiled at
# -O0, NOT the -O2 game-code default: frame-pointer setup + unfolded large-offset loads
# are the evidence (GameModeDispatch byte-matches only at -O0). gcc 2.7.2 has no
# per-function optimize pragma, so opt level is per-file. Target-specific CC1FLAGS (the
# pattern recipe reads $(CC1FLAGS), so this overrides it for just build/src/boot.o):
build/src/boot.o: CC1FLAGS := -quiet -O0 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# Phase-19 T1: same per-file -O0 mechanism for the ov_SC01_077 -O0 cluster (16 contiguous fns
# vram 0x8013B568..0x8013C98C, prologue sig 21F0A003). Split into its own .c by the splat config
# (config/splat.ov_SC01_077.yaml) so this override reaches just that .o.
build/src/ov_SC01_077/ov_SC01_077_o0.o: CC1FLAGS := -quiet -O0 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# Phase-24: the whale func_80144B9C is a 2nd -O0 region (0x80144B9C..0x801457A4) present in EVERY
# overlay (reach-134), carved into its own object <ov>_o0b by each overlay's splat config; the
# struct-assign memcpy matches only at -O0. One wildcard rule -O0-compiles all overlays' _o0b.o
# (the ×134 rollout; tools/rollout_whale_o0.py — one-shot, retired S45). All share src/shared/func_80144B9C.h.
#
# P30 T2: the glob is `_o0?` (was `_o0b`) so ANY lettered -O0 sub-split is covered by this one rule.
# A 4th -O0 region was found inside an -O2 jr split (0x80183CF0..0x80184920, 15 contiguous fns in
# ov_SC03_014 + ov_SC03_015) — the "carve within a carve": the containing object is sub-split into
# pre/-O0/post and the middle region named <ov>_o0c. Without the widened glob each new region would
# need its own hand-added rule, and a MISSED rule is silent: the region compiles at -O2 and every
# residual it produces is a pure artifact (§116 — opt level is a property of the FILE).
# corpus.o0_sources() parses this rule and resolves `?` via glob, so the -O0 oracle stays correct.
# P31 S68: glob widened to src/md_*/ too — module binaries now get -O0 sub-splits
# (md_MAIN_003_o0c, the 9-stub o0 carve, is the first). Without this a `src/md_*` region
# file silently compiled -O2: byte-neutral while stub-only (INCLUDE_ASM is verbatim asm),
# but every -O0 draft banked into it would mystery-fail the gate (§362's trap class).
# corpus.o0_sources() splits multi-glob $(wildcard ...) specs, so the -O0 oracle follows.
# P31 S77: the glob now covers TOP-LEVEL src/*_o0?.c too — main's TUs are top-level files, not
# src/main/*, so every -O0 island in the EXE was outside this rule. Measured: func_8002C410
# MATCHES 299/299 at -O0 and DIFFs 228-vs-299 at -O2, and could not bank for want of this one
# glob. Same main-blindness family as draw_waves drawing zero main functions (S76).
WHALE_O0B_OBJS := $(patsubst src/%.c,build/src/%.o,$(wildcard src/ov_*/ov_*_o0?.c src/md_*/md_*_o0?.c src/*_o0?.c))
$(WHALE_O0B_OBJS): CC1FLAGS := -quiet -O0 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# Phase-29 T2 Arm A: the -O0 cluster (0x8013B568..0x8013C98C) carved per single-file overlay into
# <ov>_o0.o (tools/rollout_o0_cluster.py — one-shot, retired S45; the generic driver is tools/rollout_o0.py) — same per-file -O0 mechanism so its h_seq family members
# bank whole-binary (the Task-1 swing verdict: they masked-MATCH only at -O0). One wildcard rule
# -O0-compiles every overlay's _o0.o; ov_SC01_077_o0.o already has its explicit rule above (filtered
# out to avoid a duplicate target-specific assignment). `*_o0.c` never matches the whale's `*_o0b.c`.
O0_CLUSTER_OBJS := $(patsubst src/%.c,build/src/%.o,$(filter-out src/ov_SC01_077/ov_SC01_077_o0.c,$(wildcard src/ov_*/ov_*_o0.c)))
$(O0_CLUSTER_OBJS): CC1FLAGS := -quiet -O0 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# md_MAIN_011 IS AN ENTIRELY -O0 MODULE (P31 S59, census in .run/s59_o0/). All 21 functions in its
# single code subseg carry the -O0 frame-pointer prologue (`sw $fp` + `addu $fp,$sp,$zero`), the
# .c is stub-only, and no -O0 glob matches `src/md_*/` at all — so its functions were unbankable no
# matter how good a draft was, and the wave draw now refuses to draw them (build_wave_atlas's
# `o0-in-an-O2-object` skip). Whole-object override, the `boot` precedent (§6): no splat change, no
# carve, and therefore none of the §18-P29 re-disassembly risk. Byte-neutral while the file is
# stub-only — proven by gating md_MAIN_011 when this landed. `corpus.o0_sources()` parses this rule,
# so every -O0-aware tool picks the object up without a name convention.
build/src/md_MAIN_011/md_MAIN_011.o: CC1FLAGS := -quiet -O0 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker
# link (the .ld pulls in the .o by path) + objcopy to the raw PS-X EXE image.
$(OUT): $(OBJS) $(ASSET_OBJS) $(LD_SCRIPT)
@set -e
mkdir -p $(dir $@)
# PsyQ SDK library integrations are EXE-only (libgs/libgte/sound/apicard are
# SLUS_007.26's layout). A second binary (BINARY != main) skips this block and links
# its own stubs. NB: ifeq/endif are make directives (column 0, no tab), resolved at
# parse time; with .ONESHELL the included recipe lines still run as one shell.
ifeq ($(BINARY),main)
ifeq ($(NO_SDK),)
# Wire in the real libcd objects (after the build objects exist — the externals discovery
# trial-links the whole image). Idempotent: re-running re-derives the externals only.
if [ -d "$(LIBCD_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBCD_ELF) $(LD_SCRIPT) $(LIBCD_OBJDIR) $(LIBCD_SYMS) libcd1,libcd2
else
echo " (no $(LIBCD_ELF) — libcd region stays asm stubs; run tools/psyq_build_libs.sh LIBCD)"
fi
if [ -d "$(LIBGS_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBGS_ELF) $(LD_SCRIPT) $(LIBGS_OBJDIR) $(LIBGS_SYMS) libgs1,libgs2,libgs3,libgs4,libgs5,libgs6,libgs7,libgs8 0x8005080C 0x80057928
else
echo " (no $(LIBGS_ELF) — libgs region stays asm stubs; run tools/make_libgs.sh)"
fi
if [ -d "$(LIBETC_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBETC_ELF) $(LD_SCRIPT) $(LIBETC_OBJDIR) $(LIBETC_SYMS) libetc
else
echo " (no $(LIBETC_ELF) — libetc region stays asm stubs; run tools/psyq_build_libs.sh LIBETC)"
fi
if [ -d "$(LIBGPU_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBGPU_ELF) $(LD_SCRIPT) $(LIBGPU_OBJDIR) $(LIBGPU_SYMS) libgpu,libgpu2
else
echo " (no $(LIBGPU_ELF) — libgpu region stays asm stubs; run tools/psyq_build_libs.sh LIBGPU)"
fi
if [ -d "$(LIBMCRD_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBMCRD_ELF) $(LD_SCRIPT) $(LIBMCRD_OBJDIR) $(LIBMCRD_SYMS) libmcrd1,libmcrd2
else
echo " (no $(LIBMCRD_ELF) — libmcrd region stays asm stubs; run tools/psyq_build_libs.sh LIBMCRD)"
fi
if [ -d "$(LIBC2_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBC2_ELF) $(LD_SCRIPT) $(LIBC2_OBJDIR) $(LIBC2_SYMS) libc2_1,libc2_2
else
echo " (no $(LIBC2_ELF) — libc2 region stays asm stubs; run tools/psyq_build_libs.sh LIBC2)"
fi
if [ -d "$(LIBGTE_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBGTE_ELF) $(LD_SCRIPT) $(LIBGTE_OBJDIR) $(LIBGTE_SYMS) $(LIBGTE_STUBS) 0x8004787C 0x80053AF8
else
echo " (no $(LIBGTE_ELF) — libgte region stays asm stubs; run tools/psyq_build_libs.sh LIBGTE)"
fi
if [ -d "$(SND_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(SND_ELF) $(LD_SCRIPT) $(SND_OBJDIR) $(SND_SYMS) $(SND_STUBS) 0x8003A444 0x8004239C
else
echo " (no $(SND_ELF) — sound region stays asm stubs; run tools/psyq_build_libs.sh LIBSPU LIBSND + tools/make_snd_used.py)"
fi
if [ -d "$(APICARD_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(APICARD_ELF) $(LD_SCRIPT) $(APICARD_OBJDIR) $(APICARD_SYMS) $(APICARD_STUBS) 0x80061F38 0x80062888
else
echo " (no $(APICARD_ELF) — apicard region stays asm stubs; run tools/psyq_build_libs.sh LIBCARD + the lib421 ELF step in docs/SETUP.md + tools/make_apicard_used.py)"
fi
if [ -d "$(LIBAPI42_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBAPI42_ELF) $(LD_SCRIPT) $(LIBAPI42_OBJDIR) $(LIBAPI42_SYMS) $(LIBAPI42_STUBS) 0x8005CE18 0x8005E188
else
echo " (no $(LIBAPI42_ELF) — libapi band blocks stay asm stubs; convert tools/psyq/lib421/LIBAPI.LIB per docs/SETUP.md)"
fi
if [ -d "$(LIBPAD_ELF)" ]; then
$(PYTHON) tools/psyq_integrate.py --vram-base $(main_VRAM_BASE) --exe $(main_EXE) --symbols $(main_SYMBOLS) --yaml $(main_SPLAT_YAML) $(LIBPAD_ELF) $(LD_SCRIPT) $(LIBPAD_OBJDIR) $(LIBPAD_SYMS) $(LIBPAD_STUBS) 0x8005D0D8 0x8005FC68
else
echo " (no $(LIBPAD_ELF) — libpad band blocks stay asm stubs; convert tools/psyq/lib421/LIBPAD.LIB per docs/SETUP.md)"
fi
else
echo " NO_SDK=1: the PsyQ object integrations are SKIPPED — main links its INCLUDE_ASM stub tiles (the fresh-clone leg)"
endif
endif
# The externals fragments belong to the SDK-object link only: under NO_SDK the stale files left by a
# previous WITH build must not be picked up (they would defsym names the stub tiles already carry).
SYMS=""
if [ -z "$(NO_SDK)" ]; then
[ -f "$(LIBCD_SYMS)" ] && SYMS="-T $(LIBCD_SYMS)"; [ -f "$(LIBGS_SYMS)" ] && SYMS="$$SYMS -T $(LIBGS_SYMS)"; [ -f "$(LIBETC_SYMS)" ] && SYMS="$$SYMS -T $(LIBETC_SYMS)"; [ -f "$(LIBGPU_SYMS)" ] && SYMS="$$SYMS -T $(LIBGPU_SYMS)"; [ -f "$(LIBMCRD_SYMS)" ] && SYMS="$$SYMS -T $(LIBMCRD_SYMS)"; [ -f "$(LIBC2_SYMS)" ] && SYMS="$$SYMS -T $(LIBC2_SYMS)"; [ -f "$(LIBGTE_SYMS)" ] && SYMS="$$SYMS -T $(LIBGTE_SYMS)"; [ -f "$(SND_SYMS)" ] && SYMS="$$SYMS -T $(SND_SYMS)"; [ -f "$(APICARD_SYMS)" ] && SYMS="$$SYMS -T $(APICARD_SYMS)"; [ -f "$(LIBAPI42_SYMS)" ] && SYMS="$$SYMS -T $(LIBAPI42_SYMS)"; [ -f "$(LIBPAD_SYMS)" ] && SYMS="$$SYMS -T $(LIBPAD_SYMS)"
true
fi
echo " LD $(ELF)"
$(LD) -T $(LD_SCRIPT) -T $(UNDEF_SYMS) -T $(UNDEF_FUNCS) $$SYMS --no-check-sections -Map $(MAPFILE) -o $(ELF)
echo " OBJCOPY $@"
$(OBJCOPY) -O binary $(ELF) $@
# Trim the linker's end-of-segment 4-align pad: splat's .ld does `. = ALIGN(., 4)` after the
# data section, over-emitting up to 3 zero bytes when the payload size isn't 4-aligned (most
# overlays; the EXE + resident are 4-aligned so this never fires for them). Shrink-ONLY and
# capped at 3 bytes -> it can never hide a real shortfall (build < target fails the SHA) nor
# extend the image. The target's true byte length is the matched payload $(EXE).
tsz=$$(stat -c%s "$(EXE)"); osz=$$(stat -c%s "$@"); d=$$((osz - tsz))
if [ "$$d" -gt 0 ] && [ "$$d" -le 3 ]; then truncate -s "$$tsz" "$@"; echo " TRIM $@ (-$$d B linker end-align pad)"; fi
# build = produce $(OUT) and verify its SHA1 (check pulls in $(OUT)).
build: check
# extract-all / build-all / check-all (Phase 13; PARALLELIZED Phase 26): build + SHA1-check EVERY
# binary in $(BINARIES) -> a single fleet PASS/FAIL. Recursion ($(MAKE) BINARY={}) RE-PARSES the
# Makefile per binary so each gets its correctly-pruned OBJS (a `foreach` can't — the OBJS glob is
# parse-time, keyed on $(BINARY)). PARALLEL across binaries via `xargs -P$(JOBS)`: every binary's
# outputs are per-binary-disjoint (asm/<bin>, build/<bin>, build/{src,asm}/<bin>) and include/ is
# READ-ONLY during a build, so concurrent builds never race. The one shared WRITE is the 4 generated
# include/*.inc macros at EXTRACT time (identical content per binary) -> `extract-all` seeds them once
# via main (serial) before fanning out. Proven 136/136 byte-identical, ~10x faster (Phase 26: the
# serial R22 ~9m -> the parallel R22 ~1m). CLEAN fleet proof (R22 — clean rebuild):
# make clean && make extract-all && make check-all
JOBS ?= 16 # parallel binary builds/extracts (override: `make check-all JOBS=32`)
# extract-all: splat-split every binary. Seed `main` FIRST (serial) so the shared include/*.inc macros
# (+ build/psyq) exist before the parallel fan-out; then extract the rest in parallel.
extract-all:
@mkdir -p .run; : > .run/extract-all.txt
# P33 B1: the payloads come from the user's disc — regenerate/verify extracted/ ONCE, serially,
# before anything reads it (a ~5 s no-op when the tree already matches the committed manifest).
$(MAKE) --no-print-directory disc-extract
# Under the global `-e` a failing main extract now aborts here. It previously did NOT: its status
# was swallowed by .ONESHELL, and the closing `! grep -q` then passed regardless — a seed failure
# could sail through as green.
$(MAKE) --no-print-directory extract BINARY=main
echo "$(filter-out main,$(BINARIES))" | tr ' ' '\n' | xargs -P$(JOBS) -I{} sh -c \
'$(MAKE) --no-print-directory extract BINARY={} >.run/extract.{}.log 2>&1 && echo "[ OK ] {}" || echo "[EXTRACT FAIL] {}"' \
| tee .run/extract-all.txt
pass=$$(grep -c "^\[ OK \]" .run/extract-all.txt || true)
fail=$$(grep -c "^\[EXTRACT FAIL\]" .run/extract-all.txt || true)
want=$$(( $(words $(BINARIES)) - 1 ))
echo "extract-all: $$pass extracted, $$fail failed of $$want (+ main, serial)"
# Assert COVERAGE (pass == N-1), not the absence of a marker (R32) — `! grep -q "EXTRACT FAIL"`
# was a vacuous pass on an empty pipeline.
if [ "$$pass" -ne "$$want" ]; then
echo "[FAIL] extract-all: expected $$want extracted, got $$pass (failed=$$fail)"; exit 1
fi
check-all:
@mkdir -p .run; : > .run/check-all.txt
echo "$(BINARIES)" | tr ' ' '\n' | xargs -P$(JOBS) -I{} sh -c \
'$(MAKE) --no-print-directory check BINARY={} >.run/check.{}.log 2>&1 && echo "[ OK ] {}" || { echo "[FAIL] {}"; tail -3 .run/check.{}.log >&2; }' \
| tee .run/check-all.txt
# `|| true`: grep -c EXITS 1 when the count is 0, and under `-e` a failing command substitution
# aborts the assignment — so the bare form would make check-all FAIL exactly when nothing failed.
pass=$$(grep -c "^\[ OK \]" .run/check-all.txt || true)
fail=$$(grep -c "^\[FAIL\]" .run/check-all.txt || true)
want=$(words $(BINARIES))
echo "check-all: $$pass passed, $$fail failed of $$want"
# Assert COVERAGE (pass == N), not merely the absence of a failure marker (R32). `fail -eq 0`
# was a VACUOUS PASS: if the xargs pipeline emitted nothing at all, pass=0 fail=0 -> [ 0 -eq 0 ]
# -> green while checking NOTHING. The byte-gate is a correctness oracle with a null coverage
# dimension; this line is the coverage half.
if [ "$$pass" -ne "$$want" ]; then
echo "[FAIL] check-all: expected $$want passing, got $$pass (failed=$$fail)"; exit 1
fi
build-all: check-all
# check: SHA1 of the build vs the committed original hash. The definition of "build OK".
check: $(OUT)
@got=$$(sha1sum $(OUT) | cut -d' ' -f1)
want=$$(cut -d' ' -f1 $(CHECK_SHA) 2>/dev/null)
if [ -z "$$want" ]; then echo "[FAIL] $(CHECK_SHA) missing or empty"; exit 1; fi
if [ "$$got" = "$$want" ]; then
echo "[ OK ] $(OUT)"
echo " sha1 $$got == $(CHECK_SHA) (BYTE-IDENTICAL)"
else
echo "[FAIL] $(OUT)"
echo " got $$got"
echo " want $$want"
exit 1
fi
# expected: snapshot a SHA1-verified build into expected/build/ as the asm-differ
# baseline for Phase 6 (asm-differ diffs build/<obj> vs expected/build/<obj>).
expected: build
@set -e
mkdir -p expected/build
# Per-binary-safe (Phase 10): refresh ONLY the active binary's image dir, then merge-copy
# build/ into the shared expected/ mirror. cp MERGES (never deletes), so `make expected
# BINARY=X` preserves binary Y's baseline even when Y isn't currently in build/ (the old
# `rm -rf expected/build` wiped every sibling). asm-differ reads expected/$(OUT) (image mode)
# + expected/build/<obj> (object mode); both resolve under the merged mirror.
rm -rf expected/$(OUT_DIR)
cp -r build/. expected/build/
echo "expected: baseline refreshed for binary=$(BINARY) -> expected/build/ (siblings preserved)"
# clean: remove ALL regenerable outputs (build/ + the splat tree) so a config change
# is followed by a stale-free `make clean && make extract && make build` (H3).
clean:
@# `clean` is FLEET-WIDE and takes no BINARY scope (cookbook §445). extract/build/check all
@# honour BINARY=, so `make clean BINARY=<x>` reads as scoped and is not: it deletes asm/ for
@# all 213 binaries, and the failure then surfaces somewhere else entirely — `corpus refused:
@# N stub(s) have NO .s on disk`, or `<bin>.ld missing`, or a concurrent agent's gate refusing
@# for a reason that is a fact about your shell. Say so rather than silently ignoring the
@# variable (R43). Recovery is `make extract-all`, NOT `make extract BINARY=<x>`.
@if [ -n "$(filter-out main,$(BINARY))" ] || [ "$(origin BINARY)" = "command line" ]; then \
echo "clean: NOTE — BINARY=$(BINARY) is IGNORED here; clean is fleet-wide (cookbook §445)."; \
echo "clean: this removes asm/ for ALL binaries. Recover with 'make extract-all'."; \
fi
@rm -rf build expected asm assets undefined_syms_auto.txt undefined_funcs_auto.txt
@# P33 B1: the four splat preset headers (include/include_asm.h, macro.inc, labels.inc, gte_macros.inc)
@# are TRACKED now — generic splat presets, identical for every binary and ROM-free, so a compile-only
@# CI can assemble without a disc. `make extract` rewrites them only if splat's presets change (a
@# visible diff), so clean no longer deletes them.
@echo "clean: removed build/, expected/, and the regenerated splat tree (asm/, assets/, undefined_*_auto.txt)."