diff --git a/docs/beetle-macos.md b/docs/beetle-macos.md index a4aa43b9..5307e8ad 100644 --- a/docs/beetle-macos.md +++ b/docs/beetle-macos.md @@ -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 `-` 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 --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. diff --git a/runtime/src/beetle_main.cpp b/runtime/src/beetle_main.cpp index 06e4d483..617c0640 100644 --- a/runtime/src/beetle_main.cpp +++ b/runtime/src/beetle_main.cpp @@ -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="" or "-" + 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); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 655d03f4..498a9b0a 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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="" or "-" (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 diff --git a/tools/cycle_compare.py b/tools/cycle_compare.py index abf453b0..313e9bb2 100644 --- a/tools/cycle_compare.py +++ b/tools/cycle_compare.py @@ -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= (or - for REGION mode) and +PSX_CYC_WATCH_N= (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"}