recompilation: emit MIPS link side-effect ($ra := PC+8) for jal/jalr/bltzal/bgezal

Previously the engine relied on callees to save/restore $ra on their own
stack frame, leaving ctx->r31 stale across linking instructions. This
worked for normal code that only uses $ra for `jr $ra` returns (which
the engine maps to a C `return`, never reading ctx->r31), but broke any
handwritten code that computes addresses from $ra.

Concrete failure in Stadium: func_80048904 (audio synth) uses the
bltzal $zero, X / bgezal $zero, X PC-arithmetic primitive: the branch
is always-not-taken (since $zero is never < 0), but the link write is
still committed by real MIPS, allowing the following `addiu $t3, $ra,
OFFSET` to compute a helper-function address PC-relatively. With the
link write missing, $t3 became a stale return-address-into-the-caller
plus OFFSET, then `jalr $t3` inside func_80048A58 dispatched to garbage
(LOOKUP_FUNC miss at 0x800AA638) and SEH-crashed mid-audio-frame.

Fix: emit `ctx->r31 = vram + 8;` immediately before each linking
instruction's call/branch emit. For conditional-link branches the
write is hoisted outside the if-block so it fires regardless of
branch direction.

Verified: regen + 60s smoke test reaches frame 3597 with
func_80048904 → func_80048A58 in the trace, no SEH crash, no
0x800AA638 lookup miss, audio probe 6/6 OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley 2026-05-03 15:11:47 -07:00
parent bc00a039f7
commit 16b4d33cf6

View file

@ -538,6 +538,12 @@ bool process_instruction(GeneratorType& generator, const N64Recomp::Context& con
break;
// Branches
case InstrId::cpu_jal:
// MIPS link side-effect: $ra := PC+8, written before the delay slot
// executes. Required for handwritten code that computes addresses
// from $ra (e.g. Stadium audio synth's `addiu $t3, $ra, OFFSET`
// PC-arithmetic trick).
print_indent();
fmt::print(output_file, "ctx->r31 = 0x{:08X}u;\n", instr_vram + 8);
if (!print_func_call_by_address(instr.getBranchVramGeneric())) {
return false;
}
@ -548,6 +554,9 @@ bool process_instruction(GeneratorType& generator, const N64Recomp::Context& con
fmt::print(stderr, "Invalid return address reg for jalr: f{}\n", rd);
return false;
}
// MIPS link side-effect: $ra := PC+8 (see cpu_jal comment).
print_indent();
fmt::print(output_file, "ctx->r31 = 0x{:08X}u;\n", instr_vram + 8);
needs_link_branch = true;
print_func_call_by_register(rs);
break;
@ -773,6 +782,18 @@ bool process_instruction(GeneratorType& generator, const N64Recomp::Context& con
auto find_conditional_branch_it = conditional_branch_ops.find(instr_id);
if (find_conditional_branch_it != conditional_branch_ops.end()) {
// MIPS link side-effect for bltzal/bgezal/bltzall/bgezall: $ra := PC+8
// is committed unconditionally, regardless of whether the branch is
// taken. Emit the write BEFORE the if-block so it runs in both
// branch-taken and branch-not-taken paths. Required for handwritten
// code (e.g. Stadium audio synth) that uses bltzal $zero,X as a
// PC-arithmetic primitive: the branch is always-not-taken (since
// $zero is never < 0), but $ra still gets loaded so that the
// following `addiu $t3, $ra, OFFSET` can compute a function pointer.
if (find_conditional_branch_it->second.link) {
print_indent();
fmt::print(output_file, "ctx->r31 = 0x{:08X}u;\n", instr_vram + 8);
}
print_indent();
// TODO combining the branch condition and branch target into one generator call would allow better optimization in the runtime's JIT generator.
// This would require splitting into a conditional jump method and conditional function call method.