mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
dispatch: Class-A shell-window fix covers OVERLAY code — Tomba2 Whoopee-logo wedge
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<IiiHHIIiiII", 40, w, -h, 1, 32, 0, img_size,
|
||||
2835, 2835, 0, 0)
|
||||
with open(path, "wb") as f:
|
||||
f.write(hdr)
|
||||
f.write(dib)
|
||||
f.write(bgra)
|
||||
|
||||
|
||||
def diff_pixels(a, b):
|
||||
"""Count differing 4-byte pixels between two equal-length BGRA buffers."""
|
||||
if len(a) != len(b):
|
||||
return -1
|
||||
n = 0
|
||||
# Compare in 4-byte strides; memoryview slicing keeps this tolerable.
|
||||
ma, mb = memoryview(a), memoryview(b)
|
||||
step = 4096 # bytes per chunk
|
||||
changed = 0
|
||||
for off in range(0, len(a), step):
|
||||
ca, cb = ma[off:off + step], mb[off:off + step]
|
||||
if ca == cb:
|
||||
continue
|
||||
for p in range(0, len(ca), 4):
|
||||
if ca[p:p + 4] != cb[p:p + 4]:
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--title", required=True,
|
||||
help="window title substring (case-insensitive)")
|
||||
ap.add_argument("--out", help="single-shot output BMP path")
|
||||
ap.add_argument("--series", type=int, default=0,
|
||||
help="capture N shots and judge LIVE/FROZEN")
|
||||
ap.add_argument("--interval", type=float, default=1.0,
|
||||
help="seconds between series shots")
|
||||
ap.add_argument("--out-dir", default="_wincap",
|
||||
help="series output directory")
|
||||
ap.add_argument("--min-changed", type=int, default=200,
|
||||
help="pixels that must change between consecutive shots "
|
||||
"for the pair to count as motion")
|
||||
args = ap.parse_args()
|
||||
|
||||
hwnd, title = find_window(args.title)
|
||||
if not hwnd:
|
||||
print("WINDOW-NOT-FOUND: no visible window matching %r" % args.title)
|
||||
sys.exit(2)
|
||||
print("window: hwnd=0x%X title=%r" % (hwnd, title))
|
||||
|
||||
if args.series <= 0:
|
||||
shot = capture(hwnd)
|
||||
if not shot:
|
||||
print("CAPTURE-FAILED")
|
||||
sys.exit(4)
|
||||
w, h, data = shot
|
||||
out = args.out or "wincap.bmp"
|
||||
save_bmp(out, w, h, data)
|
||||
print("saved %s (%dx%d)" % (out, w, h))
|
||||
sys.exit(0)
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
shots = []
|
||||
for i in range(args.series):
|
||||
s = capture(hwnd)
|
||||
if not s:
|
||||
print("CAPTURE-FAILED at shot %d" % i)
|
||||
sys.exit(4)
|
||||
w, h, data = s
|
||||
p = os.path.join(args.out_dir, "cap_%02d.bmp" % i)
|
||||
save_bmp(p, w, h, data)
|
||||
shots.append(data)
|
||||
print("shot %02d saved (%dx%d)" % (i, w, h))
|
||||
if i + 1 < args.series:
|
||||
time.sleep(args.interval)
|
||||
|
||||
moving_pairs = 0
|
||||
for i in range(1, len(shots)):
|
||||
d = diff_pixels(shots[i - 1], shots[i])
|
||||
moving = d >= 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()
|
||||
Reference in New Issue
Block a user