phase11: THE EPILOGUE POST-PASS SHIPS (maspsx=epilogue) — 565 bodies / 574 regions

Finding 84 named the transform; it is now implemented and 0x800FFBEC matches (80 B, 0 differing)
where it was 6 differing bytes without it.

IT IS A SWAP, NOT A MOVE, and getting that wrong cost one implementation: the candidate is
lw $31,16(sp) / addiu sp,sp,24 / jr $31 / nop and the original is lw $31 / nop / jr $31 /
addiu sp,sp,24 -- SAME instruction count, two words swapped. My first version moved the release
after the jump and dropped the nop, producing 3 instructions instead of 4 and turning an 80-byte
row into a 76-byte LENGTH-MISMATCH. A 'small mechanical transform' still has to be checked
against the bytes.

SCALE: 120 unclaimed rows have the filled epilogue in the ORIGINAL (scanned every worklist row's
tail for jr $31 followed by a positive addiu sp,sp,N). They are mostly SMALL -- 76, 76, 80, 92,
92, 96, 104 B -- so this is a large class of cheap rows that were blocked on a HARNESS GAP rather
than on source shape. 770 other rows have the unfilled shape and need nothing.

The tracked patch is regenerated and verified to reproduce both modified maspsx files from the
pristine checkout.
This commit is contained in:
Christopher Williams
2026-09-24 10:42:29 -04:00
parent 26a917806a
commit 05be974ce2
7 changed files with 340 additions and 11 deletions
+1
View File
@@ -522,6 +522,7 @@
0x800FEEB8 0x800FEED0 src/func_800FEEB8.c
0x800FEFFC 0x800FF008 src/func_800FEFFC.c
0x800FF6A4 0x800FF6DC src/func_800FF6A4.c
0x800FFBEC 0x800FFC3C src/func_800FFBEC.c maspsx=epilogue
0x80100318 0x80100334 src/func_80100318.c
0x80100740 0x801007E0 src/func_80100740.c
0x80100964 0x8010097C src/func_80100964.c
1 # Code-region registry: one C region per matched function.
522 0x800FEEB8
523 0x800FEFFC
524 0x800FF6A4
525 0x800FFBEC
526 0x80100318
527 0x80100740
528 0x80100964
+27
View File
@@ -2279,3 +2279,30 @@ is a conversion.
Worker D's rule, stated as it would apply it: *"if the frame is 8 bytes short and all 11 differing
bytes are frame-and-displacement, add `int pad[2];`"* — with three instances behind it (finding 131).
**The diagnostic is the shape of the residual, not the size of the body.**
### 140. THE EPILOGUE POST-PASS IS SHIPPED — `maspsx=epilogue` — and the transform is a SWAP (coordinator)
Finding 84 named the transform; it is now implemented, and **it cost one wrong implementation to
learn that it is a SWAP, not a move.** Verified instruction-by-instruction against the original at
`0x800FFBEC`:
candidate: lw $31,16($sp) / addiu $sp,$sp,24 / jr $31 / nop
original: lw $31,16($sp) / nop / jr $31 / addiu $sp,$sp,24
**Same instruction COUNT — two words swapped.** The first implementation moved the release *after*
the jump and dropped the nop, producing 3 instructions instead of 4 and turning an 80-byte row into a
**76-byte LENGTH-MISMATCH**. That is a good reminder that a "small mechanical transform" still has to
be checked against the bytes.
`0x800FFBEC` now reports **80 bytes / 0 differing / MATCH** with the mode, and **6 differing bytes /
DIFF** without it. Shipped as the region token `maspsx=epilogue`; the tracked patch reproduces both
modified maspsx files from the pristine checkout.
**WHY IT IS NEEDED AT ALL:** GNU `as` will not perform this fill itself, because doing so would put
`jr $31` in the `lw $31` **load-delay slot** — which is exactly why worker B's oracle work mattered:
the fill is not something the assembler will do for you, so the harness has to.
**SCALE: 120 unclaimed rows have the filled epilogue in the ORIGINAL** (measured by scanning every
worklist row's tail for `jr $31` followed by a positive `addiu sp,sp,N`). They are mostly **small** —
76, 76, 80, 92, 92, 96, 104 B — so this is a large class of cheap rows that were blocked on a
harness gap rather than on source shape. **770 other rows have the unfilled shape and need nothing.**
+81
View File
@@ -0,0 +1,81 @@
/*
* func_80038D48 — 144 bytes at 0x80038D48..0x80038DD8
*
* Hypothesis, not a claim about meaning: computes the midpoint of two 3-element vectors of
* halfwords and writes it to a third. Pure data movement plus one division by two — it
* matched on the SECOND spelling, and the only difference between the two was the OPERAND
* ORDER of the additions.
*
* Original words:
* 84A30000 lh a3,0(a1) ; t[0] = a1[0] + a0[0]
* 84820000 lh v0,0(a0)
* 27BDFFF0 addiu sp,sp,-16
* 00E31821 addu a3,a3,v0
* AFA30000 sw a3,0(sp)
* 84A20002 lh v0,2(a1) ; t[1] = a1[1] + a0[1]
* 84830002 lh v1,2(a0)
* 00000000 nop
* 00431021 addu v0,v0,v1
* 00031FC2 srl v1,a3,0x1f ; t[0] /= 2 (the sign-bias division form)
* 00E31821 addu a3,a3,v1
* 00031843 sra a3,a3,0x1
* AFA20004 sw v0,4(sp)
* 84A10004 lh v1,4(a1) ; t[2] = a1[2] + a0[2]
* 84810004 lh a1,4(a0)
* 000227C2 srl a0,v0,0x1f ; t[1] /= 2
* 00441021 addu v0,v0,a0
* 00021043 sra v0,v0,0x1
* AFA30000 sw a3,0(sp)
* AFA20004 sw v0,4(sp)
* 00650821 addu v1,v1,a1
* 000107C2 srl v0,v1,0x1f ; t[2] /= 2
* AFA10008 sw v1,8(sp)
* 00621821 addu v1,v1,v0
* 00010843 sra v1,v1,0x1
* AFA10008 sw v1,8(sp)
* A4A30000 sh a3,0(a2) ; a2[0] = t[0]
* 8FA20004 lw v0,4(sp)
* 00000000 nop
* A4A20002 sh v0,2(a2) ; a2[1] = t[1]
* 8FA20008 lw v0,8(sp)
* 00000000 nop
* A4A20004 sh v0,4(a2) ; a2[2] = t[2]
* 27BD0010 addiu sp,sp,16
* 03E00008 jr ra
* 00000000 nop
*
* **THE OPERAND ORDER OF THE ADDITION IS BYTE-REQUIRED: `a1[i] + a0[i]`, not `a0[i] + a1[i]`.**
* The two spellings give the SAME LENGTH (144) and differ by 16 bytes — the loads and the
* `addu` register assignments. cc1 evaluates the right-hand operand first, so the source's
* `a1[i] + a0[i]` is what produces the original's `lh a3,0(a1)` before `lh v0,0(a0)`. This is
* the same class as my `0x800B62C8` finding (a comparison's operand order being
* byte-load-bearing) and it is worth checking FIRST on any row that is the right length with
* a small residual spread over the operand setup.
*
* The other two shapes are inherited from the row's structure:
*
* 1. **The scratch is `int t[3]`** at sp+0/4/8 — the halfwords are loaded with `lh` but the
* sums and quotients are stored as WORDS, so the array must be `int`, not `short`.
* 2. **Each sum is divided by two as a SEPARATE statement** (`t[i] = t[i] / 2;`), which
* produces cc1's sign-bias form `srl; addu; sra` and the double store (`sw` before and
* after the division).
*
* LIMITS: the function name, the element count, the "midpoint" reading and the destination
* type are hypotheses taken from the instruction shape; only the bytes are evidence. No
* callee: this is a leaf and the harness emitted the common epilogue itself.
*/
void func_80038D48(short *a0, short *a1, short *a2)
{
int t[3];
t[0] = a1[0] + a0[0];
t[1] = a1[1] + a0[1];
t[2] = a1[2] + a0[2];
t[0] = t[0] / 2;
t[1] = t[1] / 2;
t[2] = t[2] / 2;
a2[0] = t[0];
a2[1] = t[1];
a2[2] = t[2];
}
+62
View File
@@ -0,0 +1,62 @@
/*
* func_8009B56C — 204 bytes at 0x8009B56C..0x8009B638
*
* Goal B, Phase 11. Three spellings. Found by the size-capped redundancy rank (0.65).
*
* int *p = *(int **)a0;
* v = (*(int *)p < 0) ? 4 : 3;
* a1[5] = v;
* if (v == 3) {
* a1[6] = (int)(BASE + B(7)); a1[7] = (int)(BASE + B(10)); a1[8] = (int)(BASE + B(11));
* } else if (v == 4) {
* a1[6] = (int)(BASE + B(7)); a1[7] = (int)(BASE + B(10));
* a1[8] = (int)(BASE + B(8)); a1[9] = (int)(BASE + B(11));
* }
*
* where BASE = *(int **)(a0 + 4) re-read at every site and B(i) = ((unsigned char *)p)[i]. The two
* arms compute byte-indexed offsets into the same base (`base + p[i]`, scaled by 4 because base is
* `int *`) and differ only in the third element: the `v == 3` arm uses p[11], the `v == 4` arm uses
* p[8] and shifts p[11] to the fourth slot.
*
* THE LEVER — COOKBOOK 43 TRIGGER 1 (POLARITY), and this is the first row of this worker's where a
* BRANCH diagnostic was what closed it. The natural spelling `(*(int *)p >= 0) ? 3 : 4` compiles to
* `bltz v0,L` with **4** in the branch delay slot and 3 as the fall-through; the original has
* `bgez v0,L` with **3** in the slot and 4 falling through — semantically mirrored, 3 differing
* bytes, and the only difference in the whole row. Inverting the condition and swapping the arms —
* `(*(int *)p < 0) ? 4 : 3` — flips the emitted polarity and matches exactly.
*
* So my earlier claim that the branch-shaped diagnostics "do not bite on my rows" was too strong:
* this is a redundant row (two near-identical arms) that failed on a **branch polarity**, not on
* frame, combiner, allocation or batch shape. The corrected statement is that they bite *less often*
* here, not that they do not bite.
*
* LIMITS: the function name, the meaning of the record at a0 and of the output array are hypotheses
* reconstructed from the disassembly; only the compiled bytes are evidence. `v` is 3 or 4 and is
* stored to a1[5] on both paths (it lands in the `bne` delay slot). Whether the record's fields are
* a tagged union or three parallel arrays is not recoverable.
*/
void func_8009B56C(int a0, int *a1)
{
int *p = *(int **)a0;
int v;
#define BASE (*(int **)(a0 + 4))
#define B(i) ((unsigned char *)p)[i]
v = (*(int *)p < 0) ? 4 : 3;
a1[5] = v;
if (v == 3) {
a1[6] = (int)(BASE + B(7));
a1[7] = (int)(BASE + B(10));
a1[8] = (int)(BASE + B(11));
} else if (v == 4) {
a1[6] = (int)(BASE + B(7));
a1[7] = (int)(BASE + B(10));
a1[8] = (int)(BASE + B(8));
a1[9] = (int)(BASE + B(11));
}
#undef B
#undef BASE
}
+65
View File
@@ -0,0 +1,65 @@
/*
* func_800F6D60 — 112 bytes at 0x800F6D60..0x800F6DD0
*
* Hypothesis, not a claim about meaning: initialises a node — copying an 8-int template
* into it, storing a key, clearing its first word, and linking it into a parent when the key
* is large enough. Pure data movement plus one comparison; matched on the FIRST spelling.
*
* Original words:
* 00A03821 move a3,a1 ; p = a1
* 3C028014 lui a2,0x8014 ; src = D_80141410
* 24421410 addiu a2,a2,5136
* 8C420000 lw v0,0(a2) ; *(S *)(p + 1) = *src -- 8-int block move,
* 8C430004 lw v1,4(a2) ; emitted as 3 loads + 3 stores, 3 + 3, then 2 + 2
* 8C450008 lw a1,8(a2)
* ACE20004 sw v0,4(a3)
* ACE30008 sw v1,8(a3)
* ACE5000C sw a1,12(a3)
* 8C42000C lw v0,12(a2)
* ... ACE50018 sw a1,24(a3)
* 8C420018 lw v0,24(a2)
* 8C43001C lw v1,28(a2)
* ACE2001C sw v0,28(a3)
* ACE30020 sw v1,32(a3)
* ACE40048 sw a0,72(a3) ; p[18] = a0
* 2C840002 sltiu a0,a0,2 ; UNSIGNED compare
* 14800004 bnez a0,0x800F6DC8 ; if (a0 < 2) done
* ACE00000 sw zero,0(a3) ; (delay) p[0] = 0 -- runs on BOTH paths
* 8CE20048 lw v0,72(a3) ; v0 = p[18]
* 00000000 nop
* ACE2004C sw a3,76(v0) ; *(int *)(v0 + 76) = (int)p
* 03E00008 jr ra ; 0x800F6DC8
* 00000000 nop
*
* BYTE-REQUIRED SHAPES:
*
* 1. **The template copy is an 8-int STRUCT ASSIGNMENT** (`*(struct S *)(p + 1) = D_80141410;`)
* — cc1 splits it 3+3+2, which is the block-move shape. Eight element stores interleave
* load/store and change the bytes.
* 2. **The key comparison is UNSIGNED** (`sltiu`). Declaring the parameter `unsigned int`
* (or writing `(unsigned)a0 < 2`) is what produces it — the same `unsigned` dial that
* finding 133 records for a family's induction variable.
* 3. **`p[0] = 0` is UNCONDITIONAL in the source but sits in the branch's delay slot**, so
* it runs on both paths. Writing it inside the `if` changes the bytes.
*
* LIMITS: the function name, the template symbol, the node layout (the key at int 18, the
* link at int 19, the parent back-pointer at +76) and the "initialise and link" reading are
* hypotheses taken from the instruction shape; only the bytes are evidence. The `struct S`
* exists purely to obtain the 8-int block move. No callee: this is a leaf and the harness
* emitted the common epilogue itself.
*/
struct S { int v[8]; };
extern struct S D_80141410;
void func_800F6D60(unsigned int a0, int a1)
{
int *p = (int *)a1;
*(struct S *)(p + 1) = D_80141410;
p[18] = a0;
p[0] = 0;
if (a0 >= 2)
*(int *)(p[18] + 76) = (int)p;
}
+31
View File
@@ -0,0 +1,31 @@
/* 0x800FFBEC (80 B) — flush two deferred callbacks.
*
* Validation row for the Phase 11 epilogue post-pass (`maspsx=epilogue`).
* The original's epilogue is `lw $31,16($sp) / nop / jr $31 / addu $sp,$sp,24` —
* the frame release is IN the jump's delay slot. cc1 + maspsx emit
* `lw $31 / nop / addu $sp,$sp,24 / jr $31 / nop` and GNU `as` will not fill it,
* because doing so would put `jr $31` in the `lw $31` load-delay slot.
*/
extern int D_80122174;
extern int D_80122170;
extern int D_80122168;
extern void func_801078A4(void);
void func_800FFBEC(void)
{
void (*callback)(void) = (void (*)(void))D_80122174;
D_80122170 = 0;
if (callback != 0) {
D_80122174 = 0;
callback();
}
if (D_80122168 != 0) {
func_801078A4();
D_80122168 = 0;
}
}
+73 -11
View File
@@ -113,25 +113,87 @@
elif op == "move":
--- a/maspsx.py
+++ b/maspsx.py
@@ -62,8 +62,11 @@
parser.add_argument("--passthrough", action="store_true")
parser.add_argument("--use-comm-section", action="store_true")
parser.add_argument("--use-comm-for-lcomm", action="store_true")
+ # Phase 10 local additions (developer-authorised, opt-in; see
+ # tools/patches/maspsx-phase10-r1r2.patch and docs/SETUP.md).
@@ -1,4 +1,5 @@
import argparse
+import re
import shutil
import subprocess
import sys
@@ -49,6 +50,50 @@
return config
+_FRAME_RELEASE = re.compile(
+ r"^(?P<indent>\s*)(?P<release>(?:addu|addiu)\s+\$sp\s*,\s*\$sp\s*,\s*[-\w]+)\s*$")
+_JUMP_RA = re.compile(r"^\s*(?:j|jr)\s+\$31\s*$")
+_NOP = re.compile(r"^\s*nop\b.*$")
+
+
+def fill_epilogue(text: str) -> str:
+ """Swap the frame release with the jump's delay-slot nop.
+
+ Phase 11 finding 84 (worker B), and the transform is a SWAP, not a move -- getting this
+ wrong cost one implementation. Verified instruction-by-instruction against the original
+ at 0x800FFBEC:
+
+ candidate: lw $31,16($sp) / addiu $sp,$sp,24 / jr $31 / nop
+ original: lw $31,16($sp) / nop / jr $31 / addiu $sp,$sp,24
+
+ Same instruction COUNT, two words swapped. The first version of this function moved the
+ release after the jump and dropped the nop, producing 3 instructions instead of 4 and
+ turning an 80-byte row into a 76-byte LENGTH-MISMATCH.
+
+ GNU `as` will not perform this fill itself, because doing so would put `jr $31` in the
+ `lw $31` load-delay slot -- which is why it must be done by hand here.
+
+ Opt-in per region (`maspsx=epilogue`): 120 unclaimed rows have this shape in the ORIGINAL.
+ """
+ lines = text.splitlines()
+ out: list[str] = []
+ index = 0
+ while index < len(lines):
+ line = lines[index]
+ release = _FRAME_RELEASE.match(line)
+ # release / jump / nop -> nop / jump / release (same instruction count)
+ if (release and index + 2 < len(lines)
+ and _JUMP_RA.match(lines[index + 1]) and _NOP.match(lines[index + 2])):
+ out.append(lines[index + 2])
+ out.append(lines[index + 1])
+ out.append(release.group("indent") + release.group("release"))
+ index += 3
+ continue
+ out.append(line)
+ index += 1
+ return "\n".join(out) + ("\n" if text.endswith("\n") else "")
+
+
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--aspsx-version", type=str)
@@ -66,7 +111,8 @@
# tools/patches/maspsx-phase10-r1r2.patch and docs/SETUP.md).
parser.add_argument("--no-jump-slot-nop", action="store_true")
parser.add_argument("--nop-on-reg-read", action="store_true")
- parser.add_argument("--nop-on-reg-read", action="store_true")
+ parser.add_argument("--honour-nop-marker", action="store_true")
+ parser.add_argument("--fill-epilogue", action="store_true")
# decomp.me debugging
parser.add_argument("--print-output", action="store_true")
parser.add_argument("--print-input", action="store_true")
@@ -154,6 +157,9 @@
gp_allow_la=version_config.gp_allow_la,
use_comm_section=args.use_comm_section,
@@ -159,6 +205,7 @@
use_comm_for_lcomm=args.use_comm_for_lcomm,
+ no_jump_slot_nop=args.no_jump_slot_nop,
+ nop_on_reg_read=args.nop_on_reg_read,
no_jump_slot_nop=args.no_jump_slot_nop,
nop_on_reg_read=args.nop_on_reg_read,
+ honour_nop_marker=args.honour_nop_marker,
)
try:
@@ -202,6 +249,8 @@
if process.returncode != 0:
sys.exit(process.returncode)
else:
+ if args.fill_epilogue:
+ out_text = fill_epilogue(out_text)
sys.stdout.write(out_text)