Files
psxrecomp/tools/window_capture.py
Matthew Stanley 4c39958226 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>
2026-07-03 09:04:17 -07:00

213 lines
7.0 KiB
Python

#!/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()