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>
32 KiB
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 PC0xBFC14CE0(= RAM 0x51E0) every 2 frames before callingfunc_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 (between0x5B64,0x5688,0x51F4) — the swap mechanism works. Beetle just performs additional swaps from callersBFC08F28andBFC09250that recomp never reaches. func_00006380(writesmem[0x75C0]=1) and the B-table trampolines atBFC0DA30/BFC0DA40(B0:0x58, B0:0x4D_card_status) DO run in recomp —BFC0DA30620×,BFC0DA40618×. 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
- What calls
func_1FC08B3C? Find the static caller (or callers) of ROM0xBFC08B3C. Use Ghidraxrefs 0xBFC08B3Cto enumerate. - Does recomp ever reach
func_000005E0(B0 dispatcher) andfunc_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-entryfn_entry_dump. - Counter-mismatch hypothesis: in
func_00004D6C, the FP-call chooses between several outcomes based onmem[0x7514]-1.func_00005B64(the dispatcher FP) returns -1 when counter=0, forcing the 0x21 path. Both sides clearmem[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 + beetletools/_dump_gate_seq.py— dump gate writes in seq order withpc/fn/ra/wfields (extends earlier handoff tool)tools/_dump_addrs.py— group wtrace by addr, show distinct writers per addrtools/_dump_7520_beetle.py— filter beetle wtrace by addr rangetools/_dump_ram_words.py— RAM dump as 4-byte words, both backendstools/_read_7520.py— sample mem[0x7520], 0x755A, 0x7568tools/_screenshot_meta.py— call screenshot/screenshot_file/ emu_screenshottools/check_pc_dispatch.py— groupfn_entry_dumpbyfunc_addrin a rangetools/press_into_card_view_recomp.py— mirror Beetle's press_into_card_view but for recomptools/arm_gate_trace.pyextended 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=0x8003202Cjumping to FUN_bfc09914- caller PC
0x000005D8is INSIDE the kernel A0/B0 dispatcher area (A0 dispatcher entry is0x000005C4, B0 dispatcher is0x000005E0) ra=0x8003202Cmeans the JAL that started this chain was at0x80032024— which translates to ROM0xBFC1A024- 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 0xB005A990at PC0xBFC1A024. The trampoline at0xBFC42990is A0:0x70, which the kernel A-table dispatches toFUN_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
- Find what calls
FUN_bfc1a000on Beetle. Arm0x1FC1A000in Beetle fn_trace and capture the caller. (One JAL produces the chain.) - On recomp, check whether
func_1FC1A000is ever entered. If yes: capture$t9value at entry. If $t9!=0, find what set it. If no: trace the upstream call chain to find why the JAL tofunc_1FC1A000doesn't fire. - 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 to1, meaning all 5 inner JALs (InitCARD, StartCARD, ChangeClearPad, A0:0x70, A0:0xAD) executed. FUN_bfc09914is entered at frame 617/2344 with$a0=0(ra=0x8003202C— confirming the JAL chain from BootInitMemcardsShell).FUN_bfc08b3cis entered 12 times across both slots: frame 2344/2350 (initial calls from FUN_bfc09914 withra=BFC09934,BFC0993C), then frames 2372/2385/2426/2433 (subsequent calls fromFUN_bfc089c0ra=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 trampolineBFC0DA00) 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..]=0xFFFFFFFFis exactly the bail-clear pattern (for (i=0; i<0x14; i++) puVar10[i]= 0xFFFFFFFF). Andmem[0xA000BA88]=0is the bail-clear for the directory area. - The validator
FUN_bfc08720is 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_readreturns!= 1for sector ≥ 1 (i.e. recomp's SIO card simulation only completes sector-0 reads correctly), OR - (b)
FUN_bfc09144returns 0 — meaning one of the error flagsmem[0xA000B9D4..B9E0]was set instead of the success flagmem[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:
- Find the kernel function pointer table that contains
0xBFC0C2E8. The address must appear somewhere — maybe in a table populated at runtime bylui+addiuinstructions in another function. Search Ghidra forBFC0C2E8halfword pairs. - Look at SystemInit / boot chain (around
0xBFC00150reset vector). Compare DuckStation/Beetle's static fn_trace through boot vs recomp's — find where the divergence first occurs. - 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.cwhole-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
- Add
register_cross_function_target(branch_target)at the three call sites incode_generator.cpp(lines 970, 981, 998). - Regen Tomba.
- Re-run
codegen_audit_game.py— expect "in-code call_by_address misses: 0". - 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 ons_diff_mode. Live mode setss_sljit_live, nots_diff_mode, so in live mode the diff never runs. Therefore:device_touchis 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 → theatexit, pc=0, kernel-0xB0/0x650livelock. (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_passesnever 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.
Related gaps (same area, tracked in SLJIT.md §11 Phases B–C)
- 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;
autoresolves to gcc on every machine becauseautocompile_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_0A16928Bbuilt 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)
- Instrumented path (preferred): finish the shadow-diff segment redesign
(Issue #9), soak with
PSX_OVERLAY_DIFF=1from 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). - 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_blockon 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)
- 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_gatedcounter inoverlay_shadow_dump). - 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 fieldsescapes/escapes_nativemust stay 0. - 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_candown-interior native route for shadow native pass; escape-fixup mirrors +overlay_loader_shadow_escape_fixup(); shadow dump fieldsin_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.