docs(cookbook): §464-§466 — volatile levers, the ASPSX slot-hop gap, and main's -O0 address law

§464, from func_8005DE78 (141 ins -> MATCH): a volatile QI/HI load stops
combine folding the u8->s32 promotion into the lbu; ||-vs-&& selects
do_jump's drop-through arm; a VOLATILE STORE can never be stolen into a delay
slot (resource_conflicts_p returns 1 on any volatil resource) which is how to
force a target nop after a j; and a "memory" clobber vs a volatile read are
NOT interchangeable CSE-breakers — both reload, but only the clobber leaves
the addu operand order alone.

§465, from func_8005F830 (152/153 byte-exact): the target hops the head insn
of the branch's own target block into the delay slot. Ten controlled probes
show cc1's fill_slots_from_thread refuses a thread insn writing the register
the branch TESTS, and a negative control shows GNU as -O2 only swaps with the
PRECEDING insn. So it is the original ASPSX reorder doing what our
REORDER_TUS substitute structurally cannot — an assembler gap, §182/§188 one
level deeper. Also records that this function's old 'epilogue unreachable'
verdicts are stale.

§466, from matching main itself (509 ins, -O0): inside a MEMORY ADDRESS,
base + i*K expands to a (mult reg K) that force_operand emits INDEX-first;
rewriting as base + ((i*(K>>n))<<n) gives the target's BASE-first addu. Value
context is unaffected, which is why it hides. Plus five supporting -O0 idioms
(COMPONENT_REF for strided stores, pad[6] for the 0x38 frame, a dead register
var to keep $s0 live, (*(u16*)x)++ vs +=1, and MEM-operand-0 argument order).
This commit is contained in:
Drew T
2026-09-03 16:02:32 -06:00
parent bf056e179f
commit 66bf1ebe62
+34 -61
View File
@@ -35281,73 +35281,46 @@ drift). The lever is real for one op and a dead end for a chain; do not spend tu
re-read the target schedules early to fill a load-delay slot while cc1 schedules it at its use.
Every C-level attempt to move it either CSE'd the two reads into one (−1 ins) or added a move (+1).
#### §462 — FOUR LEVERS FROM `main:func_80024054` (91 ins, 74/53/32 → 4)
#### §465 — THE ASPSX SLOT-HOP: A GAP OUR REORDER SUBSTITUTE CANNOT CLOSE (`main:func_8005F830`)
**Source: the S76 agent on `func_80024054`.** Four independent unlocks, none previously recorded;
the last one is the kind of rule that silently costs a whole attempt.
**Source: the S76 agent, 152 of 153 instructions byte-exact (indices 0-102 identical), 10 controlled
probes in `scratch/{t3,q,r,p}.s` plus a hand negative control in `scratch/g.s`.**
**1. `array[var - K]` folds K into the symbol's LO16 / `lhu` displacement.** Naming an intermediate
`idx = var - K` does NOT stop it — the fold happens at the front end / in combine, before any
register assignment you could steer. The only thing that defeated it was a zero-byte opacity barrier
immediately after computing the index, one per use site:
`__asm__ __volatile__("" : "=r"(idx) : "0"(idx));`
Reach for this whenever the target loads from `sym+0` with a computed index and your build folds the
constant into the displacement instead.
**The shape.** The target hops `addiu $v0,$zero,0xFE` — the head instruction of the branch's OWN
target block — up into the `beqz $v0,.L8005F9F8` delay slot.
**2. The fused `sll 16; sra 15` sign-extend-and-scale wants the index declared `s16`, not `s32`.**
This confirms §241's recipe on a fresh case — worth knowing it reproduces rather than being a
one-off of that function.
**Why no C can produce it, in two measured halves:**
* **cc1 will not.** `reorg`'s `fill_slots_from_thread` REFUSES a thread insn that writes the register
the conditional branch TESTS. Probes `f4/a8/a9/b3` (branch tests `$2`, thread head is `li $2,K`)
all leave the slot bare; probes `f5/a6/a7/b2` (differing registers) all get the insn stolen. That
is the rule, isolated.
* **GNU `as -O2` will not either.** Its reorder only swaps with the **PRECEDING** instruction, never
hops one up from a branch target. Negative control `scratch/g.s`: gas emits a `nop`, never the hop.
**3. A mask-then-compare LOCAL causes a cross-jump merge AND flips branch polarity.** Writing
`bits = val & 0xC000;` then `if (bits == …) else if …` merged two case tails into one shared block
and emitted `bne`-polarity branches where the target has `beq`. Dropping the local and switching on
the expression directly — `switch (val & 0xC000) { case 0x8000: … case 0xC000: … default: … }` —
fixed both at once and reproduced the target's forward-`beq` shape. **The temporary was the defect**;
this is the same family as §461's "laundering can be the defect", from the opposite direction.
**So this is the original ASPSX assembler's reorder doing something our `REORDER_TUS` substitute
(`reorder_passthrough.py` + `as -O2`) structurally cannot.** It is the §182/§188 epilogue class one
level deeper — an assembler gap, not a source-shape defect, and not reachable by any C spelling.
**4. 🔴 A POINTER PARAMETER'S SIGNEDNESS DECIDES HOW `-1` IS MATERIALIZED.** Declaring `arg1` as
`s16 *` rather than `u16 *` flips the fail-path constant from `ori $x, 0xffff` to `addiu $x, -1`,
matching the target — because gcc-2.7.2 canonicalizes the RHS constant against the **lvalue's**
signedness before choosing the load-immediate opcode. Nothing about the store's *value* changes, so
this is invisible in the C and shows up only as a one-instruction opcode difference. If a residual is
a lone `ori 0xffff` vs `addiu -1`, check the signedness of the pointer being written through before
touching anything else.
**Two corrections that come with it:** the older "epilogue unreachable" verdicts recorded against
this function are **STALE** — `match_one`'s REORDER_TUS path (S76) already packs the epilogue
correctly, and the epilogue is not the residual. And the only banking route left is §265, which
`src/800c3.c` already is. The draft carries TU-verbatim decls and is bank-ready if the assembler gap
is ever closed.
**Left open:** one `DELAY-SLOT` residual — `addu $a3,$zero,$zero` is insn #0 in the target and lands
in the branch delay slot in every C variant. Two independent prior attempts hit the same wall;
five further variants (statement reorder, register pin, barriers either side of the load) each left
it unchanged or traded it for an equal residual elsewhere. Permuter-class, Law 3.
#### §466 — `main` (509 ins, -O0): ADDRESS CONTEXT EMITS `mult` INDEX-FIRST
#### §463 — 🔴 SPILL SLOTS ARE 8 BYTES, AND THE §41b "LOAD ABOVE THE PROLOGUE" WALL IS REFUTED
**Source: the S76 agent that matched `main` itself.** The headline law is new and general:
**Source: the S76 agent on `main:func_8001FC08` (400 ins, 33 → 0 MATCH).** Three laws, and the
second one deletes a wall this file has been asserting.
**Inside a MEMORY ADDRESS, `base + i*K` (constant K) expands to a `(mult reg K)` rtx that
`force_operand` emits INDEX-FIRST** — `addu d,index,base`. Rewriting it as
`base + ((i * (K >> n)) << n)` materialises the index first and yields the target's BASE-first
`addu $v0,$s1,$a0` / `lui %hi; addu $at,$at,idx; sw %lo($at)`. **Value context is unaffected**, which
is why this hides: the same expression is fine everywhere except under a `MEM`.
**1. A 4-BYTE GAP IN AN OTHERWISE 4-PACKED FRAME IS A SPILL SLOT, NOT A PAD.** `sp+0xC8` / `sp+0xD0`
in this target are not struct members — they are spilled pseudos. reload's `alter_reg` calls
`assign_stack_local(mode, size, -1)`, and `align == -1` means `BIGGEST_ALIGNMENT` (8) with
`CEIL_ROUND`, so **every 4-byte spill slot occupies EIGHT bytes**. That is exactly why the target's
two slots sit 8 apart with `0xCC`/`0xD4` untouched. Reading those gaps as padding — or as fields of a
struct you then invent — is a wrong model of the frame. Worth 11 instructions here, and modelling
them as spills is also what evicts both values from local-alloc so reload picks `$t0`.
**2. §41b's "a global load cannot float above the RTL prologue" IS NOT A WALL — it is an `$a0`
ANTI-DEPENDENCE.** The parameter copy `addu $s0, $a0, $zero` *reads* `$a0`, which pins the load
below it. Get the value out of `$a0` and make the load the first statement, and it floats to idx 0
on its own. **Both moves are required and either alone is worthless** — statement-first by itself
measured 33 → **50** (worse); combined with law 1 (which is what frees the register) it went
22 → 4. Before treating a "load above the prologue" residual as unreachable, check what reads the
argument register.
**3. Which ARGUMENT POSITION a guard value is passed in decides its hard register.** Passing it as
arg 1 — `func_80021120(&L.cnt, L.lp)` — gives that pseudo a `qty_phys_copy_sugg` toward `$a1`, which
local-alloc's scan-from-`$v0` can never reach on its own. The sibling guards that do *not* pass it
stay in `$v0`, which is the control proving the mechanism rather than a coincidence.
**Banker caveat for this function:** its `INCLUDE_ASM` is at `src/800.c:11168`, but the TU's own
`MTX_80020248` typedef (`:11180`) and the `D_80074818`/`D_80075018` externs (`:11191-2`) are twelve
lines BELOW it. Hoist that block above `:11168` or drop the draft's copy, or the duplicate typedef is
a hard C89 error at bank time.
**Verified by hand (law 1c):** 26 `jal` targets and 16 HI16/LO16 relocs identical in name and order;
the four `D_1F800020` words are the scratchpad literal, byte-identical (`3c111f80` / `26310020`).
**Supporting -O0 idioms from the same match:**
* 12-byte-strided stores need a struct `COMPONENT_REF` — only that folds to `sw …,8($v0)`.
* `s32 pad[6]` supplies the 24 bytes of dead -O0 locals that make the frame `0x38`.
* A second, unused `register u8 *q = &D_800BA118` keeps the `$s0` `lui`/`addiu` + save alive.
* `(*(u16*)x)++` emits the extra `move` that `+= 1` omits.
* `CatPrim`'s arg2 must be `<load> + D_80074778*4`: with a MEM as operand 0 the address is emitted
first, then operand 1, then the load, so the `addu` comes out operand-1-first.