Skip to content

Decode the real delay slot when a function's final branch has its slot at the next function - #415

Open
0xLeathery wants to merge 2 commits into
ran-j:mainfrom
0xLeathery:feat/recomp-final-delay-slot
Open

0xLeathery wants to merge 2 commits into
ran-j:mainfrom
0xLeathery:feat/recomp-final-delay-slot

Conversation

@0xLeathery

Copy link
Copy Markdown

Problem

PS2Recompiler::decodeFunction decodes [function.start, function.end). The function emitter uses the next decoded instruction as each branch's delay slot. If that instruction isn't in the list, the emitter substitutes a synthetic NOP (makeSyntheticDelaySlot).

When the analyzer starts another function exactly at the delay slot of a function's final branch, the first function's end is that slot's address. The real instruction there is then silently dropped.

In Sly 2 (SCUS-97316) this happens in 4 functions. One of them ends in jr $ra at 0x10183c with addiu $sp, $sp, 0xA0 in its delay slot at 0x101840, where another function begins. The recompiled routine returned with $sp 0xA0 too low. That corrupted its caller's frame and sent a cleanup loop running through the whole address space.

Change

  • The per-instruction decode in decodeFunction (read, config patch, decode, MMIO annotation) now lives in one decodeInstructionAt helper. It behaves the same for normal instructions.
  • If the function's last decoded instruction has a delay slot at exactly function.end, and that address is valid, the real instruction there is decoded with the same patch and MMIO handling and appended. The emitter's existing decoded-delay-slot path then uses it.
  • function.end, the function's registered range, labels and entries are unchanged. Every consumer of the decoded list checks < function.end before using an address, so the slot gets no label, case or registration in the earlier function. Branch-likely still runs the slot only on the taken path.
  • The synthetic-NOP fallback stays for slots that aren't valid addresses, such as the end of a section.

Testing

New tests in ps2_recompiler_tests.cpp:

  • A final jr $ra whose addiu $sp, $sp, 0x20 slot starts the next function now runs that slot.
  • The same holds for final j, jal and bne.
  • A configured patch applies to the slot decoded past the end.
  • A slot past the end of a section keeps the NOP fallback.

The first three failed before the change.

The tests use single_file_output. That path has an existing race in multi-threaded combined output: "combined output completion queue is missing index N". It shows up about 2 in 6 runs on main as well. The test helper pins output_worker_threads = 1, and the race should get its own issue.

  • ps2x_tests: 450/450, in 8 consecutive runs.
  • Probes, not committed: a slot that's also a branch target and a JAL target leaves the other functions byte-identical. A beql final branch, an MMIO slot, and a production standalone entry wrapper all behave correctly.
  • Sly 2 (USA): regenerating changes exactly the 4 affected functions, and the stack corruption and hang are gone.

Known limits, not changed here

  • The analyzer starting functions at delay-slot addresses in the first place is a separate issue.
  • If the function's last instruction is itself a delay slot (a branch in a delay slot), the appended instruction could run twice. That's undefined MIPS, and it doesn't happen in practice.

Built and tested on macOS arm64 (Apple clang).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant