From 4c399582266489a9fc8dbafff4ea70f4c83842b2 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 3 Jul 2026 09:04:17 -0700 Subject: [PATCH] =?UTF-8?q?dispatch:=20Class-A=20shell-window=20fix=20cove?= =?UTF-8?q?rs=20OVERLAY=20code=20=E2=80=94=20Tomba2=20Whoopee-logo=20wedge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the fresh-launch logo freeze (present at every commit in the stack; window-capture-verified 13/13 repros, never a regression): the BIOS dispatch normalize() maps RAM phys 0x30000-0x5AFFF to the shell ROM alias. The Class-A fix routed post-game-start shell-window targets to the game/dirty path first, but its gate also required psx_game_address_in_text() — which exempts runtime-loaded OVERLAY code above the boot-EXE text end. Tomba2's text ends at 0x38800; its post-logo main-thread resume PC 0x50B80 fell through to normalize() and executed func_1FC38B80, a stale shell-image data-as-code self-loop, starving the main thread forever (one VBlank delivery per frame at the same EPC, zero main-thread instructions retired; the game's ISR ran fine, so TCP frame counters kept ticking — the freeze was invisible to every telemetry-only validation). Even PSX_FORCE_INTERP missed it: normalize() shadowed the address before the interp path saw it. Fix: post-game-start, the WHOLE shell-overlap window routes to dirty_ram_dispatch before normalize() — real hardware executes the RAM bytes regardless of who loaded them (boot EXE, overlay loader, DMA stream). Pre-start shell addresses still normalize to the shell as before. Also: - interrupts/debug_server: nestgate_* per-guard veto-attribution counters on the nested-delivery gate (freeze_check), used to exonerate the IEc/SR guards and redirect the hunt from interrupts.c to dispatch. - tools/window_capture.py: PrintWindow-based SDL-window capture with LIVE/FROZEN series verdict — window-liveness ground truth; TCP/VRAM screenshots provably lie about presentation (this bug's entire history). Validated: fresh launch now runs logo -> intro FMV -> title -> attract with sustained full-frame motion (window-level capture series). Co-Authored-By: Claude Fable 5 --- recompiler/src/full_function_emitter.cpp | 25 ++- runtime/src/debug_server.c | 12 +- runtime/src/interrupts.c | 11 ++ tools/window_capture.py | 212 +++++++++++++++++++++++ 4 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 tools/window_capture.py diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 181a38f1..d2c69a31 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1437,19 +1437,26 @@ void FullFunctionEmitter::emit_dispatch( out += " * path before normalizing it to shell ROM. */\n"; out += " uint32_t game_phys = addr & 0x1FFFFFFFu;\n"; out += " /* Class-A shell-window collision fix: post-game-start the BIOS shell\n"; - out += " * copy at RAM 0x30000-0x5AFFF is DEAD (overwritten by the game EXE),\n"; - out += " * so normalize()->shell ROM for a shell-window game address is stale\n"; - out += " * and runs dead shell code (func_1FC42090 -> null jalr -> pc=0, the\n"; - out += " * MMX6 boot wedge class A). Once the game has started, route the\n"; - out += " * shell-overlap window to the GAME image BEFORE normalize() can shadow\n"; - out += " * it to the shell. dirty_ram_dispatch -> psx_dispatch_game_compiled\n"; - out += " * returns 0 for a non-game PC, so a genuine pre-start shell address\n"; + out += " * copy at RAM 0x30000-0x5AFFF is DEAD (overwritten by the game EXE,\n"; + out += " * its runtime-loaded overlays, or CD streaming), so normalize()->shell\n"; + out += " * ROM for a shell-window address is stale and runs dead shell code\n"; + out += " * (func_1FC42090 -> null jalr -> pc=0, the MMX6 boot wedge class A;\n"; + out += " * func_1FC38B80 data-as-code self-loop starving the main thread, the\n"; + out += " * Tomba2 Whoopee-logo wedge). Once the game has started, route the\n"; + out += " * WHOLE shell-overlap window to the game/overlay/dirty-RAM path\n"; + out += " * BEFORE normalize() can shadow it to the shell \xE2\x80\x94 real hardware\n"; + out += " * executes the RAM bytes, whoever loaded them (boot EXE in text,\n"; + out += " * runtime overlay above text-end, DMA stream). The old gate also\n"; + out += " * required psx_game_address_in_text(), which silently exempted\n"; + out += " * OVERLAY code above the boot-EXE text end (Tomba2 text ends\n"; + out += " * 0x38800; its post-logo ISR-wait caller lives at 0x50B80) and sent\n"; + out += " * it to the stale shell image. A genuine pre-start shell address\n"; out += " * (game not started) still falls through to normalize(). */\n"; out += " extern int fntrace_is_game_started(void);\n"; out += " int game_shell_overlap = fntrace_is_game_started() &&\n"; out += " game_phys >= 0x00030000u && game_phys <= 0x0005AFFFu;\n"; - out += " if (!found && psx_game_address_in_text(addr) &&\n"; - out += " (dirty_ram_is_dirty(game_phys) || game_shell_overlap)) {\n"; + out += " if (!found && (game_shell_overlap ||\n"; + out += " (psx_game_address_in_text(addr) && dirty_ram_is_dirty(game_phys)))) {\n"; out += " found = dirty_ram_dispatch(cpu, addr, stop_addr);\n"; out += " }\n"; out += "#endif\n"; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 496a9cce..1f5a97d9 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7853,6 +7853,8 @@ static void handle_freeze_check(int id, const char *json) extern uint64_t g_sentinel_reach_dirty; extern uint64_t g_sentinel_reach_traps; extern uint32_t g_sentinel_reach_async; + extern uint64_t g_nestgate_depth, g_nestgate_rfepend, + g_nestgate_escreason, g_nestgate_iec; /* Top-K fn_entry histogram over the last `window` slots. */ typedef struct { uint32_t func; uint32_t count; } HistBucket; @@ -7973,6 +7975,10 @@ static void handle_freeze_check(int id, const char *json) "\"pczero_in_exc\":%u," "\"pczero_async_rfe\":\"0x%08X\"," "\"pczero_dirty_safe\":\"0x%08X\"," + "\"nestgate_depth\":%llu," + "\"nestgate_rfepend\":%llu," + "\"nestgate_escreason\":%llu," + "\"nestgate_iec\":%llu," "\"hist\":[", id, g_debug_current_func_addr, @@ -8031,7 +8037,11 @@ static void handle_freeze_check(int id, const char *json) g_pczero_ra, g_pczero_in_exc, g_pczero_async_rfe, - g_pczero_dirty_safe); + g_pczero_dirty_safe, + (unsigned long long)g_nestgate_depth, + (unsigned long long)g_nestgate_rfepend, + (unsigned long long)g_nestgate_escreason, + (unsigned long long)g_nestgate_iec); for (int i = 0; i < hist_n && pos < sizeof(buf) - 64; i++) { pos += snprintf(buf + pos, sizeof(buf) - pos, "%s{\"func\":\"0x%08X\",\"count\":%u}", diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 1f3110c5..4f08e712 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -348,6 +348,9 @@ static uint64_t exception_reentry_blocks; * synchronous-handler recursion is so the nested-delivery path (hardware * IEc-re-enabled semantics, see the gate below) can cap host-stack use. */ static int exception_nest_depth; +/* Nested-delivery gate veto attribution (freeze_check). One counter per guard + * in the refinement gate below; first-true-wins ordering matches the gate. */ +uint64_t g_nestgate_depth, g_nestgate_rfepend, g_nestgate_escreason, g_nestgate_iec; /* After the exception handler returns, suppress the next interrupt delivery * to give the interrupted code at least one block of execution — matching @@ -675,6 +678,14 @@ void psx_check_interrupts(CPUState* cpu) { g_rfe_escape_pending || g_exc_escape_reason != PSX_EXC_ESCAPE_NONE || !(cpu->cop0[COP0_SR] & 0x01)) { + /* Per-guard veto attribution (always-on counters, freeze_check): + * which refinement is actually vetoing nested delivery. A wedge + * where ONE guard dominates for thousands of frames names the + * false veto (Tomba2 Whoopee-logo forensics 2026-07-03). */ + if (exception_nest_depth >= 2) g_nestgate_depth++; + else if (g_rfe_escape_pending) g_nestgate_rfepend++; + else if (g_exc_escape_reason != PSX_EXC_ESCAPE_NONE) g_nestgate_escreason++; + else g_nestgate_iec++; exception_reentry_blocks++; irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0); #ifdef PSX_COSIM diff --git a/tools/window_capture.py b/tools/window_capture.py new file mode 100644 index 00000000..c0857ab6 --- /dev/null +++ b/tools/window_capture.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +"""window_capture.py -- capture the ACTUAL on-screen content of a top-level +window (SDL/GL/software, any runtime) via PrintWindow(PW_RENDERFULLCONTENT). + +This is the window-liveness ground truth. The debug-server `screenshot` / +`screenshot_file` commands render from GPU/VRAM display state and say NOTHING +about whether the SDL window is presenting -- they lied about the Tomba2 +Whoopee-logo freeze (2026-07-02). This tool reads what the user's monitor +shows. + +Requires a Windows-native python (C:/msys64/mingw64/bin/python.exe -- the +MSYS/devkitPro pythons have no ctypes.windll). + +Usage: + window_capture.py --title "Tomba! 2 Recompiled" --out shot.bmp + window_capture.py --title "Tomba! 2 Recompiled" --series 10 --interval 1.0 \ + --out-dir _wincap + -> saves _wincap/cap_00.bmp .. cap_09.bmp, prints per-pair changed-pixel + counts and a LIVE/FROZEN verdict (FROZEN = every consecutive pair + differs in < --min-changed pixels). + +Exit codes: 0 = captured (series: LIVE), 2 = window not found, + 3 = series verdict FROZEN, 4 = capture failed. +""" +import argparse +import ctypes +import ctypes.wintypes as wt +import os +import struct +import sys +import time + +user32 = ctypes.windll.user32 +gdi32 = ctypes.windll.gdi32 + +PW_RENDERFULLCONTENT = 0x00000002 +SRCCOPY = 0x00CC0020 +BI_RGB = 0 +DIB_RGB_COLORS = 0 + + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ("biSize", wt.DWORD), ("biWidth", ctypes.c_long), + ("biHeight", ctypes.c_long), ("biPlanes", wt.WORD), + ("biBitCount", wt.WORD), ("biCompression", wt.DWORD), + ("biSizeImage", wt.DWORD), ("biXPelsPerMeter", ctypes.c_long), + ("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wt.DWORD), + ("biClrImportant", wt.DWORD), + ] + + +def find_window(title_substr): + """Return (hwnd, title) of the first visible top-level window whose title + contains title_substr (case-insensitive).""" + matches = [] + needle = title_substr.lower() + + @ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM) + def cb(hwnd, lparam): + if not user32.IsWindowVisible(hwnd): + return True + n = user32.GetWindowTextLengthW(hwnd) + if n == 0: + return True + buf = ctypes.create_unicode_buffer(n + 1) + user32.GetWindowTextW(hwnd, buf, n + 1) + if needle in buf.value.lower(): + matches.append((hwnd, buf.value)) + return True + + user32.EnumWindows(cb, 0) + return matches[0] if matches else (None, None) + + +def capture(hwnd): + """PrintWindow the client area; return (width, height, bgra_bytes).""" + rc = wt.RECT() + user32.GetClientRect(hwnd, ctypes.byref(rc)) + w, h = rc.right - rc.left, rc.bottom - rc.top + if w <= 0 or h <= 0: + return None + + hdc_win = user32.GetDC(hwnd) + hdc_mem = gdi32.CreateCompatibleDC(hdc_win) + hbmp = gdi32.CreateCompatibleBitmap(hdc_win, w, h) + gdi32.SelectObject(hdc_mem, hbmp) + + ok = user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT) + if not ok: + # Fallback: BitBlt from the window DC (fails if occluded, but better + # than nothing for windows that reject PrintWindow). + ok = gdi32.BitBlt(hdc_mem, 0, 0, w, h, hdc_win, 0, 0, SRCCOPY) + + data = None + if ok: + bih = BITMAPINFOHEADER() + bih.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bih.biWidth = w + bih.biHeight = -h # top-down + bih.biPlanes = 1 + bih.biBitCount = 32 + bih.biCompression = BI_RGB + buf = ctypes.create_string_buffer(w * h * 4) + got = gdi32.GetDIBits(hdc_mem, hbmp, 0, h, buf, + ctypes.byref(bih), DIB_RGB_COLORS) + if got == h: + data = buf.raw + + gdi32.DeleteObject(hbmp) + gdi32.DeleteDC(hdc_mem) + user32.ReleaseDC(hwnd, hdc_win) + return (w, h, data) if data else None + + +def save_bmp(path, w, h, bgra): + """Write a 32bpp top-down BMP.""" + img_size = w * h * 4 + hdr = struct.pack("<2sIHHI", b"BM", 14 + 40 + img_size, 0, 0, 14 + 40) + dib = struct.pack("= args.min_changed + moving_pairs += moving + print("pair %02d->%02d: %d px changed %s" + % (i - 1, i, d, "(MOTION)" if moving else "")) + + if moving_pairs == 0: + print("VERDICT: FROZEN (0/%d pairs show motion)" % (len(shots) - 1)) + sys.exit(3) + print("VERDICT: LIVE (%d/%d pairs show motion)" + % (moving_pairs, len(shots) - 1)) + sys.exit(0) + + +if __name__ == "__main__": + main()