Files
BFM-decomp/cookbook/C0167.md
T

18 KiB

§156 — THE PREFERENCE-DONOR MERGE: cross-region variable reuse is what fills a0-a3, and a call-arg use in ONE region steers the fill in ALL of them (P30 S46 tier-3, func_80186E24, 611 ins: 236-off "S11 regalloc-order" → MATCH, zero new pins)

Symptom: a multi-loop function where every loop's caller-saved map is permuted the same way — target consistently {a0: index scalar, a1: dst ptr, a2: src ptr, a3: char/bound}, yours fills v1/a0/a1 "correctly" by density first-fit. Per-loop locals can NEVER reproduce it: the copy-loop pointers' density (loop-weighted refs / tiny range, e.g. 13/12) beats the index var's (10/32) in allocno_compare, so your dst/src allocate first and sit in v1/a0. No conflict, no decl order, and no S11 verdict fixes an ORDER gap that size.

The two coupled mechanisms (read off -da greg/lreg + real-2.7.2 global.c):

  1. MERGE pole, function-wide (extends RC-14 beyond one block): ONE char *d; u8 *s; reused as every phase's dst/src (incl. secondary pointers: the ph6 ent/d2, the digit-render write pointer) sums their loop-weighted refs into 100+ → the merged allocnos allocate FIRST and first-fit lands them in a1/a2 for the WHOLE function (v0/v1 blocked by block-temp conflicts). One edit moved 113 → 26 mismatches. Same move for scalars: one s16 scratch serving {phase-A cnt, phase-B fl, phase-B cnt, filter n} — union range crosses a call somewhere → K4 → $s0 everywhere. Diagnostic tells: (a) the same caller-saved reg hosting the same ROLE in every loop; (b) a callee-saved reg hosting values that individually never cross a call — both mean ONE reused source variable, THE 90s-dev frugality signature. Merge macro locals too (_mask/_k both in a0, disjoint segments = one variable passed for both macro params).
  2. The preference DONOR (new; extends RC-10): the merged index var b is a call arg in ONE region (cnt = f(b + 0x62) → set_preference through (set a0 (plus b 0x62)) gives the ALLOCNO an a0 hard-reg preference). In find_reg pass 0, every LOWER-priority-loser's preferred reg is skipped by conflicting allocnos (regs_someone_prefers, global.c:952) — so in the OTHER region, higher-density char/pct SKIP a0 and take a3/a1, leaving a0 for b even though it allocates near-last. A per-loop u8 id has no call use → no pref → char grabs a0 → the whole permutation. The preference travels with the allocno across regions; reuse is what carries it. Corollary: an unwanted pref-override (a copy landing in a0 you can't explain) is often inherited via expand_preferences' death-merge (A dies in insn setting B, no conflict → prefs IOR both ways: our sub inherited raw's a0-pref through raw = q - sub); blocking it needs a conflicting a0 OWNER at override time, which the donor merge provides for free.

Also byte-settled on the way (each is a one-line lever):

  • x = 1; if (c() == 0) x = g(); can NEVER put li $s2,1 in the bnez slot (multi-set ⇒ no S2 boost; dbr's backward scan stops at the call). The if (ret != 0) x = 1; else x = g(); spelling gives the D3 own-thread MOVE steal + relax inversion = the bnez; li shape.
  • A second pseudo copy (sub = pct) survives cse ONLY placed in the LOAD's bb before the branch: cse's follow-jumps extends the ebb along the TAKEN edge (label-used-once), so a use two fallthrough-branches down is in a fresh table; combine can't reach across bbs; K8 global never coalesces. In an arm (same bb as use) every spelling dies. dbr then backward-fills the copy into the branch slot — the beqz; addu $v1,$a1,$0 idiom.
  • Guard shape i = 0; if (n != 0) do {...} while (i < n); = beqz + i=0 in the slot + signed slt bottom. A rotated for emits blez; i = 0 INSIDE the if leaves the slot as nop (the eager steal fails mark_target_live_regs' conservative liveness).
  • for (k = 0; k < n; k++, q >>= 4) — comma-increment order is LUID order: addiu/addu(k) before andi 0xFFFF, srl in the loop slot.
  • RC-12 $0-add (id = b + zr) reconfirmed for a cross-call s32 copy the target keeps as addu $s1,$a0,$zero (plain copy: cse kills; u8 source: andi).

Route: map the target's per-loop caller-saved ROLES first; if the same reg repeats a role across loops, REUSE one variable before touching pins or densities — the §17 pin is the fallback, not the opener. Pins tried here (sub→$3, mask→$4) each half-worked and leaked new prefs (set_preference sees a pinned var as hard → its expression partners inherit prefs); the reuse form needed zero pins.

§159 — THE DECLARATION AXIS: conform to byte-truth, and make every guard state its COVERAGE (P30 S47; ~10,930 sites across 8 axes, fleet byte-identical)

The class is bigger than "a few symbol conflicts". dedup_extend's 129 failures over three binaries split 106 conflicting types / 21 CC1-FAIL / 4 undefined-reference / 3 DIFF. Real byte divergence is 2%; everything else is declaration plumbing. memcpy — the symbol the checkpoint named — is 17 of 106. The tail that actually dominates is ordinary deduped callees: func_80128ED8 (19), func_8012F14C (14), ApplyMatrixSV (12).

Direction: the HEADER is usually the liar, not the target. For func_80128ED8 the byte-true definition is s32 (s32, s32*) — exactly what the overlay .c files declare — while engine_core.h's macro-local extern says (void*, void*). §58b already says the draft's signature is byte-truth; the corollary is that a macro-local extern is just another stub-era guess and carries no more authority than a TU's.

A deduped function has NO definition in any .c — its body lives inside a #define DEFINE_func_X() \ macro, where backslash continuations and indentation defeat every definition parser. conform_decls therefore refused (correctly) the entire largest class it was built for. tools/macro_draft.py bridges it: emit the macro body verbatim, dedented, and the existing conformer reads the signature it already knows how to read.

§85's all-or-nothing is LITERAL. Conforming the 10 header sites alone broke ov_SC01_000: that binary carries the old spelling in its own file (_jr_80140608.c:2422). Header and fleet move together or not at all.

Three guards that asserted completeness over a population they had silently narrowed:

  1. The file-level "defining TU" skip. engine_core.h is not a TU — it holds ~1,600 macro definitions plus thousands of unrelated macro-local externs. Skipping it whole left 10 stale externs while 1,514 fleet sites moved, and the run still printed non-canonical declarations remaining: 0 OK (axis complete) — a completion assertion blind to the file it had skipped. Fix: scope the skip to the defining macro's span, not the file.
  2. The return-axis comparison. It matched the literal spelling extern <ret> , so typedef int s32 made 11 int sites look like a return change and 2 sites omitting extern defeated the prefix — refusing a conform whose return type was s32 on both sides, citing 165 consumers. Fix: compare NORMALIZED return types.
  3. The §85 consumer scan — the dangerous one, because it under-reported. Three surface patterns (= fn(, return fn(, if (fn() miss every consumer that is not immediately after the operator, and this codebase casts constantly: s0 = (s32 *)func_80144A04((s32 *)a1); — cleared as "0 callers consume", then the build failed with void value not ignored as it ought to be. Fix: invert the test — a call is DISCARDED only when it stands alone as a complete statement; everything else consumes. Validated both ways: finds exactly the site that broke the build, still returns 0 for the two return-axis changes that gated clean.

Arity conforms: cast the handful of call sites, do not revert the axis. Widening (s32) to (s32, s32, s32) fleet-wide produced too few arguments at exactly 6 sites (5 real, 1 in a comment). The §17a-1 fn-pointer cast ((void (*)())func_X)(a) is byte-neutral — the callee address is a compile-time constant, so gcc still emits a direct jal — and it preserves a 2,843-site axis that reverting would have thrown away.

Prediction check, recorded because it was wrong: the documented hazard (SCALAR-NARROWING s32 -> u16, byte-proven on func_80175DA8) was benign here across 2,052 sites; the breaks came from arity and from the under-reporting consumer guard, neither of which the tool warned about.

memcpy is NOT conformable this way. It has no C definition to be byte-truth, and the build emits warning: conflicting types for built-in function 'memcpy' — the declaration changes gcc's builtin handling, which is why ov_MAIN_012.c:14333 records an extern memcpy turning an inlined block-move into a CALL. That class needs a chosen canonical + its own gate, not a mechanical conform.

THE GENERAL LAW (worth a rule): an assertion that excludes part of its own population reports success over the gap. All three defects printed a clean verdict while skipping a file, a typedef alias, or a syntactic position. A guard must state its COVERAGE, not just its verdict — the same finding as _open_stubs vs corpus.stubs (T0(d)) and the family_hseq scope stamp (T0(c)).

§160 — THE REACH-15 WAVE HARVEST: an align-1 block move, and the instrument that called it a wall (P30 S47)

§160a — UNALIGNED 8-BYTE COPY = an ALIGN-1 STRUCT ASSIGNMENT. Target shape:

lwl $v0,0x3($a1) ; lwr $v0,0x0($a1) ; lwr $v1,0x4($a1)
swl $v0,0x13($sp); swr $v0,0x10($sp); swr $v1,0x14($sp)

lwl/lwr + swl/swr is gcc-2.7.2's emit_block_move when the moved type has alignment 1. A u32 copy gives aligned lw/sw and is wrong. The C that produces it, byte-proven on func_801EDC18:

typedef struct { char c[8]; } Blk8;
extern Blk8 D_801ED98C;            /* see §160c — this one needed a DEFINITION, not an extern */
Blk8 buffer;
buffer = D_801ED98C;               /* struct assignment, NOT memcpy, NOT a u32 loop */

Generalisation: read the move width off the target, not off the data's apparent type. Any lwl in a target means the source expression's type had alignment 1 at that point — a char[]/u8[] struct, never a u32* cast. The in-tree note at src/ov_MAIN_012/ov_MAIN_012.c:14333 records the same mechanism from the other direction (an extern memcpy disabling the builtin turned an inlined block-move into a CALL).

§160b — match_one WAS BLIND TO DATA-BUNDLED .s FILES; a byte-perfect draft read as a wall. A splat .s may bundle a leading data symbol with its function: .section .rodata re-emitting D_801ED98C as two .words, then .section .text. Those .word lines carry the SAME /* off vaddr HEX */ comment shape as instructions, so masked_diff.insns_from_s counted them as target instructions — while insns_from_object (objdump -j .text) can never emit them. Result on a byte-perfect draft: mine=26, target=28, 26 mismatched, every position shifted by a constant +2, classified SIZE-MISMATCH [redraft]. The wave agent correctly abandoned it at "closeness 6"; the whole-binary gate then banked that very body. 116 of 12,583 .s files in the corpus have this shape — every one mis-measured, one at -29 instructions. Fixed: insns_from_s now tracks .section and counts only .text. Controlled over the full corpus: 12,467 files unchanged, 116 corrected, zero regressions. Same artifact class as §129a (post-carve jtbl inflates the target count). One shape, two causes: when mine is SHORTER than target by a small constant and every position looks wrong, suspect the INSTRUMENT's section scope before redrafting.

§160c — THE .s RODATA BLOCK IS THE DEFINITION, and the fleet's externs are only consumers. Four sites declare extern short D_801ED98C; and NOTHING in src/ defines it — from which I concluded the binary owned the data and shipped an extern-only draft. The gate refuted it: undefined reference to D_801ED98C. The symbol was defined by the very .s the draft replaced, so banking the function deleted the data. The variant that emits it — const Blk8 D_801ED98C = {{0x00,0x00,0x7E,0xFF,0xB0,0x00,0x00,0x00}}; (the little-endian decomposition of the two .words) — banks clean. Law: when the target .s contains a data symbol, that draft OWNS the data. Grep for a definition, never infer ownership from the presence of externs.

§160d — THE ASYMMETRIC INDEX RELOAD (func_801EDED4). Two consecutive table lookups indexed by a byte field that was JUST stored must NOT both read the same C variable. The target emits sb, then sll reusing the stored register for index #1, then re-loads lbu + a second sll for index #2. The source is deliberately asymmetric — a local for the first use, a memory re-read for the second:

u8 t = (*(u8 *)(p + 5) + 1) & 1;
*(u8 *)(p + 5) = t;
*(s32 *)(p + 0xC)  = TBL_A[t];                  /* reuses t  -> no lbu, one sll */
*(s16 *)(p + 0x2E) = TBL_B[*(u8 *)(p + 5)];     /* re-reads  -> the second lbu + sll */

Both-t loses 2 instructions (cse collapses index*2); both-memory risks cse substituting a (subreg:QI reg) and emitting a spurious andi 0xff. Read the lbu/sll COUNT off the target and distribute local-vs-memory to match it — do not assume the source is uniform.

§160e — STACK-LAYOUT SOURCE ORDER, now byte-proven on a SECOND function (func_8017D364 + func_801EDED4, so this is a rule, not a coincidence). With MATRIX m1 + SVECTOR in + SVECTOR out on the stack, writing m1.t[0]; m1.t[1]; m1.t[2]; in.vx=0; in.vy=0; in.vz=…; in that source order lands the two sh $zero stores in the load-delay window after the a1 setup and before the t[2] store. Any other order moves them.

§160f — ADDRESS-ONLY GLOBAL STORE: to store a symbol's ADDRESS (not its value), declare it as an array — extern u8 SYM[]; — so array-to-pointer decay emits lui/addiu with no following load.

§160g — PROCESS: sibling-search keyed on the CALLEE SET should be STEP 0 of every wave prompt. One grep for two callee symbols returned an already-banked body that turned a 126-instruction crack into a copy-edit. The existing wave template greps cross-overlay magic literals; extend it to "grep the callee symbols across src/ for a non-INCLUDE_ASM body". Engine-state globals like D_80126948 are shared across many overlay TUs, so a matched sibling is often already sitting there.

§161 — THE RETRY-WAVE HARVEST: a jump table indexed from zero, and two allocator traps (P30 S47)

§161a — case 0: break; IS LOAD-BEARING WHEN A JUMP TABLE IS INDEXED FROM ZERO. Target shape:

lhu $v1,0x34($s0) ; sltiu $v0,$v1,6 ; sll $v0,$v1,2      <- NO `addiu $v1,$v1,-1`

Writing the natural case 1: … case 5: makes gcc-2.7.2 pick minval = 1, so it emits addiu $v1,$v0,-1; sltiu $v0,$v1,5 and every table index shifts one slot. The body can be perfect and it still reads 58 of 77 mismatched — a near-total mismatch produced by a one-line source difference, which is exactly the shape that gets a whole family written off as a codegen wall. Fix: add an explicit empty case 0: break; as the FIRST case. minval drops to 0, the subtract disappears, and gcc's jump optimizer threads the empty body straight onto the epilogue. THE DIAGNOSTIC TELL (family-wide): if a member's jump table has its first entry pointing at that function's own epilogue/end address, it needs the case 0 construction. ⚠ CORRECTED BY §162a (P30 S48): this tell is NOT exhaustive — check BOTH edges of the table. As written it reads as a complete test on entry[0], and an agent that finds a real body there stops looking. The upper edge is equally source-controlled: entry[N-1] == the epilogue needs a TRAILING empty case N-1: break; to pin maxval, and its symptom is the opposite of the one below — maxval-wrong shifts NOTHING (two bytes: the sltiu immediate and a table one word short), so it is functionally invisible and surfaces only as image drift. Byte-proven on func_8018CC40 (10-member family); emitted table [$L2(end), $L4, $L7, $L9, $L10, $L12] matches jtbl_801E59F8 = [0x8018CD60(end), 0x8018CC7C, 0x8018CCC4, 0x8018CCEC, 0x8018CCFC, 0x8018CD40]. Corollary: a bnez inside a case arm that jumps to a label which is NOT a jtbl entry is a plain if/else, not a case fallthrough — do not model it as one.

§161b — ALIASING A PARAMETER INTO A LOCAL CAN COST A SECOND CALLEE-SAVED REGISTER. void *s0 = a0; before three mutually-exclusive uses forced gcc-2.7.2 to allocate a SECOND callee-saved register (+8 bytes of frame, +3 instructions) even though the uses never overlap. Using the raw parameter directly at every site — *(s32 *)(a0 + 0xE4) = …, typed s32, not void * — collapsed it back to the single $s0 the target uses. When your frame is 8 bytes too big and you have one more sw $sN than the target, look for a pointer alias before touching pins.

§161c — LOOSE-PROTOTYPE ENGINE HELPERS AND THE DECL THAT FIGHTS THEM. Some engine helpers are declared (void) at file scope in an overlay yet every jal to them carries addu $a0,$s0,$zero in the delay slot — they really take an argument. Declaring extern s32 f(); in the draft to model that collides with the TU's (void) and the gate reports too many arguments to function (byte-witnessed, func_80178970). Do NOT fight the file-scope decl: drop the draft's extern and cast at the call site (§17a-1) — ((s32 (*)(s32))func_80178970)(a0) — which gcc folds to a direct jal, so it is codegen-neutral. Six call sites converted; the gate then banked it. Process note: the crack agent PREDICTED this failure in its report before the gate ran. Read the agent's integration notes before diagnosing a gate failure — it has already seen the TU.