From 2cbbbfc4daa235d25ad7b75174e19d6bb40866ac Mon Sep 17 00:00:00 2001 From: Matthew Stanley Date: Sun, 23 Aug 2026 11:57:29 -0700 Subject: [PATCH] observability: window traps that read as results, and a per-game watch list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related defects in the interp observability rings. All three share a failure mode that is worse than an error: the tool reports an empty result that is indistinguishable from a real finding. "total: 0" reads as proof the events never happened. 1. callret_watch armed on `lo != 0`, making address 0 an off switch rather than a legal window floor. `lo=0 hi=0x200000` — the obvious spelling of "watch the whole address space" — replied ok and recorded nothing. Measured on one build, identical but for the floor: lo=0x00000000 hi=0x00200000 -> total 0 lo=0x00000004 hi=0x00200000 -> total 468732 Four bytes of window between an empty ring and half a million call resolutions. This cost real time during beads-eio.3.59: an interp->compiled call route was briefly believed absent when the ring was simply never on. Arm on a NON-EMPTY window (hi > lo) instead. `lo=0` is now an ordinary floor. Note that `{"lo":"0"}` was the DOCUMENTED disarm spelling (TCP_COMMANDS.md), so that contract is kept explicitly rather than broken: lo=0 with no hi still disarms, and now says so in the reply. Added `disarm=true` as the unambiguous spelling. A `lo` with no `hi` is refused outright instead of silently inheriting the previous ceiling (usually 0, i.e. an empty window) — that was the second way to arm nothing by accident. 2. s3_smear_watch had the same `lo`-nonzero gate, so lo=0 with a real hi recorded nothing while answering ok. Same fix. It already cleared `hi` whenever `hi` was omitted, so `{"lo":"0"}` naturally lands on an empty window and still disarms — no special case needed there. Both commands now report `armed`, and callret_watch's dump carries `armed` plus the active window, so an empty read can always be told apart from an unarmed one. 3. The xprobe JAL/JALR watched-target list was six guest addresses from two specific titles COMPILED INTO the shared runtime (an MMX6 card-load flow and a Tomba target). Every other game therefore watched nothing, silently: the `watched` dump came back empty and gave no hint that the build was incapable of watching anything. Same defect class as beads-eio.3.21, and the direct reason the JAL call site was unusable while measuring beads-eio.3.59 — only the JALR callret ring could be used there. The list is now configured, never compiled in: `xprobe_watch` TCP command to get/set, or PSX_XPROBE_WATCH in the environment so targets are watched from instruction zero without a rebuild. Default EMPTY — no title is privileged — and a read with nothing watched says so rather than looking like a negative result. Matched on the normalised address so KSEG and physical forms both work. Verified against Ape Escape, headless, RelWithDebInfo: - PSX_XPROBE_WATCH=0x8002C0E4 -> watching that target from boot - xprobe_watch targets= -> count 2; the `watched` dump then carries 41 JALR records for 0x8002C0E4 and 21 for 0x8002C14C, with full frame/cycle/src/sp/ra context. This title could previously watch NOTHING. - xprobe_watch targets= (empty) -> count 0 plus an explanatory note - callret_watch lo=0 hi=0x200000 -> armed true, total 77484 - callret_watch lo=0 -> disarmed, legacy spelling named - callret_watch lo=0x40000 -> refused, window left unchanged - callret_watch disarm=1 -> disarmed - s3_smear_watch lo=0 hi=0x200000 -> armed true (previously silent) - pre-fix build, callret lo=0 hi=0x200000 -> plain ok, total 0 (the trap) TCP_COMMANDS.md regenerated for the new command. Note the regen also picks up rows that had drifted on master beforehand: `--check` already failed on pristine master (index said 292, the servers register 304), which is why the count line jumps 292 -> 305 rather than by one. --- TCP_COMMANDS.md | 78 +++++++++++++++--------- runtime/src/debug_server.c | 105 ++++++++++++++++++++++++++++++--- runtime/src/dirty_ram_interp.c | 91 +++++++++++++++++++++++++--- 3 files changed, 230 insertions(+), 44 deletions(-) diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index ddb53ac38..cbbdc778e 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -92,30 +92,30 @@ Columns: **N** = native, **D** = DuckStation oracle. | `pc_hit_clear` | | ✓² | — | Clear the last-hit record | | `quit` | ✓ | | — | Shutdown native runtime | -¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. -² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). - -### Boot-time write ranges - -Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to -retain the first writes to one or more half-open RAM ranges from guest -instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are -normalized to physical addresses. For example, the Crash Bash investigation -that motivated this option can be reproduced without title-specific code: - -```powershell -$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' -.\CrashBashRecomp.exe -``` - -Connect at any later point and query `wtrace_boot_stats`, -`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the -write address/value/width, guest PC and return address, register context, frame, -and DMA channel. The option is ignored in builds made with debug tools disabled. - ---- - -## Divergence-hunt workflow +¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. +² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). + +### Boot-time write ranges + +Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to +retain the first writes to one or more half-open RAM ranges from guest +instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are +normalized to physical addresses. For example, the Crash Bash investigation +that motivated this option can be reproduced without title-specific code: + +```powershell +$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' +.\CrashBashRecomp.exe +``` + +Connect at any later point and query `wtrace_boot_stats`, +`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the +write address/value/width, guest PC and return address, register context, frame, +and DMA channel. The option is ignored in builds made with debug tools disabled. + +--- + +## Divergence-hunt workflow When a recompiled-BIOS bug is suspected, the two servers let you find the **first** divergence instead of chasing symptoms. Standard procedure (inherited from v3's `DEBUG.md`): @@ -214,7 +214,16 @@ ring names the return path that let it come back. `sp_b`/`ra_b`/`s0_b`/`s3_b`, post-call `pc_a`/`ra_a`/`sp_a`/`s0_a`/ `s3_a`/`v0_a`, `bail`/`rfe`/`esc`/`in_exc` flags, `dstatic`/`dblocks`/ `dexc` engine-attribution deltas across the call, `last_func`. -- `{"cmd":"callret_watch","lo":"0"}` — disarm. +- `{"cmd":"callret_watch","disarm":true}` — disarm. The legacy + `{"lo":"0"}` spelling (no `hi`) still disarms and says so in the reply. + +The ring is armed iff the window is NON-EMPTY (`hi > lo`), so `lo` may be +`0` — `{"lo":"0","hi":"0x200000"}` really does watch the whole address +space. Every reply carries `armed`, and the dump carries `armed` plus the +active window, because an unarmed ring reports `total: 0` — which otherwise +reads exactly like a genuine "those calls never happened" answer. Passing +`lo` with no `hi` is refused rather than silently inheriting the previous +ceiling (usually `0`, i.e. an empty window). ## `hle_dump` — BIOS-HLE tier call ring (native only) @@ -263,9 +272,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**292 commands registered** — 279 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). - -47 of 292 have prose above; **245 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +**305 commands registered** — 292 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). + +49 of 305 have prose above; **256 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -289,6 +298,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `card_buffer_dump` | ✓ | | | | `card_data_writes` | ✓ | | | | `card_data_writes_reset` | ✓ | | | +| `card_handoff` | ✓ | | | | `card_mgr_clear` | ✓ | | | | `card_mgr_trace` | ✓ | | | | `card_read_summary` | ✓ | | | @@ -379,6 +389,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_timeseries` | ✓ | ✓ | ✓ | | `freeze_check` | ✓ | | | | `game_options` | ✓ | | | +| `geom_correction` | ✓ | | ✓ | | `get_frame` | ✓ | ✓ | ✓ | | `get_quads` | ✓ | | | | `get_registers` | ✓ | ✓ | ✓ | @@ -404,6 +415,11 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hle_dump` | ✓ | | ✓ | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | +| `input_route_append` | ✓ | | | +| `input_route_clear` | ✓ | | | +| `input_route_start` | ✓ | | | +| `input_route_status` | ✓ | | | +| `input_route_stop` | ✓ | | | | `insn_freeze` | ✓ | | | | `insn_freeze_snapshot` | ✓ | | | | `insn_freeze_status` | ✓ | | | @@ -447,6 +463,10 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `parity_ctl` | ✓ | ✓ | | | `parity_dump` | ✓ | ✓ | | | `pause` | ✓ | | ✓ | +| `pc_probe_arm` | ✓ | | | +| `pc_probe_clear` | ✓ | | | +| `pc_probe_dump` | ✓ | | | +| `pgxp` | ✓ | | | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | @@ -477,6 +497,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `savestate` | ✓ | | | | `screenshot` | ✓ | ✓ | ✓ | | `screenshot_file` | ✓ | ✓ | ✓ | +| `screenshot_hires` | ✓ | | ✓ | | `set_input` | ✓ | ✓ | ✓ | | `set_snapshot` | ✓ | ✓ | ✓ | | `sio_arm_audit` | ✓ | | | @@ -563,5 +584,6 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `xlate` | ✓ | | | | `xprobe` | ✓ | | | | `xprobe_arm` | ✓ | | | +| `xprobe_watch` | ✓ | | | diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4c8677f76..cccf2daf1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -12111,12 +12111,16 @@ static void handle_s3_smear_watch(int id, const char *json) ? hex_to_u32(buf) : 0u; g_s3_smear_valid = 0; } - send_fmt("{\"id\":%d,\"ok\":true,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"," + /* `armed` so a never-tripped watch (valid:0) can be told apart from one + * that was never recording — the same ambiguity the callret ring had. */ + send_fmt("{\"id\":%d,\"ok\":true,\"armed\":%s," + "\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"," "\"excl\":\"0x%08X\"," "\"valid\":%d,\"pc\":\"0x%08X\",\"insn\":\"0x%08X\"," "\"s3_old\":\"0x%08X\",\"s3_new\":\"0x%08X\"," "\"call_target\":\"0x%08X\",\"frame\":%u}\n", - id, g_s3_smear_lo, g_s3_smear_hi, g_s3_smear_excl, + id, (g_s3_smear_hi > g_s3_smear_lo) ? "true" : "false", + g_s3_smear_lo, g_s3_smear_hi, g_s3_smear_excl, g_s3_smear_valid, g_s3_smear_pc, g_s3_smear_insn, g_s3_smear_old, g_s3_smear_new, g_s3_smear_tgt, g_s3_smear_frame); @@ -12141,25 +12145,74 @@ static void handle_callret_watch(int id, const char *json) uint32_t last_func_a; } E; extern uint32_t g_callret_lo, g_callret_hi; + extern int callret_armed(void); extern E g_callret_ring[]; extern uint64_t g_callret_seq; const uint32_t cap = 64u; /* MUST match CALLRET_CAP (dirty_ram_interp.c) */ char buf[32]; + /* Explicit disarm, and the documented legacy spelling for it. `lo` with no + * `hi` used to mean "disarm" only because the gate tested lo != 0; keep + * that contract for lo=0 (scripts rely on it) but answer it explicitly + * instead of silently. */ + const int want_disarm = json_get_int(json, "disarm", 0) != 0; + if (want_disarm) { + g_callret_lo = g_callret_hi = 0; + g_callret_seq = 0; + send_fmt("{\"id\":%d,\"ok\":true,\"armed\":false,\"lo\":\"0x00000000\"," + "\"hi\":\"0x00000000\"}\n", id); + return; + } if (json_get_str(json, "lo", buf, sizeof(buf))) { - g_callret_lo = hex_to_u32(buf); - if (json_get_str(json, "hi", buf, sizeof(buf))) - g_callret_hi = hex_to_u32(buf); + const uint32_t new_lo = hex_to_u32(buf); + char hibuf[32]; + const int have_hi = json_get_str(json, "hi", hibuf, sizeof(hibuf)) != NULL; + if (!have_hi) { + /* Legacy disarm: {"lo":"0"} with no hi. Honour it, name it. */ + if (new_lo == 0) { + g_callret_lo = g_callret_hi = 0; + g_callret_seq = 0; + send_fmt("{\"id\":%d,\"ok\":true,\"armed\":false," + "\"lo\":\"0x00000000\",\"hi\":\"0x00000000\"," + "\"note\":\"disarmed (legacy lo=0 spelling; prefer" + " disarm=true)\"}\n", id); + return; + } + /* A floor with no ceiling silently inherited the previous hi — + * usually 0, i.e. an empty window that recorded nothing while + * replying ok. Refuse instead of guessing. */ + send_fmt("{\"id\":%d,\"ok\":false,\"error\":\"lo without hi\"," + "\"armed\":%s,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"," + "\"note\":\"pass BOTH lo and hi (hi > lo) to arm, or" + " disarm=true to stop; window left unchanged\"}\n", + id, callret_armed() ? "true" : "false", + g_callret_lo, g_callret_hi); + return; + } + g_callret_lo = new_lo; + g_callret_hi = hex_to_u32(hibuf); g_callret_seq = 0; - send_fmt("{\"id\":%d,\"ok\":true,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"}\n", - id, g_callret_lo, g_callret_hi); + /* Report `armed` rather than leaving the caller to infer it: an empty + * window records nothing, and "total: 0" from an unarmed ring reads + * exactly like a real "these events never happened" answer. */ + const int armed = callret_armed(); + send_fmt("{\"id\":%d,\"ok\":true,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"," + "\"armed\":%s%s}\n", + id, g_callret_lo, g_callret_hi, armed ? "true" : "false", + armed ? "" + : ",\"note\":\"empty window (hi <= lo) - NOT recording\""); return; } uint64_t total = g_callret_seq; uint32_t avail = total < cap ? (uint32_t)total : cap; size_t BUF_SZ = 512u + (size_t)avail * 512u; char *out = (char *)malloc(BUF_SZ); if (!out) { send_err(id, "oom"); return; } + /* Carry the window and armed state on the READ too: "total": 0 is + * otherwise indistinguishable between "armed, nothing matched" and "never + * armed", and the second reads as evidence that the events did not happen. */ size_t pos = (size_t)snprintf(out, BUF_SZ, - "{\"id\":%d,\"ok\":true,\"total\":%llu,\"entries\":[", - id, (unsigned long long)total); + "{\"id\":%d,\"ok\":true,\"armed\":%s,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"," + "\"total\":%llu,\"entries\":[", + id, callret_armed() ? "true" : "false", g_callret_lo, g_callret_hi, + (unsigned long long)total); for (uint32_t i = 0; i < avail && pos < BUF_SZ - 600; i++) { E *e = &g_callret_ring[(total - avail + i) & (cap - 1u)]; pos += (size_t)snprintf(out + pos, BUF_SZ - pos, @@ -12806,6 +12859,39 @@ static void handle_ce_profile(int id, const char *json) /* Arm the §18 boundary trip at runtime once the idle baseline is known: * xprobe_arm {"frame_trip":N,"stk_kb":K,"warmup":F}. Any 0 disables that arm. */ +/* "xprobe_watch": get or set the JAL/JALR watched-target list that filters the + * xprobe `watched` dump. Previously a hardcoded list of one title's addresses, + * so every other game silently watched nothing. Pass `targets` (comma / + * semicolon / space separated, 0x or decimal) to replace the list; pass an + * empty string to clear it; pass nothing to read it back. Also settable before + * the process starts via PSX_XPROBE_WATCH, so a target can be watched from + * instruction zero. Always reports the active list, so an empty `watched` dump + * is never ambiguous between "not called" and "not watched". */ +static void handle_xprobe_watch(int id, const char *json) +{ + extern void dirty_ram_xprobe_watch_set(const char *spec); + extern int dirty_ram_xprobe_watch_get(int index, uint32_t *phys_out); + extern int dirty_ram_xprobe_watch_count(void); + char spec[512]; + if (json_get_str(json, "targets", spec, sizeof(spec))) + dirty_ram_xprobe_watch_set(spec); + char list[768]; + size_t pos = 0; + const int n = dirty_ram_xprobe_watch_count(); + for (int i = 0; i < n && pos < sizeof(list) - 24; i++) { + uint32_t phys = 0; + if (!dirty_ram_xprobe_watch_get(i, &phys)) break; + pos += (size_t)snprintf(list + pos, sizeof(list) - pos, "%s\"0x%08X\"", + i ? "," : "", phys); + } + list[pos] = '\0'; + send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d,\"watching\":[%s]%s}", + id, n, list, + n ? "" : ",\"note\":\"no targets watched - the xprobe watched dump" + " will be empty regardless of what the game calls; pass" + " targets=0x... to watch\""); +} + static void handle_xprobe_arm(int id, const char *json) { extern void dirty_ram_xprobe_arm(int frame_trip, int stk_kb, int warmup); @@ -13241,6 +13327,7 @@ static const CmdEntry s_commands[] = { { "stack_profile", handle_stack_profile }, { "xprobe", handle_xprobe }, { "xprobe_arm", handle_xprobe_arm }, + { "xprobe_watch", handle_xprobe_watch }, { "ce_profile", handle_ce_profile }, { "frame", handle_frame }, { "frame_fingerprint", handle_frame_fingerprint }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 5ec7fd014..8fec4017b 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -402,7 +402,15 @@ uint32_t g_ra_load_snap_gpr[32] = {0}; * interp blocks across the call). This is the piece the s3 tripwire lacks: * the tripwire names the callee that came back smeared; this ring names the * RETURN PATH that let it come back. Zero-cost when disarmed. */ +/* Armed iff the window is NON-EMPTY (hi > lo). It used to be `lo != 0`, which + * made lo=0 a silent off switch: `callret_watch lo=0 hi=0x200000` looked like + * "record the whole address space", replied ok, and then recorded nothing — + * so an empty ring read as "these events never happened" rather than "the + * ring was never on". A window is a window; 0 is a legal floor. Default 0/0 + * is still an empty window, so the ring stays disarmed (and zero-cost) until + * someone sets one. */ uint32_t g_callret_lo = 0, g_callret_hi = 0; +int callret_armed(void) { return g_callret_hi > g_callret_lo; } /* MUST stay field-for-field identical to the local mirror `E` in * debug_server.c handle_callret_watch() (which dumps this ring through an * opaque extern; a divergence is silent garbage, not a compile error). */ @@ -429,7 +437,8 @@ enum { CRES_PLAIN = 1, extern uint64_t g_dispatch_static_hits; /* debug_server.c; bumped by generated dispatch */ extern uint64_t psx_cycle_count; static uint32_t callret_begin(CPUState *cpu, uint32_t pc, uint32_t target) { - if (!g_callret_lo || pc < g_callret_lo || pc >= g_callret_hi) + if (g_callret_hi <= g_callret_lo || pc < g_callret_lo || + pc >= g_callret_hi) return 0xFFFFFFFFu; uint32_t idx = (uint32_t)(g_callret_seq++ & (CALLRET_CAP - 1u)); CallRetEnt *e = &g_callret_ring[idx]; @@ -1115,13 +1124,77 @@ static void xprobe_flush_frame(void) { /* Record one boundary crossing. want_detail=1 for interp-site guest transfers * (rich context), 0 for the dd-site (count + depth only). Runs the per-frame * flush on frame change (leak-proof reset of g_mixed_depth) and the early trip. */ +/* Watched call targets — CONFIGURED, never compiled in. + * + * This used to be a hardcoded list of six guest addresses from two specific + * titles (an MMX6 card-load investigation and a Tomba flow). Baking one + * game's addresses into the shared runtime makes the JAL-site resolution ring + * record for those titles and SILENTLY NOTHING for every other one: an + * investigation on any other game reads an empty `watched` dump and cannot + * tell "this target is never called" from "this build was never able to watch + * it". That is the same defect class as beads-eio.3.21, and it is why the JAL + * call site was unusable while measuring beads-eio.3.59 — only the JALR + * callret ring could be used. + * + * Default is EMPTY: no title is privileged, and a game that wants targets + * watched says so at runtime. Set via the `xprobe_watch` TCP command or the + * PSX_XPROBE_WATCH environment variable (comma/semicolon/space separated, + * 0x-prefixed or decimal) so a watch can be in place from instruction zero + * without a rebuild — the ring is always-on, the filter is what you choose. + * Compared on the normalised address so a KSEG or physical form both match. */ +#define XPROBE_WATCH_MAX 32 +static uint32_t s_xprobe_watch[XPROBE_WATCH_MAX]; +static int s_xprobe_watch_n = 0; +static int s_xprobe_watch_env_done = 0; + +static void xprobe_watch_parse(const char *spec) +{ + s_xprobe_watch_n = 0; + if (!spec) return; + const char *p = spec; + while (*p && s_xprobe_watch_n < XPROBE_WATCH_MAX) { + while (*p == ',' || *p == ';' || *p == ' ' || *p == '\t') p++; + if (!*p) break; + char *end = NULL; + unsigned long v = strtoul(p, &end, 0); + if (end == p) break; + p = end; + s_xprobe_watch[s_xprobe_watch_n++] = (uint32_t)v & 0x1FFFFFFFu; + } +} + +/* Env seeding is lazy so it works no matter which subsystem touches the ring + * first, and costs one branch after the first call. */ static int g_xprobe_watch(uint32_t t) { - return t == 0x8001A954u || t == 0x80046264u || t == 0x8004630Cu || t == 0x8004DFA0u - /* MMX6 card-load firstfile flow (mmx6_card_load_regression_state): - * the mount 0x8001C1AC (works) vs firstfile flow 0x8001C4C0 (its body - * never runs) — record every call event + resolution for both. */ - || t == 0x8001C1ACu || t == 0x8001C4C0u; + if (!s_xprobe_watch_env_done) { + s_xprobe_watch_env_done = 1; + const char *env = getenv("PSX_XPROBE_WATCH"); + if (env && *env) xprobe_watch_parse(env); + } + if (s_xprobe_watch_n == 0) return 0; /* nothing watched: zero-cost */ + const uint32_t phys = t & 0x1FFFFFFFu; + for (int i = 0; i < s_xprobe_watch_n; i++) + if (s_xprobe_watch[i] == phys) return 1; + return 0; +} + +/* Control plane for the `xprobe_watch` TCP command. set() replaces the list + * (NULL or empty clears it); get() reports it so a caller can never mistake + * "nothing watched" for "watched but never called". */ +void dirty_ram_xprobe_watch_set(const char *spec) +{ + s_xprobe_watch_env_done = 1; /* explicit set wins over the env */ + xprobe_watch_parse(spec); } + +int dirty_ram_xprobe_watch_get(int index, uint32_t *phys_out) +{ + if (index < 0 || index >= s_xprobe_watch_n) return 0; + if (phys_out) *phys_out = s_xprobe_watch[index]; + return 1; +} + +int dirty_ram_xprobe_watch_count(void) { return s_xprobe_watch_n; } static void xprobe_event(uint32_t src_pc, uint8_t op, uint8_t site, uint32_t target, uint32_t ds_insn, uint32_t sp, uint32_t ra, int want_detail); /* Watched-target call note for NON-interp call sites (overlay shard call-outs @@ -2912,7 +2985,11 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ cosim_exec_one_begin(); int transferred = exec_one_fetched(cpu, pc, insn, &next_pc); #ifndef PSX_NO_DEBUG_TOOLS - if (g_s3_smear_lo && !g_s3_smear_valid && + /* Armed iff the window is NON-EMPTY, same as the callret ring: a `lo` + * test made address 0 a silent off switch, so lo=0 with a real hi + * recorded nothing while the command answered ok. Since this watch + * clears `hi` whenever it is omitted, {"lo":"0"} still disarms. */ + if (g_s3_smear_hi > g_s3_smear_lo && !g_s3_smear_valid && pc >= g_s3_smear_lo && pc < g_s3_smear_hi && cpu->gpr[19] != before_s3 && (g_s3_smear_excl == 0u || insn != g_s3_smear_excl)) {