From 6901aac8517371bd187ef65fdacb78eddbab5846 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 30 Sep 2026 18:58:25 -0700 Subject: [PATCH] Discover scheduled overlay prologues through their first basic block --- docs/internal/FAITHFUL_TIMING_PLAN.md | 10 ++++ .../tests/test_aot_overlay_discovery.py | 38 +++++++++++++ tools/aot_overlay_spike/extract_generic.py | 53 ++++++++++++------- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 72a2f20e..307e1e6b 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,16 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-09-30 — Original-disc overlay discovery (beads-eio.3.215).** + Ape Escape Primordial Ooze exposed return-adjacent functions omitted by the + archive scanner: a scheduled LUI/load before stack setup and an RA save beyond + its fixed twelve-instruction prefix. Reuse the proven prelude recognizer and + validate the first basic block through its control transfer, rejecting invalid + instructions, SP changes and out-of-frame saves. Synthetic positive/negative + regressions pass. This changes offline discovery; guest instructions and + timing semantics are unchanged. Release regeneration and gameplay validation + are tracked in beads-eio.6.9. + - **2026-08-31 (GPU DMA2 review correction — source gate passed):** The first fork review found two valid timing defects in the DMA2 candidate. The linked-list engine now reads and emits one live payload word at each diff --git a/recompiler/tests/test_aot_overlay_discovery.py b/recompiler/tests/test_aot_overlay_discovery.py index 50a2c9b0..665d0f28 100644 --- a/recompiler/tests/test_aot_overlay_discovery.py +++ b/recompiler/tests/test_aot_overlay_discovery.py @@ -3714,6 +3714,43 @@ def check_packaged_game_toml_resolves_bios_profile(recompiler): assert MOD.recompiler_project_root_args(args) == [] +def check_return_adjacent_scheduled_frames(): + # A compiler may schedule address setup before allocating the stack, or + # delay saving RA until after other callee-saved registers. Neither changes + # the function boundary proved by the preceding return and its delay slot. + def image(prefix): + words = [0x03E00008, 0x27BD0020] + prefix + return bytearray(struct.pack('<' + 'I' * len(words), *words)) + + delayed = ([0x27BDFFC8] + [0x26100001] * 12 + + [0xAFBF0034, 0xAFB10030, 0x03E00008, 0x27BD0038]) + scheduled = [0x3C038001, 0x90620080, 0x27BDFFD8, + 0xAFB10014, 0xAFBF0020, 0x03E00008, 0x27BD0028] + for prefix in (delayed, scheduled): + data = image(prefix) + assert EXTRACT.return_adjacent_framed_entries(data, LOAD) == [LOAD + 8] + + data = image(scheduled) + assert EXTRACT.prologues(data, LOAD) == [LOAD + 8] + assert LOAD + 16 not in EXTRACT.return_adjacent_framed_entries(data, LOAD) + + # The extra reach must not promote data, an unrelated load, a second stack + # adjustment, or a save reached only after a control transfer. + bad_prefixes = [ + [0x3C038001, 0x90820080] + scheduled[2:], # load uses a different base + [0x27BDFFC8, jal(LOAD + 0x100), 0, 0xAFBF0034, 0x03E00008, 0], + [0x27BDFFC8, 0x27BDFFF0, 0xAFBF0034, 0x03E00008, 0], + [0x27BDFFC8, 0xAFBF0034, 0xAFB00038, 0x03E00008, 0], + [0x27BDFFC8, 0xAFBF0034, 0xFFFFFFFF, 0x03E00008, 0], + [0x27BDFFC8, 0xAFBF0034], # no terminating control-flow evidence + ] + for prefix in bad_prefixes: + assert EXTRACT.return_adjacent_framed_entries(image(prefix), LOAD) == [] + no_boundary = image(delayed) + put(no_boundary, 0, 0) + assert EXTRACT.return_adjacent_framed_entries(no_boundary, LOAD) == [] + + def main(): default_recompiler = ROOT / "recompiler" / "build" / "psxrecomp-game.exe" parser = argparse.ArgumentParser() @@ -3722,6 +3759,7 @@ def main(): if not os.path.isfile(args.recompiler): raise SystemExit(f"recompiler not found: {args.recompiler}") + check_return_adjacent_scheduled_frames() check_composite_call_boundaries() check_bounded_jump_table_discovery() check_scheduled_jump_table_discovery() diff --git a/tools/aot_overlay_spike/extract_generic.py b/tools/aot_overlay_spike/extract_generic.py index 901f4310..2fd1e185 100644 --- a/tools/aot_overlay_spike/extract_generic.py +++ b/tools/aot_overlay_spike/extract_generic.py @@ -66,19 +66,21 @@ def prologues(data, base): the return boundary, LUI/load opcode pair, and matching address register are all required data-as-code gates. """ - entries=[] - for off in prologue_offsets(data): - entry=off - if off >= 16 and _word(data, off-16) == 0x03E00008: - lui=_word(data, off-8) - load=_word(data, off-4) - lui_rt=(lui >> 16) & 0x1F - if (lui >> 26) == 0x0F and lui_rt != 0 and \ - 0x20 <= (load >> 26) <= 0x26 and \ - ((load >> 21) & 0x1F) == lui_rt: - entry=off-8 - entries.append(base+entry) - return entries + return [base + framed_entry_offset(data, off) + for off in prologue_offsets(data)] + + +def framed_entry_offset(data, off): + """Recover the same return-proven scheduled prelude for every frame scan.""" + if off >= 16 and _word(data, off-16) == 0x03E00008: + lui=_word(data, off-8) + load=_word(data, off-4) + lui_rt=(lui >> 16) & 0x1F + if (lui >> 26) == 0x0F and lui_rt != 0 and \ + 0x20 <= (load >> 26) <= 0x26 and \ + ((load >> 21) & 0x1F) == lui_rt: + return off-8 + return off def prologue_offsets(data): """File offsets whose word is an `addiu $sp,$sp,-N` stack-frame prologue.""" @@ -397,10 +399,16 @@ def return_adjacent_framed_entries(data, base): instruction alone is insufficient. Require a preceding ``jr ra`` and its arbitrary architectural delay slot, at most six alignment NOPs, plus an in-frame save of ``ra`` before the new function's first control transfer. - Every stack-relative word save observed in that prefix must fit the frame. + Recover a proven LUI/load prelude before the frame allocation. Scan the + first basic block, rather than a fixed number of instructions: register + scheduling can put the RA save after many other saves/address calculations. + Every instruction must decode, SP must stay unchanged, and every observed + stack-relative word save must fit the frame. A control transfer must end + the prefix; truncated bytes are not a complete proof. """ entries=set() for off in prologue_offsets(data): + entry=framed_entry_offset(data,off) word=_word(data,off) frame_imm=word&0xFFFF frame_size=0x10000-frame_imm @@ -408,22 +416,27 @@ def return_adjacent_framed_entries(data, base): continue boundary=False for padding in range(7): - jr_off=off-8-padding*4 + jr_off=entry-8-padding*4 if _word(data,jr_off)!=0x03E00008: continue - if all(_word(data,p)==0 for p in range(jr_off+8,off,4)): + if all(_word(data,p)==0 for p in range(jr_off+8,entry,4)): boundary=True break if not boundary: continue saved_ra=False consistent=True - for pc in range(off+4,min(len(data),off+4+12*4),4): + ended=False + for pc in range(off+4,len(data)-3,4): candidate=_word(data,pc) - if candidate is None: + if not co._is_valid_mips_word(candidate): consistent=False break if _is_control_flow_word(candidate): + ended=True + break + if co._instruction_writes_gpr(candidate,29): + consistent=False break op=(candidate>>26)&0x3F rs=(candidate>>21)&0x1F @@ -437,8 +450,8 @@ def return_adjacent_framed_entries(data, base): break if rt==31: saved_ra=True - if consistent and saved_ra: - entries.add(base+off) + if consistent and ended and saved_ra: + entries.add(base+entry) return sorted(entries) def _is_control_flow_word(word):