Matching Patterns - OpenRAC/rac3-uya-decomp GitHub Wiki
What makes SN ee-gcc 2.95.3 produce retail's exact code, collected from the ~1400 frontbin functions matched so far (and boot_elf's). The research behind it (compiler comparisons, how the assemblers were identified) is in docs/compiler_matrix_findings.md.
Every frontbin and boot_elf range already builds with -mvu0-use-vf0-vf2 -O2 -G8 -fopt-stack -mno-check-zero-division, so $s registers saved with sd in 8-byte slots and div without the trap come out right on their own. The VU0 flag (since 2026-10-07, see VU0 j-form) also changes ordinary loop code, so measure drafts with it. What varies per function is how globals are declared, split vs no-split addresses, and which assembler runs.
-G8 lets the compiler address any object of 8 bytes or less relative to $gp. Retail only did that for some variables. Match each global's declaration to how retail reaches it:
| Retail access | Declaration | Use |
|---|---|---|
lw $v0, -0x3e40($gp) |
extern s32 D_001D8A70; (sized) |
D_001D8A70 |
lui $v0, %hi(D_X) + lw $v0, %lo(D_X)($v0)
|
extern s32 D_001DA0D0[]; (no size) |
D_001DA0D0[0] |
Struct or table through lui/addiu
|
extern S_Foo D_00142430[]; or extern S_Foo D_00142430; (size > 8) |
p = D_00142430; p->x |
The address of a $gp access is 0x1DC8B0 + offset (offsets are negative). Name the symbol D_ plus 8 hex digits: -0x5b0c($gp) is D_001D6DA4.
An unsized array is never small data, so it always gets lui. To keep existing code readable you can alias the element:
extern u8 D_00142734[];
#define D_00142734 (D_00142734[0])Sometimes retail reads a variable through lui and writes it through $gp in the same function, or uses $gp only for the access that sits in a branch delay slot. No single declaration does that with the default assembler. The cause is SN's own assembler, Ps2EeAs, which retail was built with:
- It is single-pass. It uses
$gpfor a symbol only if it already knows the symbol is small at that point in the file. gcc writes its.externsize hints at the end of the file, too late. - In a branch delay slot it can't expand a two-instruction
luiaccess, so it uses$gpthere regardless.
Option A (preferred): natural C with @ps2as. Declare everything with its natural sized type and assemble with Ps2EeAs. For the variables retail does reach through $gp everywhere in the function, tell the assembler their size before the function:
/* Ps2EeAs only uses $gp for these if it knows they are small before the use. */
__asm__(".extern D_001D62FC, 4");
__asm__(".extern D_001D6308, 2");
extern void (*D_001D62FC)(void);
extern u16 D_001D6308;.extern only declares a size. It creates no storage. Examples: func_003969B8, func_003D3050. The bitfield writes to D_001D4CEC (flags.b5 = 0;) and the GIF packet writers (p[0] = ...; p += 4;) are this pattern too.
Option B: two declarations with the default assembler. An unsized array for the lui accesses plus a sized alias for the $gp ones:
extern s32 D_001D4CE8[]; /* lui reads */
extern s32 D_001D4CE8_g; /* $gp writes */The alias needs an address in symbol_addrs_resolved.txt (D_001D4CE8_g = 0x1D4CE8;) or the link fails.
Use the plain name (D_00142430, func_003E8320). A source file is one translation unit: every block sees the earlier blocks' declarations in its file, plus, at the top, the declarations from earlier files of names the file uses without declaring them itself. Other files' declarations never reach a file that declares the name itself.
An alias is needed only when two blocks in the same file need different types for one symbol (a different struct type, sized vs unsized, a function used once as an array to take its address), or as one of the code-generation techniques on this page (a second symbol at the same address to get lui where the plain name gets $gp, and the like). Then don't change the shared declaration; declare a per-function alias named after the function address:
typedef struct { u8 pad[0x18]; u16 h18; ... } S_3969B8;
extern S_3969B8 D_00142430_003969B8[];and add D_00142430_003969B8 = 0x142430; to symbol_addrs_resolved.txt (and a D_00142430 = 0x142430; line for the plain name if it's the first use of the address). pr_check.py flags any alias without an address, and try_in_context.py / pr_check.py report a clash that needs one.
Until 2026-10-04 aliases were the default, a leftover of the single text.c, where every declaration stayed in scope to the end. That day 646 of the 941 were renamed back to plain names; the 295 left are the ones where a block in the same file really uses another type, or where the alias changes the generated code.
To pass a function's address, use the function's own name (func_003E8320), declared the way the sources already declare it, not an extern u8 func_003E8320_x[]; array alias.
Name typedefs uniquely the same way (S_3969B8, S_142430x). Two blocks defining the same typedef name with different bodies is the most common full-build error.
The source files must only produce .text. Every variable is extern:
-
s32 D_X;,static s32 D_X = 3;andvoid (*D_X)(void);withoutexternare definitions. They create.data,.sdataor.bss, which shifts the retail layout or fails the link withmultiple definition. - String literals and float constants that the compiler puts in
.rodataor.lit4have the same problem. See floats below. -
pr_check.py --obj build/src/text.c.olists any data section that sneaks in.
Retail was built from many source files, and some used -mno-split-addresses (N) while others used the default (S). They form address runs, recorded in tools/text_parts.txt:
-
S:
lui $v0, %hi(X)+lw $v0, %lo(X)($v0), and array bases aslui+addiu. -
N: the compiler emits
lw $v0, Xand the assembler expands it, so you seelui $atpairs and loads that fold the offset differently.
A function's mode only shows when it touches a global. If the diff is all about how addresses are formed, run try_func.py --all-modes. Known N-only shapes: constructors that return p, destructors that save ra with sd, and the "no-split global load" class.
| Assembler | Selected by | When |
|---|---|---|
bin/ee-as.exe (Aug 2000) |
default | Almost everything. It inserts the nop after mtc1 that retail has. |
ee/bin/Ps2EeAs.exe (SN ps2eeas 1.9.25) |
@ps2as |
Mixed gp/lui access (above); float constants built inline with lui/ori/mtc1; mtc1 hazard nops that depend on whether the next instruction uses the register |
ee/bin/as.exe (May 2001) |
@newas |
Rare; try it when both of the others are one instruction off |
Ps2EeAs can't read the GNU macro.inc, so a range assembled with it must not contain any INCLUDE_ASM stub. Give the function its own single-function range (the build then compiles it as a separate slice of its file) (see Toolchain and build).
Ps2EeAs can select lui/lo instead of $gp when GCC places its scalar
.extern symbol, size declaration after the first access. An isolated probe
with four real four-byte scalars and a float constant demonstrated both
GPREL16 access and inline constants once the same size metadata was made
visible early. Alignment attributes alone did not change that result.
For a scoped candidate, tools/try_func.py accepts repeated
--early-extern-size SYMBOL=SIZE options, for example:
python tools/try_func.py scratch/candidate.c --mode N --as ps2as --early-extern-size D_001DB6DC=4
The helper requires a positive size exactly present in the compiler's own
generated assembly. It duplicates only those declarations immediately before
the first .ent, preserving the initial header, instructions, and late
declarations. Missing symbols, different/conflicting sizes, malformed requests
and missing function entries are rejected. No source inline ASM, register
constraint, storage or instruction substitution is introduced.
This is an opt-in compiler-metadata experiment, not a matching result. Recheck the full function's symbol type/size, payload, all resolved retail bytes and raw object encoding. The current full-project builder does not automatically enable this command-line option; successful candidates need a reproducible build integration before promotion. Never guess a different data width merely to influence GP selection.
-
li.sconstants: retail usually builds them inline (lui $at, 0x3f80/ori/mtc1). Only Ps2EeAs does that. With the default assembler, the constant goes to a.lit4pool and the link fails withR_MIPS_LITERAL lit4. Use@ps2as. - Read the constant's value from the asm:
lui $at, 0x3f46+ori $at, $at, 0x6666is0x3F466666, which is0.775f. Write it with enough digits that it rounds to the same bits. - Float arguments need prototypes. Without one, a
floatargument is promoted todouble(you'll seecvt.d.sand the wrong registers). Declare it (extern f32 func_00388A28(f32, f32);) or cast at the call (((void (*)(void *, f32))func_00388830)(a, f)). - 64-bit values (
sd,ld,dsll32,dsra32) arelongorunsigned longin this compiler, nots32.
A float loaded through $gp (lwc1 $f12, -0x33a4($gp)) is a small-data global in frontbin's .lit segment (0x1D5680 to 0x1D9900), not a literal. Declare it sized, like any $gp variable:
extern f32 D_001D950C; /* 0x1DC8B0 - 0x33A4 */A few such loads reach below 0x1D5680, into the main executable's small data; declare those the same way. Constants written in the source come out inline (lui/ori/mtc1) and need @ps2as, as above.
A function that ends ld $ra, ...; j func_X; addiu $sp, $sp, N (or j func_X straight after the $ra reload) makes a sibling call: it jumps to its last callee instead of calling it and returning. SN ee-gcc 2.95.3 never does that (it emits jal and jr $ra), and frontbin has no such function. boot_elf's engine core has 43 and i5bootn 3. Sony's library compiler, ee-gcc 2.9-ee-991111, does make them (i5bootn's compiler matrix matched them with it, and with nothing newer), and so do 2.96 and 3.2 (about 11% of functions in the 2026-10-07 calibration builds; boot_elf's newlib region looks like 2.96, see docs/boot_elf.md). So a sibling call only says "not our compiler". tools/triage.py puts them in the sibcall bucket; skip them until that compiler is in the toolchain. See docs/compiler_matrix_i5bootn.md, which also lists the libgcc code (byte-identical to the prebuilt libgcc.a) in i5bootn and boot_elf's core.
- Some core functions start on a 4-byte boundary. A source file's object starts 8-aligned, so a file can only begin at an 8-aligned function; inside a file gcc places the functions itself.
- Some core code reloads
$gp(lw $gp, %lo(sym)($v0)): it is library code that switches small-data bases. Leave those as INCLUDE_ASM for now. -
core.rdataisn't split, so a core function with aswitch(ajtbl_in its.s) stays INCLUDE_ASM until it is. - Most of the core is Sony library code built with another compiler (region map in
docs/boot_elf.md). Signs that a function is library code and not worth a C attempt yet (k1, 83 of 200 small core functions skipped for these):- each saved register in its own 16-byte slot with
sd,$rahighest (our gcc gives 8-bytesdslots with-fopt-stackandsqwithout it; thesq_ra_funcs.txtrewrite only producessq); - sibling calls;
- a short loop whose branch delay slot is left empty, with
nops before the branch (padded to 7 words; neither Ps2EeAs nor-fno-delayed-branchreproduces it); - an alignment
nopafter ab; - newlib reent wrappers that keep
&errnoas a full address in a register (our gcc folds the low half into the offset); - varargs functions that save none or all eight FP argument registers (ours saves four);
-
mult $0+mflo(our gcc always uses the 3-operandmult).
- each saved register in its own 16-byte slot with
- Three core functions (
func_00122278,func_0013A9F0,func_00136010) match with SN gcc only with-mcheck-zero-division(the project's-mno-check-zero-divisiondropped), which suggests libraries built with their compiler's default flags. They have single-function overrides. - 989snd's EE side (0x13B330 to 0x13D420) is SN code at
-O2 -G8 -fopt-stackand matches with our toolchain; start core work there. -
A wait loop that re-reads a
$gpglobal withlui(func_0013BD58,@ps2as): retail testsD_1D4A00through$gp, then polls it in a 6-word loop withlui/lw. Write the first test plainly and the loop's read as a volatile cast,do { func_0011F0A0(0); } while (*(volatile u32 *)&D_1D4A00 == 0xFFFFFFFF);. With an unsized alias or a literal address, gcc builds the address in a register before the loop, and agotoloop gets merged with the first test. The loop's load is an assembler macro, whichtools/asm_filter.pycounts as two words in@ps2asranges (see Tools).
switch works in C. The jump tables used to sit inside the data blob; since tools/migrate_jtbls.py they come from the source files, in function order, exactly where retail has them (the start of .data). Each asm function with a table has an INCLUDE_RODATA(...) line right after its INCLUDE_ASM. When you convert the function, delete both lines: gcc's table takes the same place. pr_check.py catches a leftover INCLUDE_RODATA.
Getting the table to start at the right index: if retail's table has entries for 0 and 1 that go to the default code, list them with the default at the end, as in func_003B0FC8:
switch (D_00143A07[0]) {
case 2: return 0x151;
...
case 6: case 7: return 0x150;
case 0: case 1: default: return 0x150;
}Insomniac wrote VU0 math as inline assembly inside C functions, so that is the matching form too. Two forms match retail. Where retail keeps a VU0 sequence together (leaves, and blocks with no ordinary instruction between the VU0 ones), one volatile block with explicit registers matches, as below. Where retail interleaves VU0 instructions with ordinary code (argument moves, constants, lq/sq), use the j-form: a volatile asm is a scheduling barrier, so one block can't reproduce that.
For the one-block form, use explicit $vfN registers in the template and pass pointers as "r" operands:
void func_00388698(void *o, void *a, void *b) {
__asm__ __volatile__(
"lqc2 $vf1, 0(%1)\n"
"lqc2 $vf2, 0(%2)\n"
"vmini.xyzw $vf1, $vf1, $vf2\n"
"nop\n"
"sqc2 $vf1, 0(%0)\n"
: : "r"(o), "r"(a), "r"(b) : "memory");
}Keep any nop retail has between VU0 instructions in the template.
Most VU0 functions are pure assembly leaves: a few lqc2 loads, vector math, an sqc2 store. For those, transcribe every instruction into one __asm__ __volatile__ block in retail's order and let gcc emit the return. The details that decide whether it matches:
-
Raw
.wordlines.tools/fix_quadword_ops.pywriteslqc2,sqc2,lqandsqas raw words so the GNU assembler can't pad them. Decode them back to real instructions in your C (.word 0xD8A10000islqc2 $vf1, 0($5)); a.wordinside inline asm assembles fine but is unreadable. -
The delay slot decides the assembler. gcc ends the function with its own
j $31. With the default assembler in reorder mode, the assembler moves your block's last instruction into that delay slot. When retail has that instruction in the delay slot, the default assembler is right; when retail hasjr $rafollowed bynop, use@ps2as, which leaves the order alone. Try both. -
Branches inside the block need
.set noreorderaround the part that contains the label, or the assembler re-pads the branch. - Functions that end by moving a value into
$f0(mtc1 $at, $f0) still don't match: retail has that instruction in the delay slot and neither assembler puts it there, since gcc, not the assembler, owns the return value. Those need the value returned from C instead of from the asm block.
-
Trailing padding is part of the layout. A function's
.sfile also holds the padding words that follow it (func_0039BD08is 0x24 bytes with 5nops after it, because the next function starts at 0x39BD40). Converting such a function to C drops that padding and shifts everything after it, which changes everyjaltarget in the build even though the function itself is byte-exact andtry_func.pysays MATCH. Before converting, check the gap to the next function's address; if there is one, the padding has to be reproduced or the function left as asm. -
Linker remnants (the
remnantbucket intriage.py, about 200 entries). Retail has about 620 single instructions, each followed by anop, between functions. Nothing references them, and 449 of them areaddiu $sp, $sp, N, a function epilogue. They are what the original linker left behind when it stripped unused functions: the final odd instruction plus its alignmentnop. They are not source code: they live inasm/remnants/and are included withLINKER_REMNANT(...). Don't write C for them.A C function whose whole body is one
asm volatile("addiu $sp, $sp, 0x50")looks like a match in localdecomp but is not one: gcc still appendsj $31and anop, which shifts every later function. 26 of these were scored 0 before localdecomp stopped trimming real instructions past the target size;pr_check.pynow warns when its count runs ahead of the sources. -
Handwritten assembly (the
handwrittenbucket, 100 functions). spimdisasm flags them (addi,$at, unusual registers). The original was a.sfile, so their.sinasm/handwritten/is the source, included withASM_FUNC(...). The same goes for VU0 leaves whose last instruction sits in thejr $radelay slot after a dependent instruction (mtc1 $4, $f0afterqmfc2 $4,ppacbafterppach): neither assembler nor gcc will put it there, even when gcc emits themtc1itself, so the original wrote the whole function,jrincluded, in assembly.
When the instructions are right but their order or registers aren't:
-
Independent stores get rotated by the scheduler. gcc emits the last store of a run to the same base first. For retail's order A, B, C, D write B, C, D, A (
func_003A61D0,func_003A6888). The scheduler issues at most one memory access per cycle; between equal priorities it prefers the instruction with more dependents, then the one that frees a register, then source order (found by rac1-decomp). -
adduoperand order is picked by the access form, not by the order you write the+. gcc canonicalises the addition.p = (u8 *)T + i * 4andb = table + igive index first;T[i].field,S.arr[i]andtable[i]give base first (rac1-decomp). A field at a fixed offset from an indexed table (sw $a0, 0x34($v0)afteraddu base, idx) is an array member of a struct:extern T D_X; D_X.arr[i] = v;(func_00395958). -
Two registers holding the same pointer. Retail computing
base + i * 4into one register and copying it to another before the second store (addu $v0, $a0, $a1; move $a0, $v0) is two arrays in one struct:s->a[i] = x; s->b[i] = y;(func_003A7FC8). Pointer variables,volatileand return tricks don't produce it. -
Callee prototypes decide argument code. Use the declaration the sources already have instead of an alias with guessed types. A
long/unsigned longparameter builds its constant as one macro that can move (func_003A3EF0,func_003D47A0); a callee declared with too many arguments leaves extramoves (func_0037EAA0). A function that ignores its argument in retail may still be called with one:func_003E16B8(&D_001DA9B8)is what keeps the pointer in$v0infunc_003E2D90. -
volatilepins accesses. reorg never moves a volatile access into a delay slot, and volatile stores keep their order against the epilogue (rac1-decomp). Try it before an__asm__fence when retail leaves a slot empty. -
fabsfthat isn't scheduled. When retail'sabs.ssits beforejr $rainstead of in its delay slot, write__asm__("abs.s %0, %1" : "=f"(r) : "f"(x))(func_003BEBF8,func_0037E920). -
Check m2c's pointer arithmetic. It writes
D_X + 0x40orp->f4 + 0x20on struct ors32 *types, which scales the offset. Cast tou8 *first (func_0039D510,func_003E16B8). -
Early
returnvsif/elsechanges block layout and which branch is likely (beql/bnel). -
x = c ? a : bvsifproduce different code (movz/movnvs branches). Try both. - Declaration order of locals can swap registers.
-
Signedness picks
sltvssltuandsravssrl.u8vss32for a flag changesandimasks. -
A copy of a parameter (
s32 id = arg;) sometimes changes which register holds it. -
Pointer vs index loops (
p++vsa[i]) produce different induction code.
Stop after a handful of attempts on the same register difference. Leave it as a partial and move on: breadth gets more done.
When every instruction is right but a variable sits in another register, python tools/regalloc.py scratch/func_X.c prints gcc's global allocation order: for each variable its reference count, live length, priority (about refs * log2(refs) / live) and the register it got. gcc allocates in decreasing priority, and a variable takes the first register not used by anything live at the same time that was already allocated; a parameter prefers its incoming register but loses it to a higher-priority local that overlaps it. So to move a parameter, change what outranks it or what overlaps it:
- Give the competing local a shorter live range (compute it later, or inline it into its single use), or one more or fewer use.
- Declare a temporary in a different block, or turn an
ifthat sets it into a conditional expression: both change the live length and reference count the priority uses. - Declaration order of locals only matters as a tie-break when priorities are equal.
Change the C, re-run the tool, and compare the -> registers with retail before spending a build on it.
m2c (--target mipsee-gcc-c --valid-syntax) gets the logic right for many functions but needs the fixes below before it matches. Each was confirmed on a real function.
-
m2c names parameters by register, C by position. A function that only uses
$a0and$a3must still declarearg1andarg2, or its arguments land in the wrong registers (func_003ACED0). -
Virtual calls pass the object as the first argument. m2c drops it when
$a0already holds it:(*vtbl[0x10])(obj, D_x), not(*vtbl[0x10])(D_x)(func_003E7028,func_003E7D68). The branch is also usually positive:if (call(...) != 0) { ...; return 1; } return 0;. -
Early return versus a result variable changes the branch layout. If only the branch shape differs, try
if (!cond) return 0; ...; return 1;(func_003B4220). -
Narrow argument types. A
s8parameter makes gcc sign-extend at entry. If retail only stores the byte, declare the parameters32and let the store narrow it (func_003B4220). -
Keep one base register with a struct pointer. m2c repeats
M2C_FIELD(&D_x, ..., off), which gcc folds into the address. Declare a struct with the fields at their offsets, takeS *p = D_x;once and writep->field(func_003B8700,func_0039C710). -
(x > -1 ? x : x + 0xFF) >> 8 << 8is(x / 256) * 256(signed). The same for 1024 (func_003AAD40,func_003AAEC8). -
A callee's parameter order sets how its arguments are scheduled. Registers are assigned by type independently (ints and floats separately), so a prototype can list parameters in a different order than their registers, and that changes which argument setup lands in the
jaldelay slot (func_0038C840,func_0038C718). -
128-bit copies (
lq/sq):typedef int u128_t __attribute__((mode(TI)));and*(u128_t *)p = *(u128_t *)q;. -
Unaligned 16-byte copies (
ldl/ldr/sdl/sdr): assign a struct ofu8 b[16](alignment 1):V16 v = D_x;(func_003E5D08and its four siblings, with-O2 -G8split addresses). -
Singleton lookup template (
func_003E24B0and six siblings). Retail is split-address (-G8, no-mno-split-addresses), the singleton is a struct object with a local pointer, and the check is a conditional expression:p = D_001DA9B8_x; /* typedef struct { s32 x0, x4, x8, xC; } S; extern S D_...[]; */ if (p->x4) q = p; else q = func_003E16B8(p); t = (void *)func_003E0E28(func_003E1898(q), arg0); v = (t != 0 && vcall(t, g) != 0) ? t : 0;
Separate
tandvare needed here: retail keeps the tested object in$a0. -
Pointer-count loops over a table of function pointers keep one outer struct pointer:
p->q->fn[i]rather than cachingp->q(func_003E15D8). -
Loop padding no assembler mode reproduces (retail has
jal; nop; nop; nop; bnez):do { r = f(); __asm__ volatile("nop\n\tnop\n\tnop"); } while (r);(func_003B6488,func_003AB100). This is an inline-asm hack.tools/asm_filter.pynow countsjal/jalrwhen it pads short loops (see "Loop padding with a call in the body" below), so try the plain loop first; it may make the hack unnecessary. -
64-bit constants and
lwu: declare the callee parameter and the struct field asunsigned long(func_00386608, needs@ps2as). -
Under
@ps2as, a scalarextern s32 X;gets the non-gp form (lui; lw, orlui $at; sw). Add__asm__(".extern X, 4");only for symbols retail reaches through$gp(func_003A3220,func_003D3C80). -
Check the raw words for
lq/sq(.word 0x78../0x7C..) before rewriting a copy loop (func_0037F550). -
Singleton-style load through a different address form: a second symbol alias at the same address (
D_001D52FC_003B42D0, unsized array) gives retail'slui/lwwhere the plain name gives$gp(func_003B42D0).
Each of these fixed a real near miss.
-
Toggle a callee between
voidands32. Retail's register choice depends on whether the callee's result is live in$v0. A call whose result is discarded but declareds32kept$v0busy and moved a followingluifrom$v0to$v1(func_003AE098,func_003B12C8,func_003CB890,func_003BC0F0,func_003B62E8). If another block in the same part already declares itvoid, call through a cast (((s32 (*)(s32))func_0039D6C8)(1)) instead of redeclaring. -
Scalar versus array decides who splits the address. Under
@ps2as,extern s32 X;becomes the assembler macro (lui $a0; lw $a0, lo($a0), destination reused) andextern s32 X[]/X[0]becomes gcc's split form (lui $v1; lw $v0, lo($v1)). When a near miss differs only in which register holds the high half, flip that global (func_003958A0,func_00396F18,func_003ADF88). Add.extern X, 4for the symbols retail reaches through$gp. -
Singleton vtable wrappers (the
D_001DA9B8family) match in plainSmode (no-mno-split-addresses). The key is a temporary for the virtual call's result:t = vcall(p); cb = lookup(table, t); r = cb(p, ...). Twelve functions follow this template (func_003E2728,func_003E2808,func_003E28E0,func_003E1E50,func_003E2028,func_003E2C88,func_003E22D0,func_003E23C0,func_003E2B98,func_003E21F8,func_003E2618,func_003E2A90), and the permuter found the same template forfunc_003E2118,func_003E29B8andfunc_003E30C8. The lookup functions are defined later in the same part, so call them through an alias symbol and a cast. -
Index first in an address sum.
(i << 2) + (s32)basegivesaddu idx, base, the order retail has;base + i * 4gives the other (func_003E8EC8,func_00392108). -
A callee that returns its argument. Use the result (
b = f(b, 1)), or the pointer needs its own callee-saved register (func_003DFB40family). -
Separate return variable, single return.
func_003ABE98andfunc_003AD520only match with onereturn r;at the end and no early return inside the else branch; early returns let gcc foldrto a constant and free the register. -
Hash-style update
off = (key & 1) + (off + 1)gets reassociated; a temporary (t = off + 1; off = (key & 1) + t;) keeps retail's order. With it plus the init-order fix below, the lookupsfunc_003E4890,func_003E4918andfunc_003E4DA0match; the insertsfunc_003E5F00,func_003E6680andfunc_003E67B8are still open (setup moves hoisted in a different order). -
Chains of
!= 0tests combined with&(func_003E34F0,func_003E3580,func_003E3630):r = f(a, b) != 0; t = f(a, c) != 0; r = r & t; .... A single expression gives conditional moves instead. -
A switch whose table starts at 0 needs
case 0:sharing thedefault:body (func_003AE368); without it gcc subtracts 1 and builds a shorter table. -
A reload that retail really performs after a store to the same global needs
volatileon that global (func_00395FF0,D_001D4CEC); a second alias symbol is scheduled above the store instead. -
Object-array initialisers (
if (flag == 0) { for (...) init(p++); flag = 1; } return &array[i];): retail has onenopin the loop that neitherSnor@ps2asreproduces, so they needi--; __asm__ volatile("nop"); p++;(func_003E13D0,func_003AD820,func_003AEDF8, as infunc_003B46F0). That is an inline-asm hack. -
Order of independent stores can only be found by search. A statement-order hill climb on the store lines found
func_003AC0D8. decomp-permuter finds the same kind of change; it also tends to introducedo { ... } while (0)andnew_vartemporaries, which are harmless, but check that it did not change what the code does (it once moved an assignment into anif).
Thirteen retail functions (func_003869E8, 0038C888, 0038C9D8, 003A6C30, 003A9E60, 003AAF88, 003B3DB8, 003B82C0, 003BA5B8, 003C0B10, 003C1130, 003D1B10, 003D2370) save $ra with sq in a 16-byte slot. gcc 2.95.3 writes sd, and when the function also saves $s registers it puts $ra in the lowest slot, where retail puts it in the highest ($s0 lowest, ascending). No compiler or flag we have gives retail's layout, so tools/asm_filter.py rewrites it: list the function in tools/sq_ra_funcs.txt and every callee-saved save and restore is moved to retail's slot (same slot set, same frame size) as sq/lq. Write the C normally.
- A function with
$sregisters also needs the-fopt-stackflag removed (a single-function override intools/text_parts.txt), because retail's$ssaves aresq, notsd. -
func_0038C888andfunc_0038C9D8are done this way.func_003A9E60has the right slots and call; only its argument-copy registers differ (retail gives$a0the first temp, gcc gives it the last). - A 64-bit argument that is moved with
daddumust belong, nots64(s64produced a 128-bitpor).
-
Read a struct-typed global through its fields, with locals for the early reads.
func_003A13B0only matched once the two reads ofD_00225780(f30,f6C) were locals at the top and the indexed table was a struct array (D_160C40.e[idx].v[a], entry type 0x14C bytes) instead of pointer arithmetic. Retail's base-register reuse ($a1holds one global, then the other) follows from that. -
Do not cache a field chain if retail re-reads it (
func_003C0B10):obj->set->nandobj->set->arr[j]->ewritten out each time matched; a local forobj->setdid not. The same holds for plain fields:func_003AC6F0matched withp->f8,p->f0written at each use, where locals for them rotated$a2/$a3/$t0. -
A base pointer that is hoisted to the top (
addiu $a1, $a0, 0x70before anything else) is a localu8 *b = p->b;declared first (func_003932B0). -
Assign first, then test (
func_003932B0):p->f74 = t; if (a < t) p->f74 = a; else if (t < 0) p->f74 = 0;puts the first store in the branch's delay slot the way retail has it. The if/else form with the store inside each arm does not. -
Boolean return from a comparison: write
if (r < 0) return 0; return 1;(func_003AAAC8).return r >= 0;(and!(r < 0),(r < 0) ^ 1) compile tonor/srl, retail hasslti+xori 1. -
Search loops (
func_0038EC80,func_00395BC0):for (i = 0; i < N; i++) if (tab[i].k == key) break;followed byif (i == N) return;/if (i < N)gives retail's rotated loop. They need a struct with the exact element stride; the function itself is easy once the stride is right. -
Loop over an index range (
func_0039B0F8):for (i = lo; i < hi; i++) { dst[i].x = ...; }withlo/hiread from a table matched; the pointer-walking version withn = hi - lodid not. -
Float immediates and
$gpfloats together (func_003A3028):@ps2asplus__asm__(".extern X, 4");for each$gpfloat/pointer global; initialise a loop offset inside theifthat guards the loop (off = 0;before theifmoves themoveabove the branch). -
Typedef and extern names collide within a file. A block's typedefs and
externs stay visible to every later block in the same source file, so give each function's types a suffix (S_395BC0), and give a global an alias symbol (D_160C40_00395BC0plus a line insymbol_addrs_resolved.txt) only when another block of the file declares it differently. -
VU0 code that is not a pure leaf (
func_003BFD10): C around one__asm__block withlqc2/vadd.xyz/sqc2matched in no-split mode; a callee that other matched code declares with fewer arguments needs an alias symbol (func_003BFC18_003BFD10). -
div.swithnops in front (func_00393380): matched as plain C oncetools/divs_nops.txthandled the padding (see thediv.sentry under Known open problems). Don't use the old inline-asmnop; nop; div.sworkaround; it changes the register choice.
-
Switch functions work now. Write the
switch; the compiler emits its own jump table in.rodata, so delete theINCLUDE_RODATA(... jtbl_XXXXXXXX)line after the function (func_003A0178,func_0039BD48). Case numbers must follow the retail table, not the order of the code: readasm/nonmatchings/text/rodata/jtbl_*.s, find which index points at each body, and put the bodies in address order. gcc only builds a table when at least five non-merged case nodes exist and it deletes cases that lead to the same place asdefault; to get retail's long table add cases thatbreakbut are not adjacent (case 0: case 2: case 4: case 18: break;), which keeps a 19-entry table. -
A store that gcc pulls into a branch delay slot but retail does not (
func_003ADBB0): make that store volatile,*(volatile s32 *)&D_001D9F40 = 0;. Retail put the epilogue's first load there instead. -
A reload of a pointer global after every store (
func_00385570,func_00387B10, GIF packet writers): declare the pointer globals32and write through casts of it (*(s32 *)(D + 0x64) = a;). Ans32store may alias ans32global, so gcc reloads it; a store through a typed struct pointer does not reload. -
Boolean ANDs of call results (
func_003E3A80):t = f() != 0; ok = ok & t;with a temporary matchessltu+and. Writingok &= f() != 0makes gcc usemovz. -
Two arms where retail's branch-likely goes to the later block (
func_003AAC70): put the!=case first (if (b->f4 != 4) {A} else {B}), and the arm that falls through first in the secondif. -
Pointer arithmetic order (
func_003AAC70):(s32)((u8 *)b + 8 + b->f30)givesaddiubeforeaddu;(s32)b + b->f30 + 8gives the opposite. -
Loop padding with a call in the body (
func_003BD360):tools/asm_filter.pynow countsjal/jalras one instruction when it pads short loops to 6. -
A call with no argument moves (
func_003E11D0,func_003E5F00): retail'sjalfollows the prologue with$a0/$a1untouched even though the callee takes them. The original called it with the incoming registers still live, so call it through a zero-argument declaration:extern void *func_003E1150_003E11D0();(alias symbol, K&R, no prototype) and writefunc_003E1150_003E11D0(). gcc then emits nomove $a0, $s1. -
Boolean from a compare (
func_003AAAC8):if (r < 0) return 0; return 1;, notreturn r >= 0;.
Five functions between 0x600 and 0x96C bytes (func_0039DB38, func_003E3F08, func_003DF038, func_003DE8F0, func_00384420), all S mode with @ps2as.
-
Small objects get the assembler's
la, not gcc's split. Under@ps2as, gcc treats an object of 8 bytes or less as small data and emits onela/lwmacro, which Ps2EeAs expands tolui/addiu(adjacent, same register) when it has no.externhint. A larger or unsized object gets gcc's split form instead (luiandaddiuscheduled apart,%hiCSE'd). So when retail'slui/addiufor an address are adjacent, and the same address is rebuilt at every use instead of kept in a register, declare the object with its real small size:extern s32 D_001DA868[2];(an 8-byte buffer cleared withfunc_00388440(p, 0, 8)) or the 0x20-spaced hash tables at 0x1DA9C8 infunc_003E3F08. -
Which addresses stay in
$sregisters is gcc's allocation priority. In a long run of calls that reuse table addresses (func_003E3F08), each repeated address is one pseudo; the ones with many uses over a short span win the callee-saved registers and the rest are rebuilt withlui/addiuat each use. If the wrong ones are cached, look for an object that is really small data (previous point) before reordering calls. -
volatileon a global that is saved and restored around a call (t = D_001D9F40; f(); D_001D9F40 = t;infunc_00384420). Retail keeps both accesses out of the call delay slots, which only happens for a volatile access. -
A hardware register read with the full address in a register (
lui; ori 0x800; lw 0($v0)):*(volatile u32 *)0x10000800. Withoutvolatilegcc folds the low half into the load offset. -
A byte constant address that must not land in a delay slot:
*(u8 *)0x1D5477instead of a declared global (func_0039DB38). A declared scalar is small data, so reorg puts its load in a delay slot and Ps2EeAs turns it into$gp.- The same works without a constant address when the variable is the next word of a declared global:
(&D_1D4A40)[1] = b;instead ofD_1D4A44 = b;keeps the store aslui; swbefore the branch, whereD_1D4A44goes into the delay slot as a$gpstore (func_0013B918).
- The same works without a constant address when the variable is the next word of a declared global:
-
Loop-invariant copies come from member access in the loop. Retail's
move $t0, $t2before each loop (func_0039DB38) is loop.c hoistingo + 0x38Cout ofo->an[o->idx][i]. A local pointer (an = o->an) makes it disappear. Likewise a pointer that loop strength reduction creates (lbu 4($a2)with$a2stepping) comes from indexing (raw[i + 4]), not from a pointer variable in the source. -
One variable per loop counter. A counter shared by a loop with calls and a loop without moves both into a callee-saved register; give each loop its own variable. The same goes for a temporary reused for unrelated values (a save/restore temp and a swap temp in
func_00384420). -
Stores to one struct in retail's order A..J: the "write B, C, ..., A" rotation applied to
func_0039DB38's ten stores only after a statement-order search; try permutations of the stores before anything else. -
(x ^ 1) == 0and(x ^ 1) & 1give retail'sxoritest wherex == 1or!(x & 1)giveli/bneorandi/bnez(func_003DE8F0,func_00384420). Bit 0 of a flags word read withlbuis*(u8 *)&flags. -
Blocks of the same function with their own locals: two branches that each pass
&wto a callee use two stack slots in retail (sp0,sp4), so each branch declares its ownf32 w;(func_003DE8F0). -
div.sright aftermtc1under@ps2as: Ps2EeAs adds its ownmtc1hazardnop, so thetools/divs_nops.txtcount for thatdiv.sis one less than retail'snopcount (func_00384420). -
sqrt.sthat spimdisasm prints asc1 0x504is missed bygen_divs_nops.py; check the table line has one count persqrt.stoo (func_0038F3F8,func_00390730). With the default flags,sqrtf()adds an errno check and a fallback call. The existing inline form is__asm__("sqrt.s %0, %1" : "=f"(r) : "f"(sum));with a separate input variable. For a plain-C candidate, see the native compiler probe below before adding assembly.
SN ee-gcc 2.95.3 v1.36 can emit sqrt.s from plain C. An isolated probe on
2026-10-03 compiled sqrtf(value) and __builtin_sqrtf(value) with the existing
-O2 -G8 -mno-split-addresses -fopt-stack -mno-check-zero-division flags:
| Additional flags | Function size | Native sqrt.s
|
Calls in that function |
|---|---|---|---|
| none | 44 bytes | 1 | 1, errno/fallback path |
-ffast-math |
12 bytes | 1 | 0 |
-O3 -ffast-math |
12 bytes | 1 | 0 |
The source used a correctly typed extern f32 sqrtf(f32); declaration and no
inline assembly, forced register or approximation. The correctly typed double
sqrt probe still needed conversions/calls, not the same single-precision leaf.
The spelling __builtin_sqrt remained an undefined symbol in a separate probe;
no supported double-precision builtin ABI is inferred from that result.
-fno-math-errno is not accepted by this
compiler's cc1.
This proves compiler capacity, not a matching game function or numerical
equivalence for arbitrary inputs. -ffast-math may also alter surrounding
floating-point evaluation and NaN/unordered comparisons. Try it only as a
scoped candidate configuration; compare the complete compiled function,
including padding, all branches and fully resolved relocations, against retail.
Never remove an errno/libm call by relabeling it as a native instruction or
replace square root with an approximate routine to obtain a match.
A function sees the declarations at the top of its file (from earlier files), every earlier block of its own file, and nothing from later files. A file with mixed flags is compiled in slices, and a slice only sees the declarations of the file's earlier blocks, never their definitions. So:
- A call to a function that is defined in another file, or in another slice of the same file, still needs its own
extern. - An
externin your block must agree with that function's definition if both end up in the same file (or slice). When they differ, use the definition's types and cast at the call, or align the otherexternlines to yours (callers that ignore the result are unaffected when the return type changes fromvoidtos32). - A K&R declaration
extern s32 f();followed by a prototyped definition withu8ors16parameters is a conflict; use a prototype. - A function called with a different argument count than its existing prototype needs a cast call:
((s32 (*)())f)(a, b).
Many functions are near-copies. Once one is matched, the rest usually go fast:
-
isAchecks through a vtable at+8:if ((*(VT_E **)(p + 8))->isA(p, D_001D97D0_g)) { func_X(p, a, b); return 1; } return 0; -
8-slot hash tables (
HT_8/HE_8):h = ((key & 7) + ((key % 7) * i + i)) & 7;over 8 probes, with a sentinel value. -
Getters/setters on the big state struct at
0x142430, each with its own aliased declaration.
If you find a family, say so in your PR. It helps the next person.
Leave these for now, or open an issue if you crack one:
-
Resolved (hand-written):
lwc1 $fN, off($gp)followed bynop(func_003882D0,func_00388308,func_00388340,func_00388378,func_00388388). Not a C problem. Retail has the same load-then-jump-then-use shape unpadded 19 other times, so thenopis not something a compiler or assembler adds: SN gcc's-Soutput has none, and neither GNUas(any-mips/-mcpu) nor Ps2EeAs inserts one. The five sit in the hand-written math range (0x3882D0 to 0x388388) between functions already classified as hand-written, so they areASM_FUNCnow instead of C plus an__asm__("nop")hack. -
Resolved: 64-bit constant synthesis
li 0x8000; dsll 24(func_00383B08): pass the constant as anunsigned longliteral with@ps2as. -
Resolved:
div.s/sqrt.snoppadding (func_003E1D18and 69 other functions). Retail pads mostdiv.s/sqrt.swith 0 to 3nops; no rule predicts the count, and both assemblers delete explicitnops in reorder mode.tools/asm_filter.pynow puts them back as raw.word 0lines, driven bytools/divs_nops.txt(one line per function:func_0037E568 2, one count perdiv.s/sqrt.sin order). Runpython tools/gen_divs_nops.pyto add lines for every function that is stillINCLUDE_ASM; existing lines are kept so a function keeps its padding after it is decompiled. If the number ofdiv.s/sqrt.sin your C differs from the table, the function is left unpadded. First function matched this way:func_0037E568. Functions that were already in C without the table are not in it. -
Resolved 2026-10-06: register allocation where retail keeps an argument in a temporary (
move $t3, $a0at entry) while$a0holds something else (func_0039BEC0): solvable in C. The cause is gcc's priority order (seetools/regalloc.py): a block-local temporary outranks the parameter and overlaps it, so the parameter is pushed out of its incoming register. Two changes reproduce retail's exact allocation forfunc_0039BEC0(screenId$t3, the-2constant$a0,result$t2,flag$t1): declareD_001A7430unsized (extern s32 D_001A7430[];, read asD_001A7430[0]), because retail reaches it with a compilerlui, and write the flag ass32 flag = (screenId >= 0) ? D_001D5B78[screenId] : 0;. That takes the diff from 1,680 to 210; what is left is the branch shape (which instruction lands in thebltzdelay slot). The forms that get the registers right changed the branch layout, and the forms that got the layout right changed the registers. It matched in the first final pass under@ps2as, with a one-element alias ofD_001DA050anddo { flag = 0; } while (0);(found with f2'sdo { } while (0)brute force). -
Resolved: "a match in isolation can still differ in the full build".
func_003AEDC8andfunc_003AED08passedtry_func.pybut failed the full build with two$gpstores swapped. The cause was the tool, not the build: it masked every relocated field, and two stores through$gpdiffer only in their relocated offsets, so the wrong order looked identical.try_func.pynow fills relocations in with the symbols' real addresses and compares them in full (checked against all 617 previously matched blocks: no false failures). The#define D_001D5B34 ...hack macros above some functions still replace a laterexternof the same name;#undefit in your block (seefunc_00389908). - About 20 matched functions use inline
__asm__or a hand-rolled$gpregister. They count, but plain-C rewrites of them are welcome. -
Init order before a loop: loop-invariant constants gcc hoists (a divisor
3, a&symbol) land between the loop variable's init and the other inits. To reproduce retail's order, name them as locals and assign in retail's order (i = 0; three = 3; sent = &sym; off = 0;, thenfor (; i < 3; i++)). Matched the three 3-slot hash lookups func_003E4890/4918/4DA0. -
A quad store that the scheduler moves behind a narrow store: make that one
sqstore volatile (*(volatile u128_t *)(d + 0) = ...) and keep the narrow stores plain; retail'ssq, sb, shorder then holds (func_0037FEE8). Also: a down-countingnthat retail keeps in the callee-saved register ahead of a copy ofq + 0x41DCwantsn--in the loop instead ofm = n - 1; n = m(func_003E0478). Ado { h = f(&s, 1); } while (h == 0);poll loop followed bydo {} while (g(h) >= 0);matches the retail pair of raw-branch loops (func_003AB180). -
D[idx].fieldagainst a cached pointer:&D[idx].f380folds the field offset into the symbol (lui; addiu D+0x380; addu idx*size), whilep + 0x380from a cachedp = &D[idx]adds it to the register. When retail shows an extra copy of the element address, or an argument built as(D + off) + idx*size, write that access asD[idx]...with a struct type for the element instead of reusingp(func_003809F0; also matched withu128_tfields for thelq/sqpairs). A 16-byte struct ofu8[16]assigned through a local gives theldl/ldr, sdl/sdrpairs (func_003E0930). Passing the callee's float arguments before its integer ones in the prototype moved amov.sinto the right slot (func_0038CA08).
Two shapes showed up while trying to match the smallest functions still filed as plain.
Neither is a C problem: if your target's .s contains one of them, move on to another
function rather than rewriting the C.
-
lq $at-$atas a data register. Compiling the same three-quadword copy (*(u128_t *)at 0x00/0x10/0x20) with the range's flags allocates$2,$3,$6;$atis not something this compiler hands to a value. Infrontbin.elfthe only files containinglq $atare the nine underasm/handwritten/, plusfunc_00388E58andfunc_00388E38, which are nowASM_FUNCthere too (2026-10-05:tools/triage.pyfileslq/sq $atand the COP0 performance-counter ops as hand-written). -
sq $zero- a 128-bit zero store.*(u128_t *)p = 0;,(u128_t)0and a namedregister u128_t z = 0;all compile topor $2,$zero,$zerofollowed bysq $2,0($a0): the TImode zero is materialised in a register first, so retail's singlesq $zerois out of reach from plain C. Resolved 2026-10-05 with theQZEROinline-asm macro (see the blocker pass below): the original used inline asm for it.
A third shape from the same pass, sq $31 / lq $31, has a workaround rather than a dead
end - list the function in tools/sq_ra_funcs.txt (Functions that save $ra with sq
above); tools/pr_check.py now warns when a function that is C in src/frontbin/ has the
signature in retail and is missing from that list. What the pass did settle is that no flag on this compiler produces it, which is
what the next section is about.
Reproduce any of them from the repo root:
python tools/try_func.py <your attempt>.c func_XXXXXXXX # the ordinary judge
python tools/try_func.py <your attempt>.c func_XXXXXXXX --all-modes # 4 flag/assembler combos
Two more things worth knowing before a long session: sizeof(long) is 8 here, so the
64-bit type is unsigned long (long long, and therefore u64, is 16 bytes and any
arithmetic on it fails with unsupported wide integer operation), and the literal suffix
ULL is rejected by this compiler - use UL or a cast.
The sq $31 layout is not only reachable through asm_filter: the SN ProDG 2.0
package's ee-gcc295.exe (gcc 2.95.2, SN build v2.73a - a separate download, not the
3.01 package this repo builds with) emits sq $31 / lq $31 by default, puts $ra in
the highest callee-saved slot with $s0 lowest, and does not know -fopt-stack at all
(cc1.exe: Invalid option).
Measured on the C bodies of func_0038C888 and func_0038C9D8, assembled and compared
word for word rather than read off the .s: 2.0's output for both is byte-identical to
retail, the jal operand aside, while 3.01 at the project flags differs in the two $ra
accesses only. Nothing in 3.01's flag space moves those two: -O1/-O2/-O3/-Os,
-G0/-G8/-G16/-G24, -mgp64, -mips3, -mips4 and -fopt-stack/-fno-opt-stack all
keep sd $31. On this compiler -fopt-stack only governs the $s slots (16-byte
sq without it, 8-byte sd with it), never $ra - which is why the layout the filter
reproduces was never a flag away.
The same test on the two larger matched functions, func_003AAF88 and func_003C0B10,
splits the claim in two. 2.0 gets retail's frame right with no filter at all: same
frame size, $sN in the 16-byte sq slot at 0x10*N, $ra highest - where 3.01 needs
-fopt-stack removed and the filter's rewrite to get there. But it does not emit them in
retail's order: retail saves the registers ascending by slot, 2.0 descending (on
func_003AAF88, 2.0 saves $s3, $s1, $s0, retail $s3, $s0, $s1). Those two bodies are
the C that makes 3.01 match, so a compiler swap would mean re-tuning them, and re-tuning is
what the filter avoids. 2.0 also emits the lq $at bodies above exactly as 3.01 does, and
the sq $zero body too (por $2,$zero,$zero + sq $2,0($a0), unchanged), so the package
is a stack-slot signature and nothing else.
One of those tests is done: func_003A9E60 (0x8C, reconstructed from its retail
disassembly alone, flags -O2 -G8 -mno-split-addresses -mno-check-zero-division plus the
unsized D_001D5C78[] declaration the range needs) comes out with retail's frame and slots
under 2.0 and with 3.01's layout under 3.01 - but both compilers leave the same 22 of 35
words different, all of them the argument-copy registers (a0 lands in $s1 where retail
uses $t5, and the lui takes $2 where retail takes $12). So 2.0 does not fix this
one's schedule either; that half looks like allocation, not a compiler version.
So the layout is a real version signature and the arithmetic is unsettled. It is four
functions, and only the two wrappers are byte-exact; nine of the 13 still have no C block
at all. Most of the file still matches 3.01 at the project flags, so retail only
possibly looks like an intermediate SN release - 2.95.3-era instruction selection with
the older 16-byte stack slots - rather than a mix of two packages. Writing C for more of the
13 and running the 2.0 package against them is the cheap next test; the compiler/flag
matrix in docs/compiler_matrix_findings.md (15 builds) does not include this package.
Each was confirmed by a function that now matches in the full build.
-
Pass the incoming parameter to a K&R call that doesn't need it. In
func_003B7568,cb(a)instead ofcb()raises the parameter's priority, which flips which of two saved registers it gets. -
Two loops, two counters. Reusing one
ifor both loops offunc_003B2958swapped$s0/$s1. A separatejfor the second loop, indexing a[2]-sized$gparray asD_arr[j], gives retail's pointer plus count-down loop (bgezl). -
Table walk with an end marker:
for (p = arr; *p >= 0; p++)givesmove; lw; bgez; addiuwith the increment in the delay slot (func_003D46E0). A do-while withv = *p++doesn't. -
lui $v0; lh $v1, %lo(X)($v0)(the load into a different register than thelui) comes from reading through an unsized array alias,X[0], not a raw*(s16 *)0x1CD018(func_003A3430). -
Reuse the incoming pointer as the walking pointer (
arg += 8; ... func(arg, arg + b, ...)) instead of newp/qlocals, which add amoveand change register numbers (func_003972A0). -
A select assigned to a new local (
q2 = q - w) changes which instruction fills thejaldelay slot (func_003ABB60). -
Two reads of the same
luiglobal around calls need a scalarextern s32 X;under@ps2as; an unsized array shares oneluiacross the calls (func_003ABB60). -
(flag >> 24)gives a baresrawhere retail has one; the callee's parameter order sets the order of float register setup (func_003A4DC8). -
Declaring a callee
s32instead ofvoidchanges the$v0/$v1choice around the call; a K&R zero-argument declaration leaves the argument registers untouched (func_003B8968). -
Index an array in a loop instead of walking a pointer (
func_003C1950):e[i].t,e[i].idlet gcc strength-reduce to a pointer whose start sits in a temp and ismoved into the saved register, as retail does. A hand-written walking pointer gets other registers. -
Count-down loops written
for (i = N; i != -1; i--), one counter per loop (func_003A9768,func_003A8060): this decides which register holds the-1constant. -
Store order sets delay-slot filling (
func_003A5A90): zero stores writtent[1],t[2],t[3],t[0]matched; ascending order did not. -
K&R definition when callers use an unprototyped
extern void f();with another argument count (func_003A5A90); a prototyped definition makes those callers fail with "too few arguments". -
64-bit values are
long/unsigned long, notu64/s64, or gcc reports an unsupported wide integer operation (func_003D14D0). -
Float arguments need a prototype for the callee, or gcc promotes them to double (
func_003A5DF0went from 33 to 63 diffs without one).
Each was confirmed by a function that now matches in the full build.
-
The callee's return type changes the caller.
extern void f();versusextern s32 f();(or the reals32 *) moves registers and scheduling even when the result is unused. It was the deciding change forfunc_003B0608,00397380,003C8D50,00384C98and003E0FC8. Try the callee's real prototype, ors32, before anything else. -
Size hints choose the address form of a scalar extern:
__asm__(".extern X, 4");before first use gives$gp;.extern X, 16gives the macroluiform even for a scalar; an unsized array read asX[0]gives the split%hi/%loform (func_003C8D50,003B0608,0039AAF0,0039C028). -
One variable, two address forms: when retail reads the same address through both
$gpandlui, declare two names for it and give only one the.extern X, 4hint (func_003968F8,00396120,00396680,0038EB10). This is one of the cases where an alias is a real technique, not a leftover. -
Adjacent
$gpobjects need separate names: an array base for several of them gets shared by gcc (batch 15). -
A switch tree with
slti: aswitchwith onlycase 1andcase 2givesbeq; beq; retail'sbeq 1; slti 2; ...tree appears when the switch also has acase 0:(func_003A6520). -
A one-element array declaration (
u8 D_X[1], or a sizedT D_X[2]) instead ofu8 D_X[]changes whether the address is built before the call or split around its delay slot (func_003E1F40). -
Calling a K&R extern with fewer arguments than it reads matches retail where it passes only some registers; adding the extra arguments adds instructions (
func_003BE418,func_0039C028). -
Reuse the parameter as the working pointer (
a0 = ...; a0[0] = ...) to keep it in$a0; a fresh local lands in$v1(func_0039C028). -
Early returns: a
bnelfollowed directly by the "return 0" block comes fromif (x == 0) return 0;, not from nesting (func_003822C8). -
Stores of different constants to one global in several branches: write the separate stores with no result variable; gcc tail-merges them into one
swwith retail's registers (func_00396680). -
Index a struct-array member each time (
D.row[a].f) rather than taking a row pointer once; the pointer form folds the member offset into the base register (func_00395AC8). -
Same family, different data: adapt a matched sibling and change only the callee and the table global (
func_003E1F40fromfunc_003E1E50). -
Extra compiler flags are a per-file question, not a per-function trick. The original build set flags per source file, so a flag a function needs must also leave every other function in its file matching. Check that before adding one: compile the file's other C functions with it (
try_func.py --flags="-fno-..."on each).-
-fno-force-mem(needed byfunc_0039A040): all 88 C functions in3958F0.cstill match with it, and 1069 of all 1123 do. It's the flag on3958F0.c's file line. It is not file-wide in practice:func_00397490,func_0039ABB0,func_00399C90,func_003959A8andfunc_0039A3B0need-fforce-mem(the default) and each has a single-function override without it (see thefunc_00397490section below; theor/adduoperand order shows it). So either the file boundaries there are wrong or the original did not set it per file. -
-fno-schedule-insns(now onlyfunc_003C8D50andfunc_00384C98;func_003AD650lost its override, see the blocker pass below): 524 of the 1123 C functions change with it, so it wasn't a project-wide flag. Around those two, the C functions that still match with it form runs of only 3 and 1, so each would have to be its own small source file. That is possible (our file boundaries are estimates), but unproven. They stay single-function overrides; if more neighbours turn out to need the flag, that's evidence for a small file there. A test on 44 scheduling near misses (tools/try_func.py --flags="-fno-schedule-insns"): 0 matched and 44 got worse, so the flag is not a general fix for instruction-order near misses. -
-fno-schedule-insns2was on: only 392 of the 1123 C functions still match without it.
-
-
A
longworking variable keeps amovzchain: withint, gcc foldsr = 1; if (x == -2) r = 0;intoxor/sltu; withlong(and alongreturn) it emits retail'smovzchain. -
t = &A; p = &B; if (a0 == 0) p = t;loads the two addresses in the same order as retail'smovz $a2, $a1, $a0; a ternary or if/else swaps them. -
Statement-order search pays off early: the order of a function's first few statements alone moved results between 5 and 46 differing words; a brute-force search over them matched
func_00394B38andfunc_00394F78, and a random-swap search over store order matchedfunc_0039C7B0andfunc_0039C8A8. -
forversusdo { } whilecan change which registers a loop counter and its copy get (func_003D98D0neededfor). Try both. -
A guarded hardware wait loop:
if (r & 0xF0) { do {} while (r & 0xF0); }withravolatilehardware register read gives retail's shape; a plainwhilegives the unguarded form (func_003AC5E0). -
Callees with float arguments: if retail loads
$f13before a call, the callee's real prototype has more float parameters than the call seems to need. Check its definition. -
No
#definein a function block:split_text.py --refreshcopies a block's macros into later files like any declaration. -
-fno-schedule-insnsnever helped a near miss: tried on about 25 near misses, it made all but one worse and fixed none. The remaining differences in near misses are register allocation and statement order, not scheduling.
A pass over the remaining INCLUDE_ASM functions, sorted by what each one needs. Each item below was confirmed by a function that now matches in the full build.
-
sq $zerois inline asm, resolved. The compiler never stores a quadword zero directly: a zeroedu128_talways comes out aspor $v0, $zero, $zero; sq $v0. Retail's baresq $zero, off(reg)(the 16-byte zeroing loops infunc_003A0010andfunc_003A2DF8) therefore came from an inline-asm macro in the original source, the same conclusion Lombyte'sqzero.hreached for other Insomniac binaries. Use#define QZERO(p) __asm__ __volatile__("sq $0,0x0(%0)" : : "r"(p))
An asm with no outputs is implicitly volatile, so the
__volatile__is just documentation; the"=m"output form is the only one that is not. Write the loop with the index in the address,QZERO(D_1A3100 + i * 0x10)fori = 3; i >= 0; i--, not with a running pointer: the running pointer makes gcc keep a secondluiin a saved register and grows the frame (func_003A0010). -
Which register comes first in
addu. For a C pointer plus an integer gcc always puts the pointer first in the tree, sobase + offgivesaddu $v0, $base, $off. When retail has the offset register first, write the sum as integers with the offset on the left:(u8 *)(off + (s32)b). For a loaded base, load it into a local first (g = *(s32 *)0x1D4B38; base = g + off;). Matchedfunc_0037F4F8(a VU0 function:lqc2/vadd.xyz/sqc2in__asm__around plainu128_tcopies). -
A
do { } while (0)macro changes scheduling. Wrapping the loop's increment in a statement macro,#define ADVANCE(o) do { (o) += 0x27E40; } while (0), reorders gcc 2.95's output enough thatfunc_003AD650matches at the project flags; it needed-fno-schedule-insnsbefore. Try this before reaching for a flag when the diff is only the position of an increment or a store. -
Hand-written code the tools now flag.
tools/triage.pyclassifies as hand-written any function that reads or writes the COP0 performance counters (mfpc/mtpc, which spimdisasm cannot decode) or that uses$atas the data register oflq/sq; no compiler emits either. Seven functions moved toasm/handwritten/on that rule:func_0039BC18,0039BC40,0039BC70,0039BC80,0039BCC8(performance counters) andfunc_00388E38,func_00388E58($atquadword copies). -
Keep one prototype per callee across files. The split build copies declarations from earlier files, so
extern void func_13B620();in one file andextern s32 func_13B620(void);in another is a compile error inpr_check.pyand the file check oftry_in_context.py. Fix the older declaration (it wasvoidonly because that file ignored the result) rather than adding an alias.
The memory card state machine in 3958F0.c, the largest function that was still INCLUDE_ASM: an outer 32-way switch on the state field and seven nested switches (9 jump tables, now emitted by gcc). Matched at @ps2as without -fno-force-mem.
-
-fno-force-memshows as operand order.D.f188 += xcompiles toaddu rd, f188, xin retail. Under-fno-force-mem, gcc'sexpand_binopswaps commutative operands while the first one is still a memory reference, givingaddu rd, x, f188. Dropping the flag for this function took it from 280 to 238 aligned diffs. -
An address-taken small-data symbol under
@ps2asis onelamacro. DeclaringD_001D545Cas a scalarextern u32(its real type) instead of an unsized array gave retail's register allocation:lais one instruction (one pseudo) to gcc, the split%hi/%loform is two.&D_001D6318in the same function is reached through$gp, so it gets__asm__(".extern D_001D6318, 4");. That pair is what identified@ps2as. -
Where retail recomputes the global base (
addiu $X, $s2, 0x2430) only after a join, the source has a real if/else join; where it reuses the case's base register, the source has separate exits (duplicate tails ending inbreak) that gcc tail-merges. Write the struct accesses directly (D.field), no local pointer. - Cross-jumping keeps the later copy. An earlier duplicate tail becomes a jump into the later one, so when retail jumps forward into another case's code, both tails must be byte-identical (same store order and registers).
-
Error-code chains store the field in every arm (
if (r == -5) D.f16C = 0x15; else if ...). Anetemporary turns the last arm into amovz. -
Reset-store order: retail's
f154, f15C, f14Ccomes from source orderf15C = 0; f14C = 0; f154 = 0;(the store rotation documented above). Most of the last 150 diffs were store order. -
No shared temporaries across the cases of a big switch. One function-level
s32 treused in several cases pushed values into callee-saved registers everywhere; making them block-local took the diff from 544 to 278. -
Strength-reduced loop pointers: retail keeps three pointers into a 0x40-byte table (
entry,entry+0x20,entry+0x25). That came fromde = &tbl[i]forde->size/de->name[4]plustbl[i].namefor the string arguments; other combinations gave two pointers. - Smaller ones:
func_11B2E8(sprintf-like) must returns32, andfunc_0038E6D0is called through ans32cast, to keep$v0live. Atotal + 1that retail evaluates first needs its own temporary, since gcc reassociatestotal + 1 + q. -
Tooling for very large functions:
try_func.py's positional diff is unusable until the size matches. An aligned diff (difflib over the disassembly, branch targets mapped to labels) made progress measurable from 883 diffs to 0.
Each was confirmed by a function that now matches in the full build.
-
Check drafts with
try_in_context.py, not onlytry_func.py.try_func.pysees the extern declarations of earlier blocks but not their definitions, so a callee defined earlier in the file becomes an implicitintand gives false diffs (func_003D1650went from 23 diffs to MATCH in context;func_0037F588). -
GS/GIF packet writers:
long **ppwith*(*pp)++ = value;under@ps2asmatches quickly. If callers declare the first parameters32, define its32and cast inside (func_003D1E68). -
An earlier block's
.extern X, Nhint forces$gpfor the rest of the file. Where retail usesluilater in the file, use a per-function alias with no hint. Ps2EeAs still uses$gpin delay slots (func_003D2878). -
A volatile read steers delay-slot filling, since gcc keeps volatile loads out of delay slots (
func_00380AB0). -
Write symbol+offset constants inline so related-value CSE picks retail's anchor register (
func_00381F18). -
$sregisters saved withsqbut no$ratake the same fix as the sq-$rafunctions: an entry intools/sq_ra_funcs.txtand no-fopt-stack(func_003D1978,func_003D3780). -
Rebase: a
u8-array access toX + offfollowed by stores through a local struct pointer gives retail'saddiu $s0, $s0, -off(func_003B8168). -
Chained assignment
a = b = f()gives retail's store order (func_003BA490). -
Ps2EeAs fingerprint: it expands the constant
1 << 63asaddiu -1; dsll32 31(func_003B82C0). -
plzcwhas no C form:__asm__("plzcw %0, %1" : "=r"(d) : "r"(d)); d = 30 - d;(func_003D1650,func_003D1E68). -
A dead 16-byte stack copy is an aligned 4-float struct local, not a volatile
u128(func_003CB748). -
Open question:
func_003CB5B0declaresD_001D9350ass32[2]with.extern D_001D9350, 8to get the$gpform, but retail reads 16 bytes from it withlq. The 8 steers codegen and is probably not the real size. -
Fixed (batch 29): the sq-
$rarewrite inasm_filter.pyused to treatld $16/$17stack-argument loads as restores. It now counts only loads of a saved (register, slot) pair, andfunc_00386D98matched.
Status (2026-10-07): the leading explanation for VU0 code that is interleaved with ordinary code, and kept in the build. The maintainer has not formally approved it yet: each such block starts with a PROVISIONAL, VU0 j-constraint form comment, its override line in tools/text_parts.txt says PROVISIONAL VU0 j-constraint form, and agents commit each one separately as func_X (jform) so it can be dropped. grep -rn "PROVISIONAL" src/frontbin tools/text_parts.txt lists them. 38 frontbin functions are matched this way (first func_003DCD08, func_003C0188, func_003813E0; then 27 in round d and more in the final passes). Flag any new use in the PR.
Each VU0 instruction is its own non-volatile __asm__ statement. VU0 values are TImode variables, and their operands use the j constraint, which is SN cc1's VU0 register class:
typedef int Q_3DCD08 __attribute__((mode(TI)));
typedef struct { f32 x, y, z, w; } __attribute__((aligned(16))) V4_3DCD08;
Q_3DCD08 x, y, r;
__asm__("lqc2 %0, %1" : "=j"(y) : "m"(D.v0));
__asm__("lqc2 %0, %1" : "=j"(x) : "m"(D_001D9E90));
__asm__("vsub.xyz %0, %1, %2" : "=j"(r) : "j"(y), "j"(x));
__asm__("sqc2 %1, %0" : "=m"(*(V4_3DCD08 *)b) : "j"(r));gcc allocates the $vf registers and its scheduler moves ordinary instructions between the VU0 ones, the way retail does. A volatile asm is a scheduling barrier in gcc 2.95, and a non-volatile sqc2 asm without a register input ties with a neighbouring move and keeps source order; with j operands the sqc2 depends on the vsub result, so the move goes first, exactly as retail (func_003C0188: the best volatile form stops at 2 diffs, the j-form matches). The one-block volatile form in VU0 code: inline asm still matches where retail keeps the VU0 sequence together; try the j-form when retail has argument moves, constants or lq/sq copies between VU0 instructions.
- gcc prints the registers as
vf1(no$); Ps2EeAs accepts that. - The
"m"operand's type decides the address form. A struct of four floats (BLKmode) gives retail's split form,lqc2 vf1, %lo(D_00222480)($16)with the%hikept in a saved register. A TImode operand gets its full address in a register (addiu; lqc2 0($r)). A small-data scalar (extern f32 D_001D9E90;) is printed as the bare symbol and Ps2EeAs expands it with$at(func_003DCD08). Vectors that arelqc2operands on the stack need__attribute__((aligned(16))). - A
jvalue that is live across ajalis spilled withsqc2/lqc2to the stack (vf1 to vfN are call-clobbered, there are no callee saves), which is where many of retail'ssqc2/lqc2to$spcome from. - Struct arguments passed by value are passed by reference under EABI and copied by the callee. Retail's
sqc2into a temporary before such a call is written as ansqc2with an"=m"output into a local, then a by-value call (func_00383BB0).
-
Statement order matters, and so does which variable each temporary and result uses. Write the statements in retail's order. Give each
vsuba fresh output variable rather than writing the result back into an input (func_00383BB0:y0,y2for the two differences of the cross product).func_003DCD08needed a separate result variable per operation in one case and the result in the first operand's variable in another; try both. -
The load order decides the vf1/vf2 roles. When the same VU0 sequence appears with the roles swapped, use two
static __inline__helpers that differ only in which operand they load first (vaddA_383BB0loadsathenb,vaddB_383BB0loadsbthena;func_003869E8's helpers loadathenb). -
Block-local temporaries let repeated blocks get different registers, the way retail's do (
func_003B92B8,func_003B9DA8).
SN's cc1 only accepts j with the target option -mvu0-use-vf0-vfN; without it any j operand fails ("fixed or forbidden register ... for class VU_REGS"). Since 2026-10-07 every frontbin and boot_elf line in text_parts.txt carries -mvu0-use-vf0-vf2, so the j-form needs no extra flag. What agent m1 found (round k):
-
Origin. The cc1 help text is "Specify the range of vu0 registers to use", and the cc1 binary's build path is
C:\usr\PS2\gcc-2.95.3\gcc: it is Sony's own gcc patch set, not an SN addition.jselects register classVU_REGSand is the only VU0 constraint letter SN has; there are no other-mvu0-*options. -
Parsing. The text after
vf0-is compared with the namesvf0tovf31; on a match vf1 to vfN become allocatable. Anything else (vf32,vf99,31,abc, empty) silently means vf31.vf0-vf0gives no registers, so everyjasm fails. Onlyvf0-is accepted as the lower bound (-mvu0-use-vf1-vf8is rejected). vf0 is always fixed. -
It never makes the compiler use VU registers on its own: TImode copies stay
lq/sq. Explicit"vfK"clobbers work with or without it. -
Other compilers. Sony's Windows builds (2.9-ee-991111 Win, 2.95.2-273a/274, 2.95.3-107/114/136) accept the option and
jwith identical behaviour. 3.2-040921 has a rewritten version (-mvu0-use-REG1-REG2, classVU0_REGS, lettersjandCplus Q/R/Y/Z for the VU0 special registers). The Linux builds (990721, 991111-01, -dtls, 991111a, 2.96) reject it. -
N changes ordinary code. The range adds N to
n_non_fixed_regs. In GCC 2.95.3's loop.c,scan_loophoists invariants against a threshold of(call ? 1 : 2) * (1 + n_non_fixed_regs)andstrength_reduceuses(call ? 1 : 2) * (3 + n_non_fixed_regs), so N moves loop-invariant hoisting and induction-variable reduction in functions with no VU0 code at all. Examples:func_003BAE98has 122 diffs with vf31 (i < 2is hoisted out of the inner loop) and matches with vf2;func_003A1438has 234 with vf31;func_003D3FA0stays at 4 with vf31 and matches with vf2. Old drafts measured with vf31 or without the flag must be re-measured. -
Only N = 2 fits everything. A sweep of the 38 frontbin j-form matches over N = 1 to 32: 31 match at 2 to 32, three only at 2 (
func_00383BB0,func_00392A40,func_00392DD8: at N >= 3 the allocator uses vf3 where retail has vf2),func_003A1438at 2 to 24,func_003BAE98at 2 to 28,func_0039EB98at 1 to 32. N = 1 can't compile 37 of them. -
Global builds. With the flag on every line: at N = 2 only
func_003B22C0(and its boot_elf twinfunc_003B7A80) break, two prologue constants hoisted in swapped order; at N = 3 or 4 five functions break, includingfunc_00381A50, which has no VU0 code; at N = 31, 22 break. i5bootn matches at every N.func_003B22C0matches under vf2 once the struct global is used directly instead of through a local pointer (D_00160C40_003B22C0->f4954, (s32)D_00160C40_003B22C0->f7080in the call, noS_B22C0 *plocal); that form matches at no flag, vf1 and vf2 and fails from vf3, another N <= 2 bound. The same edit fixedfunc_003B7A80. - Retail evidence. All compiler-allocated VU0 code in retail (37 matched j-form functions and 13 j-form near misses) uses only vf1 and vf2; hand-written code goes up to vf31. The functions that match only at N = 2 show the allocator really had just two registers.
-
What the source probably was (m1's guess, not proven): SCE or Insomniac vector macros or inline functions written with
"j"operands, compiled everywhere with-mvu0-use-vf0-vf2.
Adding the flag to a line. It is placed first on lines that did not have it before. Appending it instead changes which neighbouring lines have equal flag lists; build_text.py merges runs of equal lists into one slice, which exposes declaration conflicts (the first one is func_003A0D58 in 39FFC8.c). When you add an override, copy the neighbouring line's flags as they are written.
Each was confirmed by a function that now matches in the full build.
-
sq $0as non-volatile asm with a memory output,__asm__("sq $0,%0" : "=m"(*(u128_t *)(p + off)));, schedules like an ordinary store. The volatile"r"form ofQZEROblocks scheduling around it (func_003BD668;func_003B7B50went from 43 to 5 diffs). Use the"=m"form when retail moves other instructions across the store. -
Adjacent
@ps2asoverrides merge into one slice, and an earlier block's.externhints then reach the later functions of that slice. After adding an override, run the full build, and fix any function it breaks with per-function alias names (func_003D2F90, afterfunc_003D2878's override).try_in_context.pychecks only the target function, so only the full build catches this. -
A loop-invariant constant that retail adds with
addu: declare it as alonglocal before the loop and add(s32)of it:long base = 0x590000; id = i + (s32)base;(func_003B3DB8). -
adduoperand order: read back the field you just stored,p->h0 = c; p->h2 = p->h0 + p->hE;(func_003A6C30). -
A nested select's branch layout: put the inner test's xor in its own block-local temporary,
else { s32 e = k ^ 0x259; m = e ? 0 : 8; }(func_003B8440). -
The callee's return type again: declaring
func_12A9F0ass32instead ofvoidfixed a$v0/$v1swap in a final 64-bit pack (func_00393E90; its siblingfunc_00393A18needs the same). -
An indexed loop with two increments,
for (i = 0; i < n; i++, list++), matched where a single induction variable didn't (func_003936A8). A second index incremented in a call argument,f(o, 0, i++)next to loop counterj, is the same lever (func_003B07B8; usingjthere left 159 diffs). -
Integration note:
tools/divs_nops.txtis keyed by function. When a patch changes a function'sdiv.spadding count, that line has to travel with the function; a stale count makes the function longer by 4 bytes pernopand shifts everything after it (func_0037D200, 2 to 1). -
Tool note:
try_in_context.pyignorestools/localdecomp_flags.txt, so an@ps2ascandidate can only be checked in place once itstext_parts.txtoverride exists.
From the agents' reports and near-miss headers; each was found while matching or nearly matching a large function.
- One C variable reused for unrelated values decides close register ties.
-
Callee prototypes decide scheduling: float/int parameter order and
longparameters change the caller; when unsure, try every order. - Declare temporaries inside the one branch arm that uses them.
- Loop invariants hoisted by the second loop pass take the first callee-saved registers.
-
Constants fold into adds but not multiplies (
i * lhkeeps its register). -
Many "mmi" bucket functions are plain C: their 128-bit ops are
-fcaller-savesspills of ordinary values, not MMI code.
Each was confirmed by a function that now matches in the full build.
-
Read the assembler off the retail bytes. No
nopafter anmfc1whose result is used by the next instruction means Ps2EeAs (@ps2as).func_0038BB50(0x86C) had stalled for several batches on the default assembler; with@ps2asit matched. -
A
do { ... } while (0)around a loop changed loop alignment and register weights enough to finishfunc_0038BB50, with no empty asm needed. -
Two stores to the same object can be written in either order; try both (
func_00386D98: theq+0x10store comes afterq+0x48). -
Struct-member reads vs cast-pointer reads. This gcc runs without strict aliasing, so a load through a cast pointer is untyped memory and the scheduler keeps stores to plain globals after it. Struct-member loads don't conflict with stores to fixed scalar globals. Reading a header through
struct { s32 x[0x27]; }instead of((s32 *)hdr)[i]was the biggest gain onfunc_003B6528. -
Float constants: write the exact decimal of retail's bits, including truncated ones (
0.0749999955fis0x3D999999, not0.075f) (func_003A0EB0). -
decomp-permuter is useful once its score is replaced with
try_func.py's own metric (relocations resolved against retail). Its default score trackedtry_funcdiffs poorly; with the replacement it improved 9 of 11 near misses in under an hour. Check the semantics of its rewrites before building on them.
From the agent reports of the 19-agent round (d), the two final passes (f, g), round k and the large-function batches. Each item names the function that proved it; "near miss" means it was found on a function that still doesn't match.
These produce retail's bytes and are in the build, each in its own commit, but the maintainer has not approved them. Mention them in the PR if you use one; prefer a plain-C form when you find one.
- The VU0 j-form (section above).
-
Empty asm barrier,
__asm__("" : "+r"(e));on a computed store pointer: stops combine folding a base offset (-0x1C0) into thesq(func_0037F7A8). A volatile store also blocks the fold but stays out of delay slots. -
cvt.w.s/cvt.s.was two one-instruction asm statements (func_003A4860,@ps2as). Retail keeps(s32)tin an FPU register fort - (f32)(s32)t; SN gcc always converts through a general register (mfc1/mtc1), and no other place in retail does this. -
A non-volatile one-block VU0 asm with explicit
$vfregisters and"m"operands (func_003C0E10). The volatile version is 8 diffs, all in the prologue save order: a volatile asm cuts the prologue scheduling region, so retail's early register saves need the non-volatile block. -
One-block VU0 asm with
"m"operands of a 4-float struct instead of"r"pointers (func_003E0190): gives retail's%lo(D)($hi)addressing, where a 128-bit operand puts the full address in a register.
-
Constant-address globals:
*(T *)0xADDR. Spot them by a retailluiof(0xADDR >> 16)with no relocation. The access is one 2-word macro that reorg never puts in a delay slot and that never uses$gp. It replaced every volatile alias in the drafts (func_003BB398,func_003BA108,func_003B7EB0,func_003BA748,func_003B8F50,func_003BBFC8); infunc_003BB398/func_003BA108it let reorg produce retail'sbnel ... sltu. An earlier example:*(u8 *)0x1D5477(func_0039DB38). -
Declared size picks the address form, in both directions. Under
@ps2as, an object declared 8 bytes or smaller (u8[8],s32[2], afnptr[2]table, or a one-element aliasT X_f[1]) is onelamacro that Ps2EeAs expands to adjacentlui/addiu, even for a large table reached through an alias (func_0037F0D8,func_0037E878,func_003B8F50,func_003B5F88,func_003B6F28,func_003B8440,func_00382458). Thelais one pseudo, so it also changes local allocation. Because it is one insn, cse or loop can hoist it far from its use; when retail builds the address right at the use, declare the object larger instead (func_003A1AD0). -
A scalar alias, not an array, fixes load order when one load must stay a single macro (
func_003A2028:extern E *D_001DA51C_3A2028;). -
A buffer reached from two bases: make it a struct member. When retail addresses a buffer directly (
sym) for some accesses but through a register holdingsym - Kfor others, declare a struct atsym - Kwith the buffer as a member at offset K:typedef struct { u8 pad[0x7F00]; u8 buf[0x100]; } S;at 0x2224A0 for the buffer at 0x22A3A0. Plain writes to the buffer, or au8array at the lower address, get the base wrong. A localu8 *h = D.buf;then kept the hoisted+0x7F00register (143 diffs without it) (func_003AB3A8). -
Each single-function override needs its own
.extern X, 4hints inside its block (s5's batch). -
A
$gpload in a short poll loop used to makeasm_filter.pygive up on the loop (Ps2EeAs then padded it to 7 words). Since round k the filter counts a load or store of a symbol with an earlier.extern SYM, N(N <= 8) as one word, so write the loop plainly (func_003B8840). The older workaround, the default assembler plus two names for the global (an.extern X, 16alias for thelui/lwmacro, the sized name for$gp), is whatfunc_003AD288andfunc_003AFF20use. -
Unreferenced rodata after a jump table. When converting a switch function whose
jtbl_*.salso holds data words no code references, write those words as aconstarray right after the function (a definition, but in.rodata, where retail has those words), so they keep their place when theINCLUDE_RODATAline goes:const u32 D_00318A18[42] = {...};afterfunc_003BC218(0x318A18 to 0x318ABC). With that, the build differs only inside the function itself.
-
Implicit-int callees in override slices. A function with its own override slice can't see same-file definitions in other slices, so every same-file callee needs a prototype in the block. Without one the call silently becomes
int f(): float arguments go as doubles and$v0stays busy.--flags=-Werror-implicit-function-declarationontry_func.pylists them. Declaring the callee alone tookfunc_003A0EB0from 190 to 78 diffs; several old drafts had been measured with implicit-int calls (func_0039C54827 to 25,func_0039EE68411 to 398). -
Extra callee arguments. A value retail keeps in an argument register for no visible reason can be an extra argument of the callee:
func_003E8FB0(thef44virtual call takessthird),func_0039E4B0(a third argument tofunc_12F9E0),func_00394A20(func_00394660reads a 6th argument from$t1). Read the callee before trusting a caller's argument count. -
Ignored callee arguments still set registers. When retail loads argument registers the callee ignores, pass them through a per-function alias prototype:
func_0037DCF0_0039A3B0(a0, a2)(func_0039A3B0). -
Pass-through arguments. When retail keeps a copy of the first argument in a higher argument register, the function has extra parameters it passes untouched to its callee:
f(p, b, c)callsg(p, b, c)(func_00135E40, boot_elf). -
Stale or missing arguments. When retail never sets an argument register, the source passed the stale incoming parameter (
func_003E8590(o, ...)infunc_003EA078) or nothing at all (func_003E16B8()infunc_003DFE10). -
Int/float order in prototypes, both the callee's and your own. Ints and floats get registers independently under EABI, so reordering a prototype keeps the ABI but changes the order (and schedule) of the argument moves. When only argument moves are misordered, sweep the interleavings: one of 35 matched
func_003A0EB0, 60 were tried forfunc_0037D200's 12-parameter callee, andfunc_003813E0,func_003A7090,func_003B16B0andfunc_003DBEC8needed their callee's floats or ints moved. The function's own parameter order works the same way:f32 gbefores32 ngavefunc_003A0D58retail's$f24/$f25,f32 f0beforea3matchedfunc_003BA5B8. -
Take a callee's parameter order from another matched caller when the callee is still assembly and callers disagree (
func_003BBFC8, via an alias offunc_00389FB8withfunc_0038BB50's order). -
longlocals and parameters. Along x = (s32)scr[0];passed to ans32parameter gives retail'sdsll32/dsra32truncation pair after the next call (func_003813E0); alongresult passed to alongparameter avoids that pair (func_003B7378). Along5th parameter moved| 0x400000into retail's slot (func_003BC568). -
Declare operator new as
malloc.func_003ECDC0is operator new (size, heap). gcc gives a function namedmalloca result that aliases nothing, so declaring it inside the function asextern void *malloc(s32, s32) __asm__("func_003ECDC0");lets the stores that fill the new object move past the store of its pointer intop->f2C, as in retail. Under its own name gcc assumes the result may aliaspand keeps those stores in source order;__attribute__((malloc))on the plain name has no effect with this compiler (func_003EB180,func_003E89F0). -
A 64-bit return type on a callee whose result is unused. Declaring
func_13CDF0as returninglong(aliasfunc_13CDF0_003A21C0) gave retail's$v1choices infunc_003A21C0; withs32orvoidlocal-alloc picks other registers. Try it alongside thevoid-to-s32change forfunc_12A9F0(func_00393E90) when only the registers around a call differ. -
K&R
s16parameter. A K&R definition with ans16parameter stays compatible with an(s32, s32, s32)prototype and gives(s16)(kind - 1)assll/srafor a switch index (func_0039A3B0).
-
Hide a value from combine. In
e = A; for (i = 0; ...), combine knowse's sign and turnsblezintobeqz.i = 0; e = A;hides it (func_003B5D10). -
Operand order sets local-alloc ties.
combine_regsties an output to operand 1, sok = C; k *= t;(constant as operand 1) gives retail's register wherek = C * tdoesn't (func_003A73A0). -
gcse constant propagation of float constants. gcse's cprop copies a float constant into call-argument moves in other blocks whenever its set is available on entry, so
1.0gets rebuilt at every call, where retail keeps it in$f22/$f23. Assign the constant inside the loop, in the same basic block as its use: gcse doesn't propagate within a block, cse keeps the register, and loop.c hoists the set to the preheader (func_003AF2C0,zero = 0.0f; one2 = 1.0f;in the loop's else arm). A second, dead assignment does not help here, because availability still holds. -
A dead second store keeps a constant inside the loop. A block-local constant with a second, dead assignment has two sets, so loop.c won't hoist it:
{ s32 lim = -1; if (i <= lim) i = 3; lim = 0; }reproduces retail'sli -1rebuilt in the loop body (func_003B23F8). -
Loop constants and clamps. A loop constant retail keeps in a callee-saved register is a variable declared before the loop (
s32 one = 1;,func_003947B0); the literal is neither hoisted nor kept. A clampc = K - x; if (v >= c) v = c;needs a block-local limit variable to get K's register (func_0039FBD8). -
Separate variables for identical constant arguments.
k1 = 0x98; k2 = 0x98;passed as two stack arguments stay two pseudos into sched1, so eachlihas one dependent and no longer outranks other instructions (func_003D1D40). In the same function, writingw2 >> 6inline in the call instead of a precomputed variable moved the operand's last use and fixed the stack-argument store order. -
An empty volatile asm orders two loads.
b = e->f0; __asm__ __volatile__(""); h = e->f38;keepsb's load ahead ofh's, sob's live range overlapse's andbtakes retail's$v1(func_00391E78). In the same function, the constant 2 that a byte is compared against landed in retail's$a0by sharing a variable with the id later passed tofunc_0038ED78(id = 2; if (b == id) { id = D_001D5EA0; ... }). -
Anti-dependence ordering by reusing one variable.
t = G; H = t; t += 0x10; G = t;keeps the first store ahead of the later ones (func_00384B68). -
Two sched1 tie-breakers decide most remaining store and load orders (g4's reading of the scheduler dumps,
-dS -dR): sched1 prefers the instruction that ends a register's life, then the one with more dependents (func_003E89F0,func_003C9078,func_003EB180, all near misses).func_003C8ED0matched once the pointer arguments of both calls were computed into variables before the first call, in retail's load order (c = b + e->h1C; a0 = b + e->h18; d = b + e->h1A;, withcanddused only by the second call): settingcanddafter the first call, or writing them inline, gave 93 diffs, and any other order of the three (ora0inline) gave 6 or 7, in the order of the loads andaddus and their registers. Compare the store-rotation note under "Codegen tricks that matter". -
New variables after calls. A new variable that doesn't cross a call keeps its computation after the call; sched1 hoists the sets of pseudos that do cross calls (
func_003869E8). -
Moving one load decides the
jal-slot argument.h = o->f60moved after anif (r) {...}block fixed which argument move lands in the delay slot (func_003EAAD0). -
A store written before the last call fills its delay slot (
func_003AA0B8). Halves computed into new variables before a call (h2 = hh >> 1;) give retail'ssrabefore thejalplus one in its delay slot (func_003BC568). -
A load retail repeats is not CSE'd because of a mode difference or a block boundary:
w = e->bC; w -= 6; if (w < 0) w = 0;as three statements kept retail's separate load (func_00384EC0).
Wrapping one statement or a range in do { ... } while (0) (or for (;;) { ...; break; }) changes more than it looks:
-
Reference weight. flow.c adds the loop depth to a register's reference count, so each wrap (or nested wrap) makes those uses count more in register allocation.
do { do { d = D_0037B7D0; } while (0); } while (0);lifted a table base above the loop counter (func_003B0A60, 12 to 2); wrapping the loop'seload and update fixedfunc_003A2028; wraps matchedfunc_0039BEC0andfunc_00391B70,func_003E98B8(permuter find, probably a macro) and moved a prologueaddiuinfunc_00121D70/func_00121DB0(boot_elf). f2's scriptsdwrap.py(single lines) anddwrap2.py(brace-balanced ranges) brute-force the position. -
Scheduling barrier. Its loop notes stop the scheduler moving instructions across that point. They cut CSE as well, so a high half shared across the barrier gets recomputed (
func_003A21C0, near miss). -
Side effect:
.p2align 3. A label right after the loop-begin note is aligned, which can add a paddingnop(also at the continue label ofdo { call } while (0)). Wrap only the range that needs it. - Around a loop, it makes loop.c hoist invariant addresses into
$sregisters (func_00383FD8, near miss) and can change loop alignment (func_0038BB50).
-
Global-alloc priority is
refs * floor_log2(refs) / live;allocno_comparetruncates it to an integer (x10000) and breaks ties by the lower pseudo number.tools/regalloc.pyshows it. Splitting a statement raises a variable's refs (f1 onfunc_00389D18); an extra dummy pointer with an initializer, used once after the loop, lengthened live ranges by 2 so two constants tied the way retail's do (func_003B16B0, a permuter find). -
Every pseudo is an allocno, local ones included. A local's hard-register preferences can push a global off
$a2/$a3in global.c's first pass (func_003AC6F0, near miss). Global-alloc also merges the preferences of a pseudo that dies in an insn into the pseudo that insn sets, so a function-scope temporary inherits a register from whatever it dies into; declared block-local in each arm, it is a local pseudo and reuses the dying input's register (func_003D2878, per-armplzcwresults). -
Loop invariants hoisted by loop.c's second pass get the highest priority, because they are hoisted closest to the loop. A movable with a short lifetime fails the first pass's
threshold * savings * lifetime >= insn_counttest; shortening one constant's lifetime (h = D_001D8B0C - 0.007f;before the call) moved it to the second pass and to retail's$f23(func_003B16B0).-dLshows the loop dump. -
Shared versus block-local variables in big switches. Shared function-level temporaries push values into callee-saved registers (block-local per case fixed
func_00397490andfunc_003B2F50). But where retail keeps one stack address or value in the same saved register across cases, it is one function-level variable reused (func_003DBEC8), and values that share a register are often one variable (cx = a->f10; cx = (b2 - b0) * cx + b0;,func_003EC960). Separate variables instead of reuse matchedfunc_0037FF90. -
Parameter copy placement. A copy made right after a parameter's first use (
d = x1 - x0; x = x0;) keeps the parameter in its incoming register (func_003D14D0). -
Stack slot order follows when a variable becomes addressable, not its declaration: gcc 2.95 moves a scalar to the stack when it first sees
&x, while arrays get their slot at the declaration. Oneu8 cc[2]array declared first, instead of twou8scalars, put the bytes at retail's 0x20/0x21 (func_003C0188, 198 to 62). -
Function-level locals for stack buffers. Block-local arrays get separate slots in gcc 2.95; retail shares slots between cases or loops, so declare them once at the top (
func_003DCD08,func_00382458,func_003A4A78). -
Inline helper parameters. integrate.c copies every non-const formal into a fresh pseudo before the body, so the order of a
static __inline__helper's formal parameters sets the schedule of those copies (func_003EB728, found by a climb over parameter orders). -
Float constants named as locals. Two float constants stored into a stack struct, declared as initialised locals at the top (
f32 c1 = 0.572f;, thens.f1C = c1;), gave retail's$fregisters; writing the literals in the stores left 17 diffs, all$f/$aregister numbers (func_003B07B8).
-
Branch versus conditional move. gcc only turns
if (a < b) a = b;into a conditional move when the source value is already in a register at that point; a memory source keeps the branch (from thefunc_003921D8analysis).if (x != -2) r = 1; else r = 0;goes through jump.c's conditional-move conversion and keeps the constant 1 in a register a later cmove shares (func_0039BF98).if (a != b) c = f(..); else c = a;putsmove c, ain thebeqdelay slot (func_0039FBD8).if (x) c = f(A); else c = f(B);gives a branch where any selected-id form givesmovz(func_003B2AF8). -
Selector type sets if/else layout. An
s32selector givesbeqwith the then-assignment in its delay slot; au8(or other narrow) selector givesbne/b(func_003B8440). -
Cross-jumping keeps the later copy (see
func_00397490). To stop it merging duplicated tails, use block-local temporaries per case and agotointo the shared tail (func_00391B70), or reorder one arm (func_003A4A78). To get a merge retail has, write the shared body out in each case (func_003A73A0,func_003C79F0) or end both arms the same way and let gcc merge them (func_003C0188's two shared tails). -
An instruction duplicated into both arms comes from after the if/else. When retail has the same
sllat the end of both arms (in thebdelay slot and in the else arm), reorg filled the delay slot with the join block's first instruction: write the shift once, after the if/else (if (a1) s = h; else s = h*0x73/0xA0;, thens * 8in the uses). Writing<< 3in each arm left 13-16 diffs (func_0038CC68). -
A dead test whose arms do the same thing. When retail loads and tests a flag but both paths then run the same code, write the test with two identical arms:
if (D_001D5BDC == 0) D_001D888C++; else D_001D888C++;. gcc merges the arms but keeps the load of the flag (func_003AE8A8, case 22). -
Switch trees.
case 2: default:gave retail'sbeq 1 / slti 2 / bneztree (func_003813E0); an extracase 3: break;picks another root (func_003DCD08,func_003DBEC8). -
Error-code chains (
func_00397490, above) and block order: writing the== 0or bad case first gives retail's layout in several boot_elf functions:if (D == 0) return f(); ...(func_0013CEB0,func_0013CF40),if (i < 0 || i >= n) return f(p); return ok;(func_001222A8). A single result variable,if (f() != 0) r = D; else {...} return r;, fixed aluiregister (func_0013A930,func_0013A880,func_0013A988), andif (call) r = 0; else r = 0x800;keeps a constant out of$s0where setting it before the call doesn't (func_001223B8,func_00122520). -
Loop shapes.
- A loop that re-reads
self->fieldeach pass is left alone by loop.c, which is why retail reloads the bound and indexes withsll/addu(func_003E8838). - Count-up loops that gcc would reverse:
i = 0; if (n > i) { do { ... } while (...); }(func_0038E030, permuter find). - A goto loop with the test wrapped in
do { } while (0)gives a rotated loop with duplicated exit tests and an unconditional back branch (func_003A54A0). An early-exit test at the top of the body is a rotatedforcondition:for (i = 0; i < N && !(test); i++)(h05's batch). -
for (...; i++, q += 0x20, fl++)puts loop.c's derived-pointer increment right afteri++(func_003C9078, near miss). - Separate block-local pointers for separate loops (f2), and one counter per loop (
func_003A4A78,func_00393878). -
-fno-rerun-loop-optas a single-function override kept an up-countingsltiloop that the second loop pass reversed (func_00394060); its neighbourfunc_00393C98needs the second pass, so it can't be file-wide.
- A loop that re-reads
-
*(*pp)++ = v;withs32parameters widened by(long)x << nat the use (func_003D3148, 147 to 0, then four siblings). -
One pointer variable per step when retail repeats
p = D_001DA0D0; D_001DA0D0 = p + 0x10; ...blocks (p1, p2, q1, q2): reusing onepchanges allocation priorities (func_003867F853 to 0,func_00387B10). Separate pointer variables per group let the last access of each group update the pointer in place (func_003A7090). -
Constant first in OR chains:
0xA | (x << 4) | ...keeps retail's grouping; with the constant in the middle gcc reassociates it (func_003872F8,func_00386F38). For TEX0 values, write the fullSCE_GS_SET_TEX0expansion with all 12 fields, constants included, inline in the block (no#define): constant folding then groups the chain as retail (func_00384EC0). -
(1L << 63)and other 64-bit constants need@ps2as. Ps2EeAs builds1L << 63asaddiu -1; dsll32 31(retail), the default assembler asori 0x8000; dsll32 16. Keep(x << 37) | 0x400000000Las its own operand or statement, or gcc merges the constants (s3's batch,func_00392DD8).u32 == 0xFFFFFFFFmakes Ps2EeAs buildlui 0xffff; ori 0xfffflike retail. -
lw+dsrl+andion a 32-bit global:unsigned long t = D[0]; if (!((t >> 6) & 1)) break;with a separate 64-bit temporary (func_0037D200); one expression narrows to an SImodesra.
-
A float constant reused as a variable:
bx = 1.0f; by = bx - ay; bx = bx - ax;where1.0f - aydoesn't (func_003875A0). -
A dead store gcc would drop is kept when it has a different mode from the later one: write the first stores as
f32 0.0f(func_003A6A00). -
Dead compare loads before a rounding expression:
(D_001DA0F0 == 1 ? z0 : z1)with two local zerosz0 = z1 = 0.0f(func_003DBEC8,func_003A5DF0); a literal? 0.0f : 0.0ffolds away completely. -
(x & 0xFF) < (y & 0xFF)keeps a signedsltwhere twou8fields comparesltu; a boolean helper returningu8reproduces adadducopy of the result before each loop test (h05's batch). -
Struct copies as statements, declared in slot order, so other stores can sit between them (
func_003C1130, near miss, 49 to 8). -
Start over. A clean rewrite, or a copy of an already matched sibling, often beats tuning an old draft:
func_003A3FB0went from 46 to 0 with a plain rewrite,func_00392400from 89 to 0 copied fromfunc_00392878.