observability: window traps that read as results, and a per-game watch list

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=<two addrs> -> 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.
This commit is contained in:
Matthew Stanley
2026-08-23 11:57:29 -07:00
committed by Matthew Stanley
parent 239052f166
commit 2cbbbfc4da
3 changed files with 232 additions and 46 deletions
+50 -28
View File
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | | |
<!-- END AUTOGENERATED COMMAND INDEX -->
+97 -10
View File
@@ -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];
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);
/* 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,\"lo\":\"0x%08X\",\"hi\":\"0x%08X\"}\n",
id, g_callret_lo, g_callret_hi);
send_fmt("{\"id\":%d,\"ok\":true,\"armed\":false,\"lo\":\"0x00000000\","
"\"hi\":\"0x00000000\"}\n", id);
return;
}
if (json_get_str(json, "lo", buf, sizeof(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;
/* 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 },
+85 -8
View File
@@ -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. */
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;
/* 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) {
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)) {