cyc_watch: arm boot anchors from power-on on both backends

Boot anchors (reset, the kernel copy, the first A0/B0/C0 calls, the shell)
pass within the first frames, before a TCP cyc_watch arm can land, so a
native-vs-Beetle boot comparison could not be run from the tree.

psx-runtime (debug_server.c) and psx-beetle (beetle_main.cpp) now read
PSX_CYC_WATCH ("<pc>" or "<pc>-<end>" for region mode) and PSX_CYC_WATCH_N
(default 16) at start-up and arm the watch before the first guest
instruction. The native arm shares the TCP handler's code path
(cyc_watch_arm); psx-beetle calls beetle_cyc_watch_arm. With neither
variable set nothing changes. tools/cycle_compare.py --no-arm then reads
both rings.

PSX_BEETLE_UNPACED=1 skips psx-beetle's 59.94 Hz wall-clock pacing for
headless oracle runs. Guest timing is unaffected.

docs/beetle-macos.md step 7 documents the variables and the anchor-key
difference for BIOS copy windows declared dispatch_key = "rom": the shell
is keyed natively at its ROM address (OpenBIOS 0xBFC0A500, SCPH-1001
0xBFC18000) while Beetle sees 0x80030000. Checked on macOS: the documented
example records the OpenBIOS shell entry at 5,015,670 in Beetle, the value
of the #429 land gate, and the native watch arms from boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-29 12:26:16 -07:00
parent 055c33aaa1
commit 216eb31ef2
4 changed files with 115 additions and 15 deletions
+46
View File
@@ -156,3 +156,49 @@ These are the recorded oracle values. `icache_miss` is 19 (+16) on the current
ROM and 17 (+14) on the 13-loop ROM of 2026-06-27, because the loop moved
within its cache line (see the README). If a build disagrees, fix the oracle
before comparing anything against it.
## 7. Boot anchors: arm cyc_watch from power-on
Reset, the kernel copy, the first A0/B0/C0 calls and the shell pass within the
first frames, before a TCP `cyc_watch` arm can land. Both backends therefore
read the watch from their environment at start-up:
| variable | meaning |
|---|---|
| `PSX_CYC_WATCH` | anchor PC, or `<pc>-<end>` for region mode (each hit is one pass's cycle count) |
| `PSX_CYC_WATCH_N` | hits to record (default 16) |
| `PSX_BEETLE_UNPACED=1` | psx-beetle only: skip the 59.94 Hz wall-clock pacing. Guest timing is unchanged; headless boots just finish sooner |
Read the rings with `tools/cycle_compare.py <pc> --no-arm` (or `cyc_watch_dump`
on each port). For an LLE boot on the native side, set `PSX_BIOS_HLE=0`.
Anchor keys can differ between the two sides. A BIOS copy window declared with
`dispatch_key = "rom"` keeps its ROM address as the native key; Beetle sees the
PC the code runs at. Both `bios/OpenBIOS.toml` and `bios/SCPH1001.toml` declare
the shell that way:
| anchor | Beetle | native |
|---|---|---|
| OpenBIOS shell entry | `0x80030000` | `0xBFC0A500` |
| SCPH-1001 shell entry | `0x80030000` | `0xBFC18000` |
A native watch on `0x80030000` records nothing. Kernel windows
(`dispatch_key = "ram"`) and ROM code use the same PC on both sides.
Example, OpenBIOS shell entry (the cycle-test disc from step 6 inserted):
```bash
PSX_CYC_WATCH=0x80030000 PSX_CYC_WATCH_N=1 PSX_BEETLE_UNPACED=1 \
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software \
"$PSXRECOMP/runtime/build-beetle/psx-beetle" ~/psx-oracle/bios/scph5501.bin \
--port 4382 --disc disc/cyctest.cue &
PSX_CYC_WATCH=0xBFC0A500 PSX_CYC_WATCH_N=1 PSX_BIOS_HLE=0 \
build/Cycle_Test_ROM --no-launcher --headless --game game.toml \
--bios ../../bios/openbios.bin --disc disc/cyctest.cue --debug-port 4600 &
python3 ../cycle_compare.py 0x80030000 --no-arm --hits 1 --native-port 4600
```
(run from `tools/cycle_testrom`). Beetle records its first hit at guest cycle
5,015,670 with `bios/openbios.bin` (SHA-1 `95419841…`). `cycle_compare.py`
warns that `anchor_phys` differs between the backends; for a ROM-keyed window
that is expected.
+22
View File
@@ -15,6 +15,7 @@ int beetle_init(const char* bios_path);
int beetle_init_with_disc(const char* bios_path, const char* disc_path);
int beetle_wtrace_arm(uint32_t lo, uint32_t hi);
int beetle_rtrace_arm(uint32_t lo, uint32_t hi);
void beetle_cyc_watch_arm(uint32_t anchor_raw, uint32_t end_raw, int n);
void beetle_shutdown(void);
void beetle_run_frame(uint16_t pad1_buttons);
int beetle_get_framebuffer(uint32_t **out_pixels, unsigned *out_w, unsigned *out_h);
@@ -151,6 +152,20 @@ int main(int argc, char** argv) {
#ifndef PSX_NO_DEBUG_TOOLS
beetle_debug_server_init(dbg_port);
std::fprintf(stdout, "psx-beetle: debug server on port %d\n", dbg_port);
/* PSX_CYC_WATCH="<pc>" or "<pc>-<end>" + PSX_CYC_WATCH_N (default 16):
* arm cyc_watch before the first frame, as psx-runtime does, so boot
* anchors are recorded from power-on on both backends. */
if (const char* cw = std::getenv("PSX_CYC_WATCH"); cw && cw[0]) {
char* e = nullptr;
uint32_t pc = (uint32_t)std::strtoul(cw, &e, 0);
uint32_t end = (e && *e == '-') ? (uint32_t)std::strtoul(e + 1, nullptr, 0) : 0u;
const char* ns = std::getenv("PSX_CYC_WATCH_N");
int n = (ns && ns[0]) ? (int)std::strtol(ns, nullptr, 0) : 16;
beetle_cyc_watch_arm(pc, end, n);
std::fprintf(stdout, "psx-beetle: cyc_watch armed from boot pc=0x%08X end=0x%08X n=%d\n",
pc, end, n);
}
#endif
/* General control-flow parity trace (oracle producer). Armed from boot via
@@ -250,6 +265,13 @@ int main(int argc, char** argv) {
* sustain unlocked rate (turbo). */
const Uint8* keys = SDL_GetKeyboardState(NULL);
if (keys && keys[SDL_SCANCODE_TAB]) continue;
/* PSX_BEETLE_UNPACED=1: no wall-clock pacing, for headless oracle
* runs (guest timing is unaffected; only the host waits less). */
static const int unpaced = [] {
const char* u = std::getenv("PSX_BEETLE_UNPACED");
return (u && u[0] && u[0] != '0') ? 1 : 0;
}();
if (unpaced) continue;
constexpr double FRAME_MS = 1000.0 / 59.94;
static FramePacer pacer = { 0 };
frame_pacer_wait(&pacer, FRAME_MS);
+37 -15
View File
@@ -9807,24 +9807,12 @@ static void handle_wtrace_ranges(int id, const char *json)
/* ---- cyc_watch command handlers (see cyc_watch_observe above) ---- */
/* cyc_watch — arm an anchor PC. {"pc":"0x...","n":16}. Clears the ring,
* masks the anchor to physical, and starts recording. The Beetle side
* (psx-beetle, added parent-side) implements the SAME command/spec. */
static void handle_cyc_watch(int id, const char *json)
/* Arm cyc_watch: clear the ring, mask the anchor(s) to physical, start
* recording. end_raw != 0 selects REGION mode (each entry = Δcycles A->B). */
static void cyc_watch_arm(uint32_t raw, uint32_t end_raw, int n)
{
char pcbuf[64];
if (!json_get_str(json, "pc", pcbuf, sizeof(pcbuf))) {
send_err(id, "cyc_watch requires pc");
return;
}
uint32_t raw = hex_to_u32(pcbuf);
int n = json_get_int(json, "n", 16);
if (n < 1) n = 1;
if (n > CYC_WATCH_RING_CAP) n = CYC_WATCH_RING_CAP;
/* Optional second anchor -> REGION mode: each entry = Δcycles of one A->B pass. */
char endbuf[64];
uint32_t end_raw = json_get_str(json, "end", endbuf, sizeof(endbuf)) ? hex_to_u32(endbuf) : 0u;
/* Disarm first so the hot path can't sample mid-reset. */
s_cyc_watch_armed = 0;
s_cyc_watch_anchor_raw = raw;
@@ -9839,6 +9827,22 @@ static void handle_cyc_watch(int id, const char *json)
s_cyc_watch_last_cycle = 0xFFFFFFFFFFFFFFFFull;
memset(s_cyc_watch_ring, 0, sizeof(s_cyc_watch_ring));
s_cyc_watch_armed = 1;
}
/* cyc_watch — arm an anchor PC. {"pc":"0x...","n":16}. Clears the ring,
* masks the anchor to physical, and starts recording. The Beetle side
* (psx-beetle, added parent-side) implements the SAME command/spec. */
static void handle_cyc_watch(int id, const char *json)
{
char pcbuf[64];
if (!json_get_str(json, "pc", pcbuf, sizeof(pcbuf))) {
send_err(id, "cyc_watch requires pc");
return;
}
/* Optional second anchor -> REGION mode: each entry = Δcycles of one A->B pass. */
char endbuf[64];
uint32_t end_raw = json_get_str(json, "end", endbuf, sizeof(endbuf)) ? hex_to_u32(endbuf) : 0u;
cyc_watch_arm(hex_to_u32(pcbuf), end_raw, json_get_int(json, "n", 16));
send_fmt("{\"id\":%d,\"ok\":true,\"anchor\":\"0x%08X\","
"\"anchor_phys\":\"0x%08X\",\"end\":\"0x%08X\",\"end_phys\":\"0x%08X\","
@@ -14275,6 +14279,24 @@ void debug_server_init(int port)
}
}
}
/* PSX_CYC_WATCH="<pc>" or "<pc>-<end>" (region mode) arms cyc_watch
* before the first guest instruction, with PSX_CYC_WATCH_N hits
* (default 16). Boot anchors (reset, the kernel copy, the first
* syscalls, the shell) pass before any TCP arm can land. psx-beetle
* reads the same variables, so one boot of each backend gives the
* same anchor from cycle 0 (tools/cycle_compare.py --no-arm). */
{
const char *cw = getenv("PSX_CYC_WATCH");
if (cw && *cw) {
char *e = NULL;
uint32_t pc = (uint32_t)strtoul(cw, &e, 0);
uint32_t end = (e && *e == '-') ? (uint32_t)strtoul(e + 1, NULL, 0) : 0u;
const char *ns = getenv("PSX_CYC_WATCH_N");
cyc_watch_arm(pc, end, (ns && *ns) ? (int)strtol(ns, NULL, 0) : 16);
fprintf(stdout, "psxrecomp: cyc_watch armed from boot pc=0x%08X end=0x%08X n=%u\n",
pc, end, s_cyc_watch_max_hits);
}
}
}
#ifdef _WIN32
+10
View File
@@ -19,6 +19,16 @@ The native side (psx-runtime) implements `cyc_watch` / `cyc_watch_dump` /
`cyc_watch_clear` in runtime/src/debug_server.c. The Beetle side
(psx-beetle) implements the SAME commands to the SAME spec (added parent-side).
BOOT ANCHORS: reset, the kernel copy, the first syscalls and the shell pass in
the first frames, before a TCP arm can land. Arm them from power-on instead:
start each backend with PSX_CYC_WATCH=<pc> (or <pc>-<end> for REGION mode) and
PSX_CYC_WATCH_N=<hits> (default 16) in its environment, then run this tool with
--no-arm to dump and diff. The anchor key can differ per side: a BIOS copy
window with dispatch_key = "rom" (the shell, in both bios/OpenBIOS.toml and
bios/SCPH1001.toml) is keyed natively at its ROM address, so the OpenBIOS shell
entry is 0x80030000 in Beetle but 0xBFC0A500 natively (SCPH-1001: 0xBFC18000).
Windows with dispatch_key = "ram" (the kernels) use the RAM PC on both sides.
Wire format (request):
{"cmd":"cyc_watch","pc":"0x80012345","n":16}
{"cmd":"cyc_watch_dump"}