Files
psxrecomp/tools/host_thread_probe.py
Matthew Stanley 95ca9d80e5 runtime: fmv_skip_no_xa detection key + host_thread_probe tool
- [video] fmv_skip_no_xa: broaden FMV detection to MDEC activity alone.
  Silent RAM-preloaded movies never stream XA, so the MDEC+XA detector
  missed them and no skip mechanism was even attempted. Per-game opt-in.
- tools/host_thread_probe.py: suspend/sample RIP+RSP of every host
  thread of a live or wedged runtime, stack-scan return addresses into
  the main exe, addr2line-symbolize (RelWithDebInfo). Diagnosed the
  fatal-halt-serve loop in minutes; use whenever a runtime 'freezes'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:42:26 -07:00

243 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""host_thread_probe.py — where is every host thread of a (wedged) runtime?
Suspends each thread of the target process just long enough to capture RIP/RSP,
reads a slice of its stack, and reports, per thread:
- thread id + SetThreadDescription name (if any)
- RIP (resolved through addr2line when it lands in the main exe)
- a poor-man's backtrace: stack-scanned return addresses inside the main exe
Usage:
python tools/host_thread_probe.py --pid 12092 [--exe path\\to\\psx-runtime.exe]
[--stack-words 4096]
The exe path is only needed for addr2line symbolization (RelWithDebInfo/DWARF).
addr2line must be on PATH (mingw binutils). Safe on a live process: threads are
resumed immediately after context capture.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import struct
import subprocess
import sys
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)
TH32CS_SNAPTHREAD = 0x4
THREAD_ALL = 0x1FFFFF
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
CONTEXT_FULL_AMD64 = 0x10000B
class THREADENTRY32(ctypes.Structure):
_fields_ = [
("dwSize", wt.DWORD),
("cntUsage", wt.DWORD),
("th32ThreadID", wt.DWORD),
("th32OwnerProcessID", wt.DWORD),
("tpBasePri", wt.LONG),
("tpDeltaPri", wt.LONG),
("dwFlags", wt.DWORD),
]
class M128A(ctypes.Structure):
_fields_ = [("Low", ctypes.c_ulonglong), ("High", ctypes.c_longlong)]
class CONTEXT64(ctypes.Structure):
_align_ = 16
_fields_ = [
("P1Home", ctypes.c_ulonglong), ("P2Home", ctypes.c_ulonglong),
("P3Home", ctypes.c_ulonglong), ("P4Home", ctypes.c_ulonglong),
("P5Home", ctypes.c_ulonglong), ("P6Home", ctypes.c_ulonglong),
("ContextFlags", wt.DWORD), ("MxCsr", wt.DWORD),
("SegCs", wt.WORD), ("SegDs", wt.WORD), ("SegEs", wt.WORD),
("SegFs", wt.WORD), ("SegGs", wt.WORD), ("SegSs", wt.WORD),
("EFlags", wt.DWORD),
("Dr0", ctypes.c_ulonglong), ("Dr1", ctypes.c_ulonglong),
("Dr2", ctypes.c_ulonglong), ("Dr3", ctypes.c_ulonglong),
("Dr6", ctypes.c_ulonglong), ("Dr7", ctypes.c_ulonglong),
("Rax", ctypes.c_ulonglong), ("Rcx", ctypes.c_ulonglong),
("Rdx", ctypes.c_ulonglong), ("Rbx", ctypes.c_ulonglong),
("Rsp", ctypes.c_ulonglong), ("Rbp", ctypes.c_ulonglong),
("Rsi", ctypes.c_ulonglong), ("Rdi", ctypes.c_ulonglong),
("R8", ctypes.c_ulonglong), ("R9", ctypes.c_ulonglong),
("R10", ctypes.c_ulonglong), ("R11", ctypes.c_ulonglong),
("R12", ctypes.c_ulonglong), ("R13", ctypes.c_ulonglong),
("R14", ctypes.c_ulonglong), ("R15", ctypes.c_ulonglong),
("Rip", ctypes.c_ulonglong),
("FltSave", ctypes.c_byte * 512),
("VectorRegister", M128A * 26),
("VectorControl", ctypes.c_ulonglong),
("DebugControl", ctypes.c_ulonglong),
("LastBranchToRip", ctypes.c_ulonglong),
("LastBranchFromRip", ctypes.c_ulonglong),
("LastExceptionToRip", ctypes.c_ulonglong),
("LastExceptionFromRip", ctypes.c_ulonglong),
]
def thread_ids(pid):
snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0)
te = THREADENTRY32()
te.dwSize = ctypes.sizeof(THREADENTRY32)
tids = []
if k32.Thread32First(snap, ctypes.byref(te)):
while True:
if te.th32OwnerProcessID == pid:
tids.append(te.th32ThreadID)
if not k32.Thread32Next(snap, ctypes.byref(te)):
break
k32.CloseHandle(snap)
return tids
def thread_name(h):
GetThreadDescription = getattr(k32, "GetThreadDescription", None)
if not GetThreadDescription:
return ""
pstr = ctypes.c_wchar_p()
if GetThreadDescription(h, ctypes.byref(pstr)) >= 0 and pstr.value:
name = pstr.value
k32.LocalFree(pstr)
return name
return ""
def main_module_range(hproc):
mods = (ctypes.c_void_p * 1024)()
needed = wt.DWORD()
if not psapi.EnumProcessModules(hproc, mods, ctypes.sizeof(mods),
ctypes.byref(needed)):
return None, None, ""
class MODULEINFO(ctypes.Structure):
_fields_ = [("lpBaseOfDll", ctypes.c_void_p),
("SizeOfImage", wt.DWORD),
("EntryPoint", ctypes.c_void_p)]
mi = MODULEINFO()
psapi.GetModuleInformation.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.c_void_p, wt.DWORD]
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.c_wchar_p, wt.DWORD]
psapi.GetModuleInformation(hproc, mods[0], ctypes.byref(mi),
ctypes.sizeof(mi))
namebuf = ctypes.create_unicode_buffer(1024)
psapi.GetModuleFileNameExW(hproc, mods[0], namebuf, 1024)
return mi.lpBaseOfDll, mi.SizeOfImage, namebuf.value
def read_mem(hproc, addr, size):
buf = ctypes.create_string_buffer(size)
got = ctypes.c_size_t()
if not k32.ReadProcessMemory(hproc, ctypes.c_void_p(addr), buf, size,
ctypes.byref(got)):
return b""
return buf.raw[: got.value]
def addr2line(exe, image_base, addrs, link_base=0x140000000):
"""Map runtime VAs to source lines. link_base = PE default for exes."""
if not addrs:
return {}
rebased = [hex(a - image_base + link_base) for a in addrs]
try:
out = subprocess.run(
["addr2line", "-f", "-C", "-e", exe] + rebased,
capture_output=True, text=True, timeout=60).stdout.splitlines()
except (FileNotFoundError, subprocess.TimeoutExpired):
return {}
res = {}
for i, a in enumerate(addrs):
fn = out[2 * i] if 2 * i < len(out) else "?"
loc = out[2 * i + 1] if 2 * i + 1 < len(out) else "?"
res[a] = f"{fn} [{loc}]"
return res
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pid", type=int, required=True)
ap.add_argument("--exe", default="", help="exe path for addr2line")
ap.add_argument("--stack-words", type=int, default=4096)
args = ap.parse_args()
hproc = k32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
False, args.pid)
if not hproc:
sys.exit(f"OpenProcess failed (err {ctypes.get_last_error()})")
base, size, mainmod = main_module_range(hproc)
base = base or 0
print(f"main module: {mainmod} base=0x{base:X} size=0x{size or 0:X}")
exe = args.exe or mainmod
reports = []
for tid in thread_ids(args.pid):
h = k32.OpenThread(THREAD_ALL, False, tid)
if not h:
continue
name = thread_name(h)
ctx = CONTEXT64()
ctx.ContextFlags = CONTEXT_FULL_AMD64
rip = rsp = 0
if k32.SuspendThread(h) != wt.DWORD(-1).value:
if k32.GetThreadContext(h, ctypes.byref(ctx)):
rip, rsp = ctx.Rip, ctx.Rsp
k32.ResumeThread(h)
k32.CloseHandle(h)
frames = []
stack_read = 0
if rsp:
raw = read_mem(hproc, rsp, args.stack_words * 8)
if not raw:
# straddled an unreadable page boundary: retry in 4K chunks
chunks = []
for pg in range(0, args.stack_words * 8, 4096):
c = read_mem(hproc, rsp + pg, 4096)
if not c:
break
chunks.append(c)
raw = b"".join(chunks)
stack_read = len(raw)
for off in range(0, len(raw) - 7, 8):
(val,) = struct.unpack_from("<Q", raw, off)
if base <= val < base + (size or 0):
frames.append(val)
reports.append((tid, name, rip, rsp, frames, stack_read))
want = set()
for _, _, rip, _, frames, _ in reports:
if base <= rip < base + (size or 0):
want.add(rip)
want.update(frames[:40])
sym = addr2line(exe, base, sorted(want)) if exe else {}
for tid, name, rip, rsp, frames, stack_read in reports:
in_exe = base <= rip < base + (size or 0)
riploc = sym.get(rip, "") if in_exe else "(outside main exe)"
print(f"\n== tid {tid} {name!r} RIP=0x{rip:X} RSP=0x{rsp:X} "
f"stack_read={stack_read} {riploc}")
seen = set()
shown = 0
for f in frames:
s = sym.get(f, hex(f))
if s in seen:
continue
seen.add(s)
print(f" {hex(f)} {s}")
shown += 1
if shown >= 25:
break
k32.CloseHandle(hproc)
if __name__ == "__main__":
main()