Files
psxrecomp/docs/internal/ISSUES.md
T
Matthew Stanley e6ce5006b4 docs: move root and stale docs into docs/ and docs/internal/
The repository root carried seven markdown files that were not onboarding
material, plus two that had been superseded by the code they described. This
sorts them: reader-facing docs to docs/, working notes to docs/internal/, and
the two superseded ones merged into the docs that replaced them.

Moved to docs/:            WIDESCREEN.md, ENHANCEMENTS.md, TCP_COMMANDS.md
Moved to docs/internal/:   PRINCIPLES.md, ISSUES.md, DEBUG.md, GTE_LM_FIX.md,
                           and from docs/: bios-hardening.md, overlay-status.md,
                           overlay-plan.md, overlay-recompilation-design.md,
                           overlay-discovery.md, TOMBA2_EXC_CONSULT_RESPONSE.md,
                           CASE_A_AOT_GAP.md, audit_inventory.md
Moved to docs/internal/upstream/: UPSTREAM_2026-08-20.md

The root keeps only what belongs there: README.md, CONTRIBUTING.md, LICENSE,
CLAUDE.md, THIRD_PARTY_ATTRIBUTION.md, RELEASE_NOTES.md, the dotfiles and
psxrecomp_cli.py.

MERGES

SPLITGEN_MIGRATION.md folded into docs/BUILDING.md as "Split generated C". Its
migration instructions were stale in a way that mattered: it told every game
repo to hand-edit its CMakeLists and add a file(GLOB ...) over
<serial>_full*.c, but runtime/runtime.cmake now does that itself. GEN_FULL_GLOB
is a multi-value argument of psxrecomp_add_game_runtime(); the loop at
runtime.cmake:1985 makes each pattern absolute against CMAKE_CURRENT_SOURCE_DIR,
expands it with file(GLOB), and concatenates the hits into
GAME_GENERATED_FULL_C. So a title names the pattern and gets however many shards
a regeneration produced. The rewritten section describes that, the two-step
fallback when the globs match nothing (GEN_FULL_FALLBACK, else <serial>_full.c
derived from the dispatch marker by substituting _dispatch.c), and the one
caveat the code actually has: that file(GLOB) is plain, not CONFIGURE_DEPENDS,
so a regeneration which changes the shard COUNT needs a re-configure to be seen.

psxrecomp-toml-readme.md folded into docs/GAME_PROJECT_SETUP.md as "EXE-only
autofill (psxrecomp-toml)", placed after "Disc autofill (probe_disc.py)" and
before "Project Studio" so the two autofill paths sit together. Its documented
flags were checked against the usage text in recompiler/src/main_toml.cpp:28-42
and against the argument parser at :150-165 -- all seven options match with no
drift, so the flag list is reproduced as-is. Added a pointer that probe_disc.py
is preferable when a cue exists, since that tool does the same EXE-header work
plus disc identity and netplay gates.

FUNCTIONAL FIXES

tools/gen_tcp_commands.py wrote to REPO/"TCP_COMMANDS.md". After the move that
path does not exist, so the generator would have created a new stray root file
and --check would have failed against it forever. Retargeted to
REPO/"docs"/"TCP_COMMANDS.md", along with the seven docstring and message
strings that name the path.

Running it surfaced two further problems, both fixed here:

  * The doc was genuinely stale -- pre-existing drift, not introduced by this
    commit (the moved file is byte-identical to origin/master, and no
    debug-server command table is touched here). Four commands registered since
    the last regeneration were missing from the index -- cdrom_timing_dump,
    savestate_status, ws_ui_groups, xprobe_watch -- and scanline had gained a
    backend. Regenerated: 307 -> 311 commands, and the change is confined to the
    AUTOGEN block (the hand-written head and tail are byte-identical to master).
    tools/gen_tcp_commands.py --check now exits 0.
  * DOC.write_text() has no newline argument, so Python translated every newline
    to os.linesep. On Windows that rewrote all 585 lines of the file to CRLF and
    turned a four-line index update into a whole-file diff -- measured: 589 CRLF
    / 0 LF after a run, against 0 CRLF / 585 LF in the repo. Now written through
    an explicit newline="" handle, verified to leave the file at 0 CRLF.

runtime/include/debug_server.h:9 and runtime/src/debug_server.c:8 cited "TCP.md
and DEBUG.md". TCP.md has never existed in this repo under that name; both now
name docs/TCP_COMMANDS.md and docs/internal/DEBUG.md.

LINK AND REFERENCE FIXES

Every inbound reference to a moved file was located by grepping each basename
across the whole tracked tree, not just the markdown. Markdown hrefs in
README.md, CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/COMPILING_OVERLAYS.md,
docs/AOT_OVERLAY_PLAN.md, docs/RELOCATION_MANIFEST_FORMAT.md,
docs/config_schema.md, docs/OVERLAY_XREF.md, docs/TCP_COMMANDS.md and several
docs/internal/ files now point at the new locations; where two files became
siblings under docs/internal/ the path was shortened rather than lengthened
(bios-hardening.md's reference to overlay-discovery.md, for instance).
docs/COMPILING_OVERLAYS.md's "in this folder" phrasing was wrong once two of the
three files it listed moved, so it now names internal/ explicitly.

24 source-comment mentions of ENHANCEMENTS.md sections G1.x across 19 files
(recompiler, runtime, cmake) were retargeted to docs/ENHANCEMENTS.md with the
section numbers untouched. docs/SHADOW_ENHANCEMENTS.md is a DIFFERENT file and
was deliberately excluded.

20 rationale strings citing docs/CASE_A_AOT_GAP.md across the four
recompiler/seeds/*.json files were updated. Done as a byte-level substring
replacement rather than a JSON round-trip: two of those files are stored with
CRLF endings and two with LF, and re-serializing them reformatted 5,700 lines.
The byte-level version changes exactly 20 lines and each file still parses.

References that look similar but point elsewhere were deliberately left alone,
and are worth naming so a future pass does not "fix" them:

  * "recomp-template PRINCIPLES.md" / "NES recomp template PRINCIPLES.md" in
    CLAUDE.md:43, docs/SHADOW_ENHANCEMENTS.md:28,
    docs/internal/HLE_SCHEDULER_CARVEOUT_PLAN.md:5,
    runtime/src/debug_server.c:4934 and tools/parity_diff.py:8 are a different
    repository's file. Only the five that mean this repo's PRINCIPLES.md were
    updated.
  * Every remaining bare ISSUES.md mention is a GAME repo's tracker
    (MegaManX6Recomp 2/7/8/9, TombaRecomp 6), not this repo's.
  * docs/disc-speed.md:4,54,56 and docs/internal/bios-hardening.md:5 name
    overlay-discovery as a BRANCH, not a file, so no path applies.

INDEXES

README.md's "New here?" table gains rows for docs/WIDESCREEN.md,
docs/ENHANCEMENTS.md and docs/TCP_COMMANDS.md. docs/internal/README.md gains
sections indexing all fourteen documents that moved in, plus an "Upstream
contributor reviews" section describing upstream/ and the new sweep file, plus a
"Removed" bullet for CYCLE_TIMING_ARCH.md (deleted in the previous commit)
pointing at FAITHFUL_TIMING_PLAN.md, CYCLE_MODEL_BEETLE.md and
PRECISE_IRQ_SLICE.md -- following the SLJIT_PERSIST_CACHE.md precedent already
in that file.

docs/ENHANCEMENTS.md:7 claimed framework bugs live in "game repos' ISSUES.md
until a framework tracker exists". A framework tracker does exist; it now says
so and names the Beads epic.

VERIFICATION

A link checker was written to scan every tracked *.md for relative links,
resolving each against the tree being scanned. Baseline at origin/master: 129
files, 137 relative links, 3 broken. After both commits: 127 files, 159 relative
links, 3 broken -- the SAME three, all pre-existing and untouched here:

    docs/DATA_SHARDS.md -> INSTANT_LOADS_PARKED.md   (never existed)
    docs/DATA_SHARDS.md -> INSTANT_LOADS_SPIKE2.md   (never existed)
    docs/NETPLAY.md     -> ../lib/recomp-net/README.md  (inside a submodule)

Zero new broken links. The checker's own coverage was checked against a raw grep
for markdown link openers: the 3-link difference is C++ lambdas inside code
blocks, correctly not treated as links.

Also verified: py_compile over all 113 tracked tools/*.py plus psxrecomp_cli.py;
all four seed JSONs still parse; the test-registration guard still reports 123
test files all registered; recompiler/tests/test_coverage_vault.py still passes;
and no file in this commit had its line endings flipped (checked mechanically
across all 50 modified files, after two files were caught and redone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:39:07 -07:00

32 KiB
Raw Blame History

PSXRecomp v4 — Open Issues

Issue #1 — Per-card directory load never runs in recomp (Phase 4 blocker)

Status: open, root cause narrowed Date opened: 2026-05-03 Phase: 4 (BIOS shell + memory-card screen)

Symptom

Recomp and Beetle both reach the same MEMORY CARD menu screen with the same correct PSX rendering. Beetle renders 3 save-block icons in CARD 1 + 1 face icon in CARD 2; recomp renders zero icons. The divergence is the per-card directory load — Beetle reads it from the on-disk card files, recomp does not.

(Note: the rainbow buttons are the actual PSX BIOS rendering — both sides have it. Not a bug.)

Hard data — gate-mode write distribution

Captured side-by-side, both runtimes driven into the memcard screen with a 240-frame CROSS hold via press_into_card_view*.py:

Mode Beetle Recomp Notes
0x01 122 21 chain reset / BUSY clear
0x02 96 16 R-step install (read-step)
0x04 2 6 step-3 install
0x08 22 3 D-step install (detect)
0x21 0 6 init+flag — recomp-only path

R-step (mode 0x02) caller breakdown:

caller (ra) Beetle Recomp
0xBFC08F28 (icon-load loop) 40 0
0xBFC09250 16 0
0xBFC08D08 30 6
0xBFC08A0C 8 4
0xBFC08B98 2 6

Recomp never reaches the icon-load loop. That is the divergence.

What we proved is not the cause

  • mem[0x80007520] ("chain-restart discriminator") is not the divergence. Both sides have it at 0 in steady state. The earlier hypothesis that recomp=0 / Beetle=non-zero at the decision point was wrong — both sides clear it in the JAL-delay-slot at PC 0xBFC14CE0 (= RAM 0x51E0) every 2 frames before calling func_00004D6C.
  • The texture corruption on the menu buttons is correct PSX rendering, not a bug.
  • The per-slot FP table at mem[0x7528+slot*4] IS swapped dynamically by recomp too (between 0x5B64, 0x5688, 0x51F4) — the swap mechanism works. Beetle just performs additional swaps from callers BFC08F28 and BFC09250 that recomp never reaches.
  • func_00006380 (writes mem[0x75C0]=1) and the B-table trampolines at BFC0DA30 / BFC0DA40 (B0:0x58, B0:0x4D _card_status) DO run in recomp — BFC0DA30 620×, BFC0DA40 618×. The trampoline machinery works for those.

Suspected root cause (not yet confirmed)

Recomp never enters func_1FC08B3C — the per-card directory load function. The icon-load loop at PC 0xBFC08F1C..0xBFC08F7C lives inside this function. The loop does jal 0xBFC0DA00 (= B0:0x4F _card_read) once per directory entry; Beetle hits this 20× per session.

Wtrace activity that appears to put recomp inside func_1FC08B3C (mode 0x02 writes with ra=0xBFC08B98) is reached via the FP-table swap path, not via the icon loop. The wider function entry at label_BFC08B3C is never logged in fn_entry_dump for the entire [0x1FC08000, 0x1FC0A000) range across 134M captured ring entries.

func_1FC08B3C IS in the recomp dispatch table (entry 5449, { 0x1FC08B3Cu, func_1FC08B3C }) plus 20+ continuation entries. So the function is recompiled and dispatchable; it's the caller that isn't reaching it.

Open questions for the next session

  1. What calls func_1FC08B3C? Find the static caller (or callers) of ROM 0xBFC08B3C. Use Ghidra xrefs 0xBFC08B3C to enumerate.
  2. Does recomp ever reach func_000005E0 (B0 dispatcher) and func_000005C4 (A0 dispatcher)? These live in the recomp dispatch table; if recomp doesn't enter them, B-table dispatch is broken. Was about to check this when the runtime froze on a 270M-entry fn_entry_dump.
  3. Counter-mismatch hypothesis: in func_00004D6C, the FP-call chooses between several outcomes based on mem[0x7514]-1. func_00005B64 (the dispatcher FP) returns -1 when counter=0, forcing the 0x21 path. Both sides clear mem[0x7514] in the same place every 2 frames, but Beetle's writes show counter cycles 1-2-3-4-5-6 inside a single window (132 cycles × 6 calls), while recomp's data showed cycles up to 13 with different clear sources. The cycle structure differs — needs deeper analysis.

Diagnostic infrastructure built / fixed this session

  • tools/summarize_gate_modes.py — tallies gate writes by value (mode 0x01/0x02/0x04/0x08/0x21) per caller, recomp + beetle
  • tools/_dump_gate_seq.py — dump gate writes in seq order with pc/fn/ra/w fields (extends earlier handoff tool)
  • tools/_dump_addrs.py — group wtrace by addr, show distinct writers per addr
  • tools/_dump_7520_beetle.py — filter beetle wtrace by addr range
  • tools/_dump_ram_words.py — RAM dump as 4-byte words, both backends
  • tools/_read_7520.py — sample mem[0x7520], 0x755A, 0x7568
  • tools/_screenshot_meta.py — call screenshot/screenshot_file/ emu_screenshot
  • tools/check_pc_dispatch.py — group fn_entry_dump by func_addr in a range
  • tools/press_into_card_view_recomp.py — mirror Beetle's press_into_card_view but for recomp
  • tools/arm_gate_trace.py extended with [0x7520..0x7524), [0x7514..0x7518), [0x7528..0x7538), [0x7258..0x7260) ranges

Tooling fix needed (CLAUDE.md Rule 15)

fn_entry_dump iterates the entire ring (up to 270M entries) before applying the addr filter, which freezes the debug server thread for seconds-to-minutes on a populated ring. Both handle_fn_entry_dump and handle_fn_exit_dump in runtime/src/debug_server.c need the addr filter applied early, plus a seq_lo/seq_hi window cap, so a filtered query for a small range returns immediately.

Files with new memories on disk

  • memory/MEMORY.md (will be updated next session with the "0x7520 is NOT the divergence — directory-load function never entered" finding)

Update 2026-05-03 (continued)

Static call chain to the directory load (via Ghidra xrefs):

????
  -> FUN_bfc24640 / FUN_bfc24a48 / FUN_bfc1cdd8       (3 of the 5 _card_load callers)
       jal 0xB005A9B0  (= jal 0xBFC42BB0 = B0:0x15 _card_load)
  -> FUN_bfc0dac0  (kernel B0:0x15 trampoline body)
       jr $t2 with $t1=0x15 → kernel B0 dispatcher
  -> _card_load (B0:0x15 body in kernel image)
       calls FUN_bfc09914
  -> FUN_bfc09914
       FUN_bfc08b3c(0)   ; load card-1 directory
       FUN_bfc08b3c(0x10); load card-2 directory
  -> FUN_bfc08b3c (the icon-load loop at BFC08F1C..BFC08F7C)
       jal 0xBFC0DA00  (= B0:0x4F _card_read)  per directory entry

On recomp NONE of FUN_bfc24640, FUN_bfc24a48, FUN_bfc1cdd8, FUN_bfc09914, FUN_bfc08b3c, FUN_bfc0c2e8 are ever called. The shell never reaches the call sites. Per memory/phase4_b0_trampoline_findings.md: "The shell has a conditional code path for card init. On DuckStation, the shell enters the card detection path. In our runtime, this condition fails, so card init is skipped." This is the same blocker.

Static caller of FUN_bfc0c2e8 (BootInitMemcards) is NOT static. It has zero static xrefs, and the literal 0xBFC0C2E8 does not appear anywhere in ROM. It's invoked via a kernel function pointer table populated at runtime.

Update late 2026-05-03 — actual call path identified via Beetle fn_trace:

Beetle's fn_trace ring (with 0x1FC09914 armed as a target) captures:

  • seq=904 JR caller=0x000005D8 ra=0x8003202C jumping to FUN_bfc09914
  • caller PC 0x000005D8 is INSIDE the kernel A0/B0 dispatcher area (A0 dispatcher entry is 0x000005C4, B0 dispatcher is 0x000005E0)
  • ra=0x8003202C means the JAL that started this chain was at 0x80032024 — which translates to ROM 0xBFC1A024
  • Disassembly at ROM 0xBFC1A024:
    bfc1a000: li $t8, 1
    bfc1a004: bne $t9, $zero, 0xbfc1a040  ; SKIP if already initialized
    bfc1a008:   sw $t8, -0x5880($at)       ; (delay slot) set init flag
    bfc1a00c: jal 0xb005a960               ; B0:0x4A InitCARD($a0=1)
    bfc1a010:   li $a0, 1
    bfc1a014: jal 0xb005a970               ; B0:0x4B StartCARD($a0=0)
    bfc1a018:   nop
    bfc1a01c: jal 0xb005a980               ; B0:0x5B ChangeClearPad
    bfc1a020:   clear $a0
    bfc1a024: jal 0xb005a990               ; A0:0x70 (_card_load equivalent)
    bfc1a028:   nop
    bfc1a02c: jal 0xb005a9a0               ; A0:0xAD
    bfc1a030:   li $a0, 1
    bfc1a034: li $t0, 1
    bfc1a038: lui $at, 0x8006
    bfc1a03c: sw $t0, 0x66f8($at)          ; mark mem[0x800666F8]=1 (init done)
    bfc1a040: lw $ra, ...
    
  • Shell calls jal 0xB005A990 at PC 0xBFC1A024. The trampoline at 0xBFC42990 is A0:0x70, which the kernel A-table dispatches to FUN_bfc09914 (the directory loader caller).
  • So the actual chain is A0:0x70, not B0:0x15. The 5 static callers of FUN_bfc0c2e8 (FUN_bfc24640 etc.) are red herrings — they handle COPY/DELETE actions, not boot-time card load.

Recomp blocker: the shell function FUN_bfc1a000 is reached via indirect dispatch (no static caller in ROM). It is in recomp's dispatch table ({0x1FC1A000u, func_1FC1A000}), but the shell never dispatches to it. OR — the shell DOES dispatch to it, but the bne $t9, $zero, 0xBFC1A040 branch at PC 0xBFC1A004 skips the init because $t9 != 0 somehow.

Confirmed already-known issue. This matches memory/phase4_b0_trampoline_findings.md: "The shell has a conditional code path for card init. ... In our runtime, this condition fails, so card init is skipped and the shell goes directly to the main menu (state 0x37)."

Concrete next step

  1. Find what calls FUN_bfc1a000 on Beetle. Arm 0x1FC1A000 in Beetle fn_trace and capture the caller. (One JAL produces the chain.)
  2. On recomp, check whether func_1FC1A000 is ever entered. If yes: capture $t9 value at entry. If $t9!=0, find what set it. If no: trace the upstream call chain to find why the JAL to func_1FC1A000 doesn't fire.
  3. Fix the recompiler's emit / generator for whatever instruction or branch is producing the divergent state. NEVER hand-patch the generated code or hand-call func_1FC1A000.

Update 2026-05-03 (continued, late session)

Step 1's address range was wrong — 0x1FC1A000 is a label INSIDE func_1FC19FD0, not the function entry. Querying for the correct entry 0x1FC19FD0 shows recomp DOES enter:

  • func_1FC19FD0 (BootInitMemcardsShell) at frame 617/2344 with $a0=0x00F000F0 (matches the AND-mask gate at BFC19FDC).
  • The full init chain runs: mem[0x800666F8] (init done flag) is set to 1, meaning all 5 inner JALs (InitCARD, StartCARD, ChangeClearPad, A0:0x70, A0:0xAD) executed.
  • FUN_bfc09914 is entered at frame 617/2344 with $a0=0 (ra=0x8003202C — confirming the JAL chain from BootInitMemcardsShell).
  • FUN_bfc08b3c is entered 12 times across both slots: frame 2344/2350 (initial calls from FUN_bfc09914 with ra=BFC09934, BFC0993C), then frames 2372/2385/2426/2433 (subsequent calls from FUN_bfc089c0 ra=BFC08A90).

Beetle fn_trace confirms the call chain:

JALR @ 0x8004673C → 0x80031FD0 (BootInitMemcardsShell)
  $a0=0x00F000F0, $a1=0x00F000F0, ra=0x80030968

The caller PC 0x8004673C is inside a function at shell-RAM 0x800466B8 (= ROM 0xBFC2E6B8), entered via JAL from PC 0x80030960. The function reads a function-pointer table at mem[$a3 + idx*0x88 + 0x34] and dispatches devices. For card init, $t2 = pointer to BootInitMemcardsShell. Recomp ALSO reaches this function (per func_1FC2E6B8 having entries) — the BIOS shell init chain IS running on recomp end-to-end.

The actual root cause is one level deeper. FUN_bfc08b3c runs but bails out in the 15-sector init loop before reaching the icon-load loop at PC 0xBFC08F1C..BFC08F7C:

  • _card_read (B0:0x4F via trampoline BFC0DA00) is called 32 times on recomp — ra=BFC08B98 (sector 0 from FUN_bfc08b3c entry), ra=BFC08D08 (sectors 1+ from the 15-sector loop), ra=BFC08A0C (FUN_bfc089c0's first read).
  • Sector 0 read SUCCEEDS — mem[0xA000BE48] shows 'M' 'C' 0 0 … 0 0 0 0x0E — the MC magic AND a valid XOR checksum (XOR of bytes 0..126 = 0x0E = byte 127). The buffer is correctly filled by recomp's SIO/card simulation for sector 0.
  • The 15-sector loop runs only ~4 iterations per slot before bailing to LAB_bfc08f8c. Confirmed: mem[0xA000B9E8..]=0xFFFFFFFF is exactly the bail-clear pattern (for (i=0; i<0x14; i++) puVar10[i]= 0xFFFFFFFF). And mem[0xA000BA88]=0 is the bail-clear for the directory area.
  • The validator FUN_bfc08720 is never entered — confirming the bail happens BEFORE the checksum check, in the read-gate:
iVar6 = FUN_bfc0da00(slot, sector, buf);
if ((iVar6 != 1) || (iVar6 = FUN_bfc09144(), iVar6 == 0)) {
    goto LAB_bfc08f8c;          // ← bail
}
iVar6 = FUN_bfc08720(buf);      // ← never called

Either:

  • (a) _card_read returns != 1 for sector ≥ 1 (i.e. recomp's SIO card simulation only completes sector-0 reads correctly), OR
  • (b) FUN_bfc09144 returns 0 — meaning one of the error flags mem[0xA000B9D4..B9E0] was set instead of the success flag mem[0xA000B9D0]. The error flags are set by the recomp's SIO IRQ / chain coordinator on read failure.

Concrete next step (next session): capture _card_read return values via fn_exit_dump of 0x1FC0DA00 while card init is happening (window from frame ~1029-1100), and capture the wtrace lifecycle of mem[0xA000B9D0] and mem[0xA000B9D4..B9E0] during the same window to determine which flag fires for the failing sector.

Or simpler: The fix is in runtime/src/memcard.c / runtime/src/sio.c. The SIO/card simulation handles sector 0 correctly but fails on sector ≥ 1. The issue is in hardware simulation, NOT recompiler/codegen. Inspect how the runtime simulates the multi-sector read protocol.

This session's findings invalidate the handoff's primary hypothesis (recomp never enters func_1FC1A000). The shell init chain IS reaching directory load. The blocker is the runtime's SIO card simulation failing sector ≥ 1 reads. Per the handoff: "Cycle-paced SIO works well enough for card detection. Sector-0 detection works." That hypothesis is correct, but sector ≥ 1 is the new blocker.

Failed attempt: 67 trampoline seeds (regressed shell main-menu transition). I added all 67 missing A0/B0/C0 trampolines (BFC0D8E0.. BFC0D940, BFC16550..BFC16750, BFC42xxx including BFC42BB0=B0:0x15) to recompiler/seeds/dispatch_miss_seeds.json, regenerated, rebuilt. Result: recomp never advanced past the MAIN MENU — pressing CROSS no longer transitioned to the memcard menu. Reverted seeds via git checkout HEAD -- recompiler/seeds/. Conclusion: dispatching the shell trampolines through recompiled C functions broke shell flow. The existing dirty_ram_dispatch handles them correctly; do not reseed.

Tool fix landed: runtime/src/debug_server.c::fn_dump_parse now defaults to a 1M-entry sliding window instead of the full 128M ring, preventing the multi-second freeze that previously made fn_entry_dump unusable on populated rings.

Next session — laser focus

The blocker is "find what kernel/shell function call chain should invoke FUN_bfc0c2e8 (BootInitMemcards) at boot, and why recomp's shell skips it." Suggested approach:

  1. Find the kernel function pointer table that contains 0xBFC0C2E8. The address must appear somewhere — maybe in a table populated at runtime by lui+addiu instructions in another function. Search Ghidra for BFC0 C2E8 halfword pairs.
  2. Look at SystemInit / boot chain (around 0xBFC00150 reset vector). Compare DuckStation/Beetle's static fn_trace through boot vs recomp's — find where the divergence first occurs.
  3. The goal is a single decision point in shell init code where recomp takes the "skip card init" branch and Beetle takes the "do card init" branch. Compare register state at that branch.

Issue #2 — fn_entry_dump / fn_exit_dump freeze the debug server on populated rings

Status: open Date opened: 2026-05-03

runtime/src/debug_server.c::handle_fn_entry_dump and handle_fn_exit_dump walk every entry in the 64K-cap ring (or rather the entire [seq_lo, seq_hi) window, which can be the full ring) applying the addr filter only AFTER constructing the per-entry buffer position. With 270M cumulative entries and a 1GB output buffer, a filtered query for a 0x200-byte addr range still iterates 134M entries before returning anything. Debug server is single-threaded so all other commands stall (including ping).

Fix: apply addr_lo/hi filter as the first check inside the loop before any string formatting; cap seq_hi - seq_lo to a sensible default (e.g. 2M) when the caller doesn't pass an explicit window.


Issue #3 — BIOS "PlayStation" disc-detected screen is missing the PS logo glyph

Status: open Date opened: 2026-05-11 Phase: 4 (BIOS shell render)

Symptom

After the Sony logo, the BIOS shows the second boot screen (the "PlayStation" disc-detected screen rendered when a disc is present). On this screen the top region — which on real hardware shows the stylized PS logo bitmap — is blank. The text below it ("PlayStation", license string, etc.) renders correctly.

Cosmetic only; boot continues, the disc loads, and Tomba's FMVs start. Logged here so we don't lose track of it once the FMV cluster is fixed.

Likely areas

  • GPU VRAM upload for the logo bitmap (CopyRectangle / CPU->VRAM transfer) may be dropping a region. The other tiles on the same screen render, so it isn't a wholesale VRAM/clut wipe.
  • Or the logo is sourced from a CD raw-sector / sector-header data path that the current cdrom.c whole-sector mode doesn't expose the way the BIOS expects.

Concrete next step

Take a Beetle screenshot of the same boot screen as oracle, diff the VRAM region the logo lives in (Beetle vs recomp at the same frame), and walk back from the missing pixels via wtrace on the source RAM/VRAM coordinates.


Issue #5 — Mid-function split targets not registered in dispatch table

Status: open, audit-surfaced Date opened: 2026-05-12 Phase: platform (recompiler emit, full_function_emitter path) Bug class: same shape as 2026-05-12 jump-table cross-function fix (full_function_emitter.cpp:777).

Symptom

When the recompiler emits a branch/jump whose target is a mid-function address (neither a known block in the current CFG nor a known function start), it emits call_by_address(cpu, 0xX); return; to defer dispatch to the runtime — but does NOT register 0xX in the dispatch table. At runtime, the dispatch binary-search misses, the target page isn't dirty, so psx_unknown_dispatch fires.

Affected emit sites in recompiler/src/code_generator.cpp

Line Pattern
970 conditional-branch taken-arm to mid-func target
981 conditional-branch fall-through to mid-func target
998 unconditional jump to mid-func target

All three should call register_cross_function_target(branch_target) the same way the jump-table emitter does (line 777 of full_function_emitter.cpp).

Evidence — Tomba audit, 2 manifestations

From codegen_audit_game.py --config game.toml:

[2] literal call_by_address targets: 333 unique, 459 sites
    dispatch table size: 1921 entries
    targets in declared code regions: 2
    targets in RAM (dirty-RAM interpreter domain): 331
    in-code targets MISSING from dispatch: 2
      0x800905E4  (1 site)
      0x80090600  (1 site)

Both addresses appear in generated/SCUS_942.36_full.c with comments /* taken: split (mid-func) */ and /* not taken: split (mid-func) */ — the recompiler EXPLICITLY knows these are mid-function splits but fails to register them.

The 331 RAM-domain targets are not bugs — they're runtime-loaded overlays correctly handled by dirty_ram_dispatch.

Concrete next step

  1. Add register_cross_function_target(branch_target) at the three call sites in code_generator.cpp (lines 970, 981, 998).
  2. Regen Tomba.
  3. Re-run codegen_audit_game.py — expect "in-code call_by_address misses: 0".
  4. Re-run on BIOS to confirm no regression.

Issue #4 — 7 unemitted GTE / COP2 instructions in BIOS Shell code

Status: open, audit-surfaced Date opened: 2026-05-12 Phase: 4 (BIOS shell render) Likely related to: Issue #3 (missing PS logo on disc-detected screen)

Symptom

The Phase B1 gte_audit (now generic, code-region-filtered) reports 4 missing gte_execute emits and 3 missing LWC2/SWC2 emits in the BIOS Shell code region. None of the affected PCs appear anywhere in generated/SCPH1001_full.c.

PC Word Class
0xBFC34FF8 0x4A480012 GTE MVMVA
0xBFC3502C 0x4A480012 GTE MVMVA
0xBFC35064 0x4A480012 GTE MVMVA
0xBFC350C4 0x4A480012 GTE MVMVA
(3 sites) LWC2 / SWC2 GTE load/store

These were previously masked by 73 data-region false positives in the old unfiltered tool. The B1 code-region filter surfaced them.

Likely cause

MVMVA is matrix-vector-multiply-and-add — the BIOS only uses GTE/3D math in two narrow places: the boot logo intro and the disc-detected/PlayStation-logo screen. We boot past both, but Issue #3's missing PS-logo glyph is consistent with these unemitted MVMVA + LWC2/SWC2 sites: if the recompiler skipped the 3D math that draws the logo, the logo would render as nothing or garbage but the surrounding text would be fine — exactly Issue #3's symptom.

Two possible root causes (not yet distinguished):

  • Discovery gap: the function containing these PCs was never identified, so nothing got emitted for it.
  • Emit gap: the function was discovered but the recompiler skipped these specific instructions when translating.

Concrete next step

Check whether ANY PC near 0xBFC34FF8 appears in generated/SCPH1001_full.c. If neighbors are emitted but the GTE sites aren't, it's an emit gap (fix in code_generator.cpp). If neighbors are absent too, it's a discovery gap (fix in function discovery seeds). Either way, then close Issue #3 alongside.

Issue #7 — sljit live execution is unvalidated (pure-live save-load wedge)

Status: CLOSED — superseded, not fixed. The sljit Tier-2 backend this issue describes was removed wholesale in 5b7e69b4 (2026-07-15), which deleted lib/sljit/, overlay_sljit.{c,h} and the sljit paths in code_provider.c / overlay_loader.c. The toolchain-free role it filled is now served by the bundled TinyCC tier (introduced c2a3f6ad, 2026-06-25), which compiles to a DLL through a subprocess rather than JITting in-process, so it does not share this defect's live-execution/save-load shape. Nothing here is actionable; retained as history. Date opened: 2026-06-15 Date closed: 2026-07-15 (by removal of the subsystem) Area: overlay Tier-2 sljit backend (runtime/src/overlay_loader.c, overlay_sljit.c, code_provider.c, overlay_sljit.c resolution)

Symptom

With PSX_OVERLAY_SLJIT_LIVE=1 (the prototype toolchain-less production path), save/load wedges the guest: reason=atexit, pc=0, dispatch looping a BIOS/kernel address (0xB0/0x650). Reproduces only in live mode — the dev differential path (overlay_diff_on) is clean (0 divergences / 395 shadow calls). Flagged in commit 81cf21b as Known Bug #1.

Root cause (NOT the emitter)

The MIPS→sljit emitter + block-local register allocator are proven correct this session: 66/66 emitter unit, 22/22 $sp-balance probe, and byte-identical to the pre-regalloc emitter across 313 overlay functions (runtime/tests/sljit_{emit_test,sp_probe,offline_diff}.c). The crash is in the live-execution wiring, which bypasses the validation harness:

  • run_shadow_diff (the device-touch detector + diff gate) is gated on s_diff_mode. Live mode sets s_sljit_live, not s_diff_mode, so in live mode the diff never runs. Therefore:
    • device_touch is never computed for a live shard → a device-touching function runs its shard and double-executes its SIO/memory-card/DMA I/O against real hardware state, corrupting the kernel byte-handler path → the atexit, pc=0, kernel-0xB0/0x650 livelock. (Known Bug #3 — leading cause.)
    • Shards run with diff_passes == 0, i.e. completely unvalidated. (Known Bug #2.)
  • Separately, the verify budget counts cumulative (not consecutive) clean passes — diff_passes never resets on a divergence — so even in dev mode an intermittently-wrong shard could reach budget on lucky passes.

The historical "$sp off by 0x18 at 0x12E478" crash was a different, already-fixed bug (diff over-scoping the call tree, fixed by per-function isolation in 81cf21b) — not this one, and not a codegen bug.

Fix (SLJIT.md §11 Phase A) — runtime-only, no regen

Collapse blind-live into validated-live: route live-mode shards through the diff gate ((s_diff_mode || s_sljit_live) at the dispatch gate), so device_touch is computed and a shard runs native live ONLY after a clean verify budget; and reset diff_passes to 0 on any divergence (consecutive-clean = "0 divergences"). After this there is no blind path: device + diverging shards stay on the interpreter.

  • gcc>sljit precedence not enforced (LIFO chain order can put an sljit shard ahead of a gcc shard for the same region). → Phase B.
  • Non-dev machine never triggers sljit generation in normal play (on-miss hook gated behind a dead env-only flag; auto resolves to gcc on every machine because autocompile_configured() tests for a config string, not a real toolchain). → Phase C.

End-to-end success criteria (per docs/internal/DEBUG.md end-to-end rule)

PSX_OVERLAY_SLJIT_LIVE=1 (or auto→sljit on a toolchain-less box): play through save-load, FMV, menus, and combat with 0 divergences, 0 wedges, shards validated-then-promoted, device functions correctly pinned to the interpreter. "Diff is clean" or "shard validated" is NOT success until the full save-load loop closes live.


Issue #8 — Tomba2 P0: wild-jump FAIL-FAST fatal after coverage-wave native promotion

Status: OPEN — root-cause hunt in flight, currently blocked on Issue #9 Date opened: 2026-07-06 (carried from session handoff) Area: overlay native execution (codegen OR dispatch/call-contract layer) Rule: root-cause via first-divergence; do NOT ship a block/disable. overlay_native_block on 0x80073328 acceptable for ATTRIBUTION only.

Symptom

Two FAIL-FAST unknown-dispatch fatals in attract soaks (~15–20 min apart per run) after the 2026-07-06 entry-based-coverage wave promoted new PCs to native (the convergence fixes work; they exposed a latent defect):

  • Crash 1: addr=ra=0x0000F514 (zeroed kernel heap, page not dirty), 4 seconds after 0x80073328 went native for the first time (its only-new-entry DLL 00038000_0A16928B built 09:04:14). 0x80073328 fired ra=0x80139400 every 2 frames all scene.
  • Crash 2: addr=0x009D6830, ra=0x00000001 (beyond 4× RAM mirror), pre-fatal register soup: target==ra; interp dispatches into 0x13xxxx drivers with garbage args (a0=0xDF73E104, a1=0xE2000318 = raw GP0 word as pointer).
  • Both: same kernel event-delivery dispatch chain (…0x20D4, 0x650, 0x2104) immediately before; a0=0x800F42E0 (persistent struct) at both fatals. Corruption PRECEDES the fatal; fail-fast catches the landing.

Evidence: memory tomba2_f514_wildjump_first_native.md + prior session's scratchpad crash_f514/ (psx_crash.txt, psx_last_run_report.json, psx_freeze_heartbeat.json).

Suspect set

The post-08:44 coverage wave DLLs in build-t2/cache/SCUS-94454/gcc/win-x64/cg4_d44a063d/: new region DLLs + island fragments (incl. F 80073328 in 00038000_0A16928B; F 80106D7C / F 80106E58 / F 80024548; jump-table-ladder aliases F 8011040C/43C/45C/478/4CC in 00106000_1675417D).

Plan (two independent paths)

  1. Instrumented path (preferred): finish the shadow-diff segment redesign (Issue #9), soak with PSX_OVERLAY_DIFF=1 from boot. Expect either a named divergence record (→ shard codegen defect, fix recompiler) or clean segments everywhere + crash persists (→ exit/call-contract class, bugA/bugD family — fix the dispatch contract layer).
  2. Bisection path (no instrument needed): quarantine halves of the post-08:44 DLL wave out of the cache dir, ~20-min attract soak per round (crash repro interval). First single-candidate probe: overlay_native_block on 0x80073328 (attribution only, never a fix).

Issue #9 — Overlay shadow-diff instrument is structurally unsound (never engages)

Status: ROOT-CAUSED; partial fixes in tree (uncommitted, _wt-tomba2-ipr); segment-granular redesign DESIGNED but NOT implemented Date opened: 2026-07-06 Area: runtime/src/overlay_loader.c (run_shadow_diff + dispatch gates), runtime/src/dirty_ram_interp.c, runtime/src/interrupts.c, runtime/src/traps.c Blocks: Issue #8 path 1. Full detail + design in memory tomba2_shadow_diff_unsound_segment_redesign.md.

Symptom

With overlay_diff_on / PSX_OVERLAY_DIFF=1, shadow_calls=1 over 51K+ frames (two independent runs) — the diff instrument validates essentially nothing while dispatch_native runs millions of calls. The game LOOKS fine, so the failure is silent.

Root cause chain (three stacked defects, found in order)

  1. CPS interior re-entry path had no diff gate — continuation re-entries (the dominant dispatch class under CPS) ran native blind in diff mode. FIXED in tree: gate mirrors the entry chain (interior_gated counter in overlay_shadow_dump).
  2. Longjmp-escape hazard — a shadow started inside an exception dispatch is unwound by the guest RFE's longjmp (setjmp frame below run_shadow_diff); epilogue skipped; s_in_shadow/s_native_exec/s_suppress_irq stuck forever. HARDENED in tree: g_exc_setjmp_epoch (interrupts.c) + overlay_loader_shadow_escape_fixup() at all 3 longjmp sites; dump fields escapes/escapes_native must stay 0.
  3. THE WEDGE (design flaw, predates this session): run_shadow_diff never terminates for non-returning functions. Interp pass = one dirty_ram_dispatch call (returns at first exit-to-elsewhere, NOT at stop_ra); native pass chases via psx_dispatch_call until pc==stop_ra — NEVER for a main-loop-shaped function. The live game then runs FOREVER inside the abandoned shadow native pass: speculative native state becomes the live timeline, IRQ-suppress stuck on, all downstream native disabled (native_exec=0). Verified live both runs: in_shadow=1, native_exec=0, escapes=0, wedge at frame ~1000. ⇒ Any pace/behavior data from a diff-mode run is garbage; the instrument was never capable of validating this game.

The fix (designed, ready to implement): CPS segment-granular diff

Diff exactly ONE c->fn invocation extent (entry/interior PC → first jal/jalr/jr/region-exit). Verified against generated DLL C: CPS codegen surfaces at EVERY call/return/indirect jump (no local jump-table switches), so a lean interp segment runner (in dirty_ram_interp.c; must intercept jal/jalr/jr/j BEFORE exec_one — exec_one nests calls inline) can run an identical extent. Both passes bounded by construction; no chasing, no fiber/RFE exposure; validates interior re-entries directly; compares pc (boundary target) as a first-class divergence signal. Abandon (no diff, interp state stands) on: syscall/break/rfe, unsupported insn, insn cap, MMIO (device_touch as today). Asymmetry guards: psx_slice_block no-fire in shadow (currently moot — slicing default OFF); cycle-counter snapshot/restore around the native pass; post-dispatch IRQ pump shadow guard (already in tree).

Uncommitted worktree state (this session, runtime-only, cg tag intact)

  • overlay_loader.c: CPS interior diff gate + interior_gated; in-exception shadow-start detour (superseded by segment design — remove when implementing); s_shadow_cand own-interior native route for shadow native pass; escape-fixup mirrors + overlay_loader_shadow_escape_fixup(); shadow dump fields in_shadow/native_exec/escapes/escapes_native; IRQ pump shadow guard.
  • interrupts.c: g_exc_setjmp_epoch + getter; fixup calls in deferred_exception_longjmp + psx_rfe_escape_check.
  • traps.c: fixup call at the deferred-honor longjmp site.
  • All inert when diff mode is off; normal play unaffected. Builds clean.