Merge pull request #431 from RetroPortingToolKit/docs/beetle-macos

docs: build the psx-beetle oracle on macOS (plus the missing Beetle hook patches)
This commit is contained in:
Matt Stanley
2026-09-29 16:31:07 -07:00
committed by GitHub
19 changed files with 1068 additions and 99 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ release-stage/
*.zip
# Vendored upstream sources (cloned locally, not checked in)
beetle-psx/
beetle-psx
# Explicit-fallback SDL2 / dep zips (large, regenerable)
sdl2-dev.zip
+26 -11
View File
@@ -16,16 +16,21 @@ Pin the checkout to the last compatible commit — the same base
git clone https://github.com/libretro/beetle-psx-libretro.git beetle-psx
cd beetle-psx
git checkout 5759277b # "audit pass" — last C++-tree base we target
patch -p1 < ../docs/beetle_wtrace_hook.patch
patch -p1 < ../docs/beetle_sio_trace_hook.patch
patch -p1 < ../docs/beetle_cdcmd_trace_hook.patch
# cdcmd re-inserts the wtrace globals + typedefs — drop the duplicate block
# in libretro.cpp / mednafen/psx/psx.h if the build errors on redefinition.
# Also needed (not yet in docs/*.patch): rtrace/irq callbacks, guest-cycle
# accumulator, PS_CDC::PSXRecomp_GetDecodeVolume, cdc/dma irq peeks. Apply
# from a prior local beetle-psx tree or re-land those hooks by hand.
for p in wtrace_hook sio_trace_hook cdcmd_trace_hook rtrace_irq_hook \
guest_cycles_hook cdc_dma_peek_hook spu_event_hook; do
git apply ../docs/beetle_$p.patch # in this order; each builds on the last
done
```
Every hook `beetle_libretro.cpp` and `beetle_debug_server.c` link against is
now in `docs/beetle_*.patch` (added 2026-09-29): the rtrace/IRQ callbacks, the
guest-cycle clock and the per-instruction PC hook behind `cyc_watch`, the
CDC/DMA IRQ peeks, `PS_CDC::PSXRecomp_GetDecodeVolume` and the SPU event
calls. `beetle_cdcmd_trace_hook.patch` is now a diff on top of the wtrace and
SIO patches, so there is no duplicate block to remove. `beetle-macos.md` has a
table of what each patch adds. The set was verified from a fresh clone on
macOS; this Linux recipe has not been re-run with it.
`beetle_cdcmd_trace_hook.patch` adds a CD-command trace callback (fires per
command dispatch in cdc.cpp) exposed as the `cdrom_cmd_dump` / `cdrom_cmd_reset`
debug commands — used to oracle-diff the Kula World CD read sequence.
@@ -43,16 +48,26 @@ make platform=unix STATIC_LINKING=1 HAVE_LIGHTREC=0 -j"$(nproc)"
cp mednafen_psx_libretro.so libmednafen_psx.a
cd ../runtime
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF -DPSX_DEBUG_TOOLS=ON
# Current master's runtime configure also needs the lib/recomp-net and
# lib/retcomm-rbengine submodules (git submodule update --init ..., or
# -DPSX_REWIND=OFF) and recompiled BIOS C. psx-beetle links no recompiled
# code, so -DPSXRECOMP_ALLOW_NO_BIOS=ON skips the BIOS check
# (beetle-macos.md, step 3).
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF -DPSX_DEBUG_TOOLS=ON \
-DPSXRECOMP_ALLOW_NO_BIOS=ON
ninja -C build psx-beetle
# Run (SDL window; use software renderer if GL context is flaky).
# Beetle looks for lowercase scph5501.bin in the BIOS directory (SHA1
# 0555C6FA… — SCPH-5501). Symlink or copy next to SCPH1001.BIN.
# Default debug port is 4380 (override with --port).
# psxrecomp's bios/openbios.bin works under that name too; see
# beetle-macos.md ("BIOS") for the silent fallback to Beetle's own OpenBIOS.
# Default debug port is 4382 (override with --port).
SDL_RENDER_DRIVER=software ./runtime/build/psx-beetle ../bios/SCPH1001.BIN --disc <game.cue> --port 4380
# Headless (if xvfb is installed):
# Headless: SDL's dummy video driver needs no X server.
# SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software ./runtime/build/psx-beetle ...
# Or, if xvfb is installed:
# xvfb-run -a ./runtime/build/psx-beetle ../bios/SCPH1001.BIN --disc <game.cue>
```
+206
View File
@@ -0,0 +1,206 @@
# Building psx-beetle on macOS
`psx-beetle` is the Beetle PSX (Mednafen) oracle. It speaks the same TCP debug
protocol as `psx-runtime`, so the comparison tools (`tools/cycle_compare.py`,
`tools/cycle_testrom/measure.py`, the parity and device-trace diffs) can point
at either backend.
Verified 2026-09-29 on Apple Silicon (macOS 27, Apple clang 21, Homebrew SDL3),
starting from a fresh clone of the official Beetle repository:
- psx-beetle boots psxrecomp's bundled OpenBIOS to its shell, headless;
- `ping` answers `backend=beetle`, and `guest_cycles` advances about 565,050
cycles per frame (33.8688 MHz at 59.94 Hz is 565,045);
- ruler #2 (`tools/cycle_testrom`) reproduces the oracle values recorded in
`internal/FAITHFUL_TIMING_PLAN.md` on all 13 loops of the 13-loop ROM
(`icache_miss +14`) and on all 15 loops of the current ROM, where
`icache_miss` reads +16 (see step 6).
The Linux recipe is in `beetle-linux.md`. The Beetle side of both is the same
patch set.
## Prerequisites
- Xcode Command Line Tools (clang, make, ar).
- Homebrew: `brew install cmake ninja sdl3`.
- Python 3 for the measurement tools.
## 1. Beetle checkout and hooks
Keep the checkout outside the psxrecomp tree and symlink it in. The symlink
`beetle-psx` at the psxrecomp root is gitignored.
```bash
git clone https://github.com/libretro/beetle-psx-libretro.git ~/dev/beetle-psx
cd ~/dev/beetle-psx
git checkout 5759277b # last C++-tree base; newer upstream is plain C
PSXRECOMP=<path to psxrecomp>
for p in wtrace_hook sio_trace_hook cdcmd_trace_hook rtrace_irq_hook \
guest_cycles_hook cdc_dma_peek_hook spu_event_hook; do
git apply "$PSXRECOMP/docs/beetle_$p.patch"
done
```
Apply the patches in this order: each one is a diff on top of the one before.
| patch | adds | psxrecomp user |
|---|---|---|
| `beetle_wtrace_hook` | store callback, J/JAL/JR/JALR callback | `wtrace_*`, `fntrace_*`, parity ring |
| `beetle_sio_trace_hook` | `FrontIO::SetSIOTraceCallback` | `sio_trace` |
| `beetle_cdcmd_trace_hook` | CD command callback | `cdrom_cmd_dump` |
| `beetle_rtrace_irq_hook` | CPU load callback; IRQ rising-edge callback | `rtrace_*`, parity reads, device trace |
| `beetle_guest_cycles_hook` | absolute guest clock; per-instruction PC hook | `ping.guest_cycles`, `cyc_watch*` |
| `beetle_cdc_dma_peek_hook` | CD INT type, DMA completion flags, CD decode volume | device trace detail, `beetle_cdc_decode_volume` |
| `beetle_spu_event_hook` | KEYON/KEYOFF/END_STOP/END_LOOP events | `spu_events` |
`runtime/src/beetle_libretro.cpp` and `beetle_debug_server.c` link against
every hook, so a tree missing any of them fails to link. Two limits:
- `beetle_core_get_guest_cycles()` returns the clock at the last frame
boundary. That is exact where the debug server samples it (between frames),
but device-trace and parity stamps taken inside a frame carry the
frame-start value. `cyc_watch` uses the exact per-instruction clock.
- `exc_ring` answers an empty ring. Its core hook was never published and no
tool reads it.
## 2. Static library
```bash
cd ~/dev/beetle-psx
make platform=osx STATIC_LINKING=1 HAVE_LIGHTREC=0 -j"$(sysctl -n hw.ncpu)"
# With STATIC_LINKING=1 the output is an ar archive, whatever the name says.
cp mednafen_psx_libretro.dylib libmednafen_psx.a
ln -s ~/dev/beetle-psx "$PSXRECOMP/beetle-psx"
```
The build takes about 20 seconds on an M-series Mac.
## 3. psx-beetle
```bash
cd "$PSXRECOMP"
# The runtime configure needs these submodules even for psx-beetle
# (or configure with -DPSX_REWIND=OFF).
git submodule update --init lib/recomp-net lib/retcomm-rbengine
cmake -S runtime -B runtime/build-beetle -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DPSX_RECOMP_UI=OFF -DPSX_DEBUG_TOOLS=ON -DPSXRECOMP_ALLOW_NO_BIOS=ON
ninja -C runtime/build-beetle psx-beetle
```
Configure must print `psx-beetle: found .../beetle-psx/libmednafen_psx.a`;
otherwise the target is skipped silently. `PSXRECOMP_ALLOW_NO_BIOS=ON` lets the
runtime configure without recompiled BIOS C. psx-beetle links no recompiled
code; drop the flag once `tools/regen_bios.sh` has run.
## 4. BIOS: OpenBIOS under a retail name
psx-beetle uses the directory of the BIOS path it is given as Beetle's system
directory. Beetle picks the BIOS from there by disc region: `scph5500.bin`
(JP), `scph5501.bin` (NA) or `scph5502.bin` (EU). A file under the right name
with an unexpected SHA-1 gets a warning and is used anyway. If none is found,
Beetle quietly boots **its own embedded OpenBIOS**, a different build (SHA-1
`bfd08f9d5c3def7b5b3662f9ec44af316f95aada` at 5759277b) from the one the native
runtime runs. So put psxrecomp's image under all three names:
```bash
mkdir -p ~/psx-oracle/bios && cd ~/psx-oracle/bios
for n in scph5500 scph5501 scph5502; do cp "$PSXRECOMP/bios/openbios.bin" $n.bin; done
# No-disc (shell) boots load <system dir>/dummy.cue and then eject it:
head -c $((2352 * 300)) /dev/zero > dummy.bin
printf 'FILE "dummy.bin" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n' > dummy.cue
```
The 2026-09-29 run used `bios/openbios.bin` with SHA-1
`95419841b5104d552b14810b1ecbe6c1358bcdf1`. Confirm Beetle loaded it: its log
prints `Obtained SHA1: 95419841...`, and a `read_ram` of `0x1FC00000`, length
`0x80000`, must hash to the same value. No retail BIOS is needed for anything
on this page.
## 5. Run
```bash
cd ~/psx-oracle # memory cards and relative screenshot paths land here
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software \
"$PSXRECOMP/runtime/build-beetle/psx-beetle" ~/psx-oracle/bios/scph5501.bin \
--port 4382 [--disc <game>.cue]
```
The two variables make it headless: SDL gives environment hints precedence
over psx-beetle's `opengl` render hint, and the dummy video driver opens no
window. Without them psx-beetle opens a 640x480 window. Both ways it paces to
59.94 Hz (in the window, hold Tab for turbo). The default port is 4382.
Smoke test:
```bash
python3 tools/debug_client.py --port 4382 ping # "backend": "beetle"
python3 tools/debug_client.py --port 4382 screenshot /abs/path/shell.png
```
With no disc the screenshot is the OpenBIOS shell: a rotating shaded cube
whose colours cycle.
## 6. Self-check: ruler #2
Before trusting a new oracle build, run the cycle micro-benchmark ROM on it.
The steps (ROM, boot disc without mkpsxiso, native build) are in
`tools/cycle_testrom/README.md`. Beetle gives, per loop iteration:
| baseline | alu | load | load2 | load_use | div | div_spaced | mult |
|---|---|---|---|---|---|---|---|
| 3 | 4 | 8 | 14 | 8 | 41 | 41 | 18 |
| gte_rtps | gte_nclip | gte_read_use | ld_div | mmio_timer | mmio_spu | icache_miss |
|---|---|---|---|---|---|---|
| 14 | 7 | 14 | 49 | 6 | 41 | 19 |
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.
+58
View File
@@ -0,0 +1,58 @@
diff --git a/mednafen/psx/cdc.cpp b/mednafen/psx/cdc.cpp
index a6b31b66..f2c460b9 100644
--- a/mednafen/psx/cdc.cpp
+++ b/mednafen/psx/cdc.cpp
@@ -2438,3 +2438,23 @@ PS_CDC::CDC_CTEntry PS_CDC::Commands[0x20] =
{ /* 0x1E, */ 0, 0, "ReadTOC", &PS_CDC::Command_ReadTOC, &PS_CDC::Command_ReadTOC_Part2 },
{ /* 0x1F, */ 0, 0, NULL, NULL, NULL },
};
+
+/* PSXRecomp oracle peeks. */
+void PS_CDC::PSXRecomp_GetDecodeVolume(uint8 out[2][2]) const
+{
+ for(unsigned i = 0; i < 2; i++)
+ for(unsigned j = 0; j < 2; j++)
+ out[i][j] = DecodeVolume[i][j];
+}
+
+uint8 PS_CDC::PSXRecomp_GetIRQBuffer(void) const
+{
+ return IRQBuffer;
+}
+
+/* INT type of the pending CD interrupt (low nibble of IRQBuffer:
+ * 1=data ready, 2=complete, 3=acknowledge, 4=data end, 5=error). */
+extern "C" unsigned psxrecomp_cdc_irq_type(void)
+{
+ return PSX_CDC ? (unsigned)(PSX_CDC->PSXRecomp_GetIRQBuffer() & 0xF) : 0u;
+}
diff --git a/mednafen/psx/cdc.h b/mednafen/psx/cdc.h
index fabf2053..c582b9f5 100644
--- a/mednafen/psx/cdc.h
+++ b/mednafen/psx/cdc.h
@@ -44,6 +44,10 @@ class PS_CDC
int DriveStatus;
#endif
+ /* PSXRecomp oracle peeks (read-only, no side effects). */
+ void PSXRecomp_GetDecodeVolume(uint8 out[2][2]) const; // [data_source][output_port], 0x80 = unity
+ uint8 PSXRecomp_GetIRQBuffer(void) const;
+
private:
CDIF *Cur_CDIF;
bool DiscChanged;
diff --git a/mednafen/psx/dma.cpp b/mednafen/psx/dma.cpp
index 27da2cd2..2f58b8ca 100644
--- a/mednafen/psx/dma.cpp
+++ b/mednafen/psx/dma.cpp
@@ -727,3 +727,10 @@ int DMA_StateAction(StateMem *sm, int load, int data_only)
return(ret);
}
+
+/* PSXRecomp oracle peek: per-channel DMA completion flags (DICR bits 24-30,
+ * bit n = channel n completed). */
+extern "C" unsigned psxrecomp_dma_int_status(void)
+{
+ return (unsigned)DMAIntStatus;
+}
+10 -44
View File
@@ -1,19 +1,15 @@
diff --git a/libretro.cpp b/libretro.cpp
index d2e82bca..e9c867c8 100644
index 99b87c80..e9c867c8 100644
--- a/libretro.cpp
+++ b/libretro.cpp
@@ -508,6 +508,11 @@ PS_SPU *PSX_SPU = NULL;
PS_CDC *PSX_CDC = NULL;
FrontIO *PSX_FIO = NULL;
+/* PSXRecomp oracle hooks: NULL when no listener attached. */
+psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb = NULL;
+psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb = NULL;
@@ -511,6 +511,7 @@ FrontIO *PSX_FIO = NULL;
/* PSXRecomp oracle hooks: NULL when no listener attached. */
psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb = NULL;
psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb = NULL;
+psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb = NULL;
+
MultiAccessSizeMem<512 * 1024, uint32, false> *BIOSROM = NULL;
MultiAccessSizeMem<65536, uint32, false> *PIOMem = NULL;
MultiAccessSizeMem<2048 * 1024, uint32, false> *MainRAM = NULL;
diff --git a/mednafen/psx/cdc.cpp b/mednafen/psx/cdc.cpp
index 1daaa5b7..a6b31b66 100644
--- a/mednafen/psx/cdc.cpp
@@ -36,43 +32,13 @@ index 1daaa5b7..a6b31b66 100644
PendingCommandPhase = 2;
}
diff --git a/mednafen/psx/psx.h b/mednafen/psx/psx.h
index 704c0a19..fc53362d 100644
index e16a230f..fc53362d 100644
--- a/mednafen/psx/psx.h
+++ b/mednafen/psx/psx.h
@@ -137,4 +137,45 @@ extern unsigned psx_gpu_overclock_shift;
extern uint8_t analog_combo[2];
extern uint8_t analog_combo_hold;
@@ -167,4 +167,15 @@ typedef void (*psxrecomp_fntrace_cb_t)(uint32_t caller_pc, uint32_t target_pc,
uint32_t a1, uint8_t kind);
extern psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb;
+/* PSXRecomp oracle: SIO byte trace callback.
+ * Fired by FrontIO when a complete byte exchange finishes. */
+class FrontIO;
+extern FrontIO *PSX_FIO;
+typedef void (*psxrecomp_sio_trace_cb_t)(uint8_t tx, uint8_t rx, uint16_t ctrl);
+
+/* PSXRecomp oracle: RAM write trace callback.
+ * Fired by PS_CPU::SB/SH/SW after the write completes.
+ * addr = virtual address (caller masks for physical filter)
+ * value = stored value (sized by `size`)
+ * pc = PC of the executing store instruction
+ * ra = $ra (GPR[31]) at time of store, for one frame of caller context
+ * size = 1, 2, or 4 (byte / halfword / word) */
+typedef void (*psxrecomp_wtrace_cb_t)(uint32_t addr, uint32_t value,
+ uint32_t pc, uint32_t ra, uint8_t size);
+extern psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb;
+
+/* PSXRecomp oracle: function-call (JAL/JALR/J/JR) trace callback.
+ * Fired by PS_CPU on every J/JAL/JR/JALR retire, BEFORE the link write.
+ * Caller filters by target_pc to keep volume bounded.
+ * caller_pc = PC of the J/JAL/JR/JALR instruction
+ * target_pc = computed branch target
+ * parent_ra = GPR[31] at time of call (parent's return address)
+ * a0, a1 = argument registers at time of call
+ * kind = 1=J, 2=JAL, 3=JR, 4=JALR */
+typedef void (*psxrecomp_fntrace_cb_t)(uint32_t caller_pc, uint32_t target_pc,
+ uint32_t parent_ra, uint32_t a0,
+ uint32_t a1, uint8_t kind);
+extern psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb;
+
+/* PSXRecomp oracle: CD command trace callback.
+ * Fired by PS_CDC just before a command handler executes.
+ * cmd = command byte (0x01 GetStat, 0x02 Setloc, 0x06 ReadN, 0x09 Pause,
+84
View File
@@ -0,0 +1,84 @@
diff --git a/libretro.cpp b/libretro.cpp
index 0924113d..5258d66e 100644
--- a/libretro.cpp
+++ b/libretro.cpp
@@ -514,6 +514,13 @@ psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb = NULL;
psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb = NULL;
psxrecomp_rtrace_cb_t g_psxrecomp_rtrace_cb = NULL;
psxrecomp_irq_cb_t g_psxrecomp_irq_cb = NULL;
+psxrecomp_pc_cb_t g_psxrecomp_pc_cb = NULL;
+uint64_t psxrecomp_guest_cycle_base = 0;
+
+extern "C" unsigned long long beetle_core_get_guest_cycles(void)
+{
+ return (unsigned long long)psxrecomp_guest_cycle_base;
+}
MultiAccessSizeMem<512 * 1024, uint32, false> *BIOSROM = NULL;
MultiAccessSizeMem<65536, uint32, false> *PIOMem = NULL;
@@ -1277,6 +1284,9 @@ static void PSX_Power(void)
PSX_PRNG.c = 6543217;
PSX_PRNG.lcgo = 0xDEADBEEFCAFEBABEULL;
+ /* PSXRecomp oracle: the guest clock restarts at power-on. */
+ psxrecomp_guest_cycle_base = 0;
+
cd_warned_slow = false;
memset(MainRAM->data32, 0, 2048 * 1024);
@@ -4825,6 +4835,10 @@ void retro_run(void)
assert(timestamp);
+ /* PSXRecomp oracle: fold this frame's CPU timestamp into the absolute
+ * guest clock before the per-frame timestamp rebase below. */
+ psxrecomp_guest_cycle_base += (uint32_t)timestamp;
+
ForceEventUpdates(timestamp);
espec->SoundBufSize = IntermediateBufferPos;
diff --git a/mednafen/psx/cpu.cpp b/mednafen/psx/cpu.cpp
index cbfd20dc..d1789712 100644
--- a/mednafen/psx/cpu.cpp
+++ b/mednafen/psx/cpu.cpp
@@ -709,6 +709,11 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
}
#endif
+ /* PSXRecomp oracle hook: per-instruction PC + absolute guest cycle,
+ * sampled before this instruction's fetch is charged. */
+ if(MDFN_UNLIKELY(g_psxrecomp_pc_cb))
+ g_psxrecomp_pc_cb(PC, psxrecomp_guest_cycle_base + (uint32_t)timestamp);
+
//
// Instruction fetch
//
diff --git a/mednafen/psx/psx.h b/mednafen/psx/psx.h
index 05122c8c..0e61795d 100644
--- a/mednafen/psx/psx.h
+++ b/mednafen/psx/psx.h
@@ -198,4 +198,24 @@ extern psxrecomp_rtrace_cb_t g_psxrecomp_rtrace_cb;
typedef void (*psxrecomp_irq_cb_t)(int which);
extern psxrecomp_irq_cb_t g_psxrecomp_irq_cb;
+/* PSXRecomp oracle: absolute guest CPU clock. psxrecomp_guest_cycle_base is
+ * the sum of the CPU timestamps of every completed retro_run() frame since
+ * the last power-on (timestamps restart at 0 each frame), so inside a frame
+ * the absolute guest cycle of an instruction is base + the CPU's running
+ * timestamp. beetle_core_get_guest_cycles() returns the base, i.e. the exact
+ * clock between frames, where the debug server samples it. */
+extern uint64_t psxrecomp_guest_cycle_base;
+extern "C" unsigned long long beetle_core_get_guest_cycles(void);
+
+/* PSXRecomp oracle: per-instruction PC hook (drives the cyc_watch
+ * comparator). Fired by PS_CPU::RunReal before EVERY instruction fetch,
+ * i.e. before any cycle of that instruction (fetch, execute, stalls) is
+ * charged.
+ * pc = guest PC about to be fetched
+ * guest_cycles = absolute guest cycles charged for all prior instructions
+ * NULL (the default) costs one predicted-not-taken branch per instruction;
+ * the listener should install it only while a watch is armed. */
+typedef void (*psxrecomp_pc_cb_t)(uint32_t pc, uint64_t guest_cycles);
+extern psxrecomp_pc_cb_t g_psxrecomp_pc_cb;
+
#endif
+144
View File
@@ -0,0 +1,144 @@
diff --git a/libretro.cpp b/libretro.cpp
index e9c867c8..0924113d 100644
--- a/libretro.cpp
+++ b/libretro.cpp
@@ -512,6 +512,8 @@ FrontIO *PSX_FIO = NULL;
psxrecomp_wtrace_cb_t g_psxrecomp_wtrace_cb = NULL;
psxrecomp_fntrace_cb_t g_psxrecomp_fntrace_cb = NULL;
psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb = NULL;
+psxrecomp_rtrace_cb_t g_psxrecomp_rtrace_cb = NULL;
+psxrecomp_irq_cb_t g_psxrecomp_irq_cb = NULL;
MultiAccessSizeMem<512 * 1024, uint32, false> *BIOSROM = NULL;
MultiAccessSizeMem<65536, uint32, false> *PIOMem = NULL;
diff --git a/mednafen/psx/cpu.cpp b/mednafen/psx/cpu.cpp
index ea01b8bc..cbfd20dc 100644
--- a/mednafen/psx/cpu.cpp
+++ b/mednafen/psx/cpu.cpp
@@ -1426,6 +1426,10 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
uint32_t value = ReadMemory<uint32>(timestamp, address, false, true);
GTE_WriteDR(rt, value);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, value, PC, GPR[31], 4);
+
if (PGXP_GetModes() & PGXP_MODE_GTE)
PGXP_GTE_LWC2(instr, value, address);
@@ -2244,6 +2248,9 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
LDWhich = rt;
LDValue = (int32)ReadMemory<int8>(timestamp, address);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue & 0xFFu, PC, GPR[31], 1);
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
PGXP_CPU_LB(instr, LDValue, address);
@@ -2268,6 +2275,9 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
LDWhich = rt;
LDValue = ReadMemory<uint8>(timestamp, address);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue, PC, GPR[31], 1);
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
PGXP_CPU_LBU(instr, LDValue, address);
@@ -2301,6 +2311,9 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
LDWhich = rt;
LDValue = (int32)ReadMemory<int16>(timestamp, address);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue & 0xFFFFu, PC, GPR[31], 2);
}
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
PGXP_CPU_LH(instr, LDValue, address);
@@ -2334,6 +2347,9 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
LDWhich = rt;
LDValue = ReadMemory<uint16>(timestamp, address);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue, PC, GPR[31], 2);
}
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
@@ -2369,6 +2385,9 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
LDWhich = rt;
LDValue = ReadMemory<uint32>(timestamp, address);
+ /* PSXRecomp oracle hook: load trace. */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue, PC, GPR[31], 4);
}
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
@@ -2504,6 +2523,10 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
break;
}
+ /* PSXRecomp oracle hook: load trace (merged register value). */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue, PC, GPR[31], 4);
+
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
PGXP_CPU_LWL(instr, LDValue, address);
@@ -2583,6 +2606,10 @@ pscpu_timestamp_t PS_CPU::RunReal(pscpu_timestamp_t timestamp_in)
break;
}
+ /* PSXRecomp oracle hook: load trace (merged register value). */
+ if(MDFN_UNLIKELY(g_psxrecomp_rtrace_cb))
+ g_psxrecomp_rtrace_cb(address, LDValue, PC, GPR[31], 4);
+
if (PGXP_GetModes() & PGXP_MODE_MEMORY)
PGXP_CPU_LWR(instr, LDValue, address);
diff --git a/mednafen/psx/irq.cpp b/mednafen/psx/irq.cpp
index 029b9f84..d99dbbb3 100644
--- a/mednafen/psx/irq.cpp
+++ b/mednafen/psx/irq.cpp
@@ -73,6 +73,10 @@ void IRQ_Assert(int which, bool status)
Asserted |= 1 << which;
//Status |= 1 << which;
Status |= (old_Asserted ^ Asserted) & Asserted;
+
+ /* PSXRecomp oracle hook: rising edge of this IRQ line. */
+ if(MDFN_UNLIKELY(g_psxrecomp_irq_cb) && ((old_Asserted ^ Asserted) & Asserted))
+ g_psxrecomp_irq_cb(which);
}
Recalc();
diff --git a/mednafen/psx/psx.h b/mednafen/psx/psx.h
index fc53362d..05122c8c 100644
--- a/mednafen/psx/psx.h
+++ b/mednafen/psx/psx.h
@@ -178,4 +178,24 @@ typedef void (*psxrecomp_cdcmd_cb_t)(uint8_t cmd, uint8_t nargs,
uint32_t pc);
extern psxrecomp_cdcmd_cb_t g_psxrecomp_cdcmd_cb;
+/* PSXRecomp oracle: CPU load (read) trace callback — the read-side twin of
+ * the wtrace hook. Fired by PS_CPU after LB/LBU/LH/LHU/LW/LWL/LWR/LWC2 read
+ * memory (not on an address-error exception).
+ * addr = virtual address of the access (LWL/LWR: the unaligned address)
+ * value = loaded bus value, zero-extended to `size` bytes
+ * (LWL/LWR: the merged register value, size 4)
+ * pc = PC of the executing load instruction
+ * ra = $ra (GPR[31]) at time of load
+ * size = 1, 2, or 4 */
+typedef void (*psxrecomp_rtrace_cb_t)(uint32_t addr, uint32_t value,
+ uint32_t pc, uint32_t ra, uint8_t size);
+extern psxrecomp_rtrace_cb_t g_psxrecomp_rtrace_cb;
+
+/* PSXRecomp oracle: interrupt-line rising-edge callback. Fired by
+ * IRQ_Assert() when source `which` (the I_STAT bit number: 0=VBLANK,
+ * 2=CD, 3=DMA, ...) goes from deasserted to asserted, i.e. exactly when
+ * the I_STAT bit latches. */
+typedef void (*psxrecomp_irq_cb_t)(int which);
+extern psxrecomp_irq_cb_t g_psxrecomp_irq_cb;
+
#endif
+51
View File
@@ -0,0 +1,51 @@
diff --git a/mednafen/psx/spu.cpp b/mednafen/psx/spu.cpp
index 513628ae..b89efa08 100644
--- a/mednafen/psx/spu.cpp
+++ b/mednafen/psx/spu.cpp
@@ -73,6 +73,12 @@
#include "../state_helpers.h"
+/* PSXRecomp oracle: SPU voice event sink, defined by the psx-beetle host
+ * (runtime/src/beetle_libretro.cpp). kind: 1=KEYON 2=KEYOFF 3=END_STOP
+ * 4=END_LOOP; addr = SPU RAM address in 16-bit units; env = ADSR level. */
+extern "C" void psxrecomp_beetle_spu_event(unsigned kind, unsigned voice,
+ unsigned addr, unsigned env);
+
uint32_t IntermediateBufferPos;
int16_t IntermediateBuffer[4096][2];
@@ -328,6 +334,11 @@ void PS_SPU::RunDecoder(SPU_Voice *voice)
BlockEnd |= 1 << (voice - Voices);
+ /* PSXRecomp oracle hook: loop-end flag reached (repeat / stop). */
+ psxrecomp_beetle_spu_event((voice->DecodeFlags & 0x2) ? 4 : 3,
+ (unsigned)(voice - Voices), voice->CurAddr,
+ (unsigned)(uint16)voice->ADSR.EnvLevel);
+
if(!(voice->DecodeFlags & 0x2)) // Force enveloping to 0 if not "looping". TODO: Should we reset the ADSR divider counter too?
{
if(!(Noise_Mode & (1 << (voice - Voices))))
@@ -925,6 +936,10 @@ int32 PS_SPU::UpdateFromCDC(int32 clocks)
if(voice->DecodePlayDelay < 3)
{
ReleaseEnvelope(voice);
+
+ /* PSXRecomp oracle hook: KEYOFF took effect. */
+ psxrecomp_beetle_spu_event(2, voice_num, voice->CurAddr,
+ (unsigned)(uint16)voice->ADSR.EnvLevel);
}
}
}
@@ -950,6 +965,10 @@ int32 PS_SPU::UpdateFromCDC(int32 clocks)
voice->CurPhase = 0;
voice->CurAddr = voice->StartAddr & ~0x7;
voice->IgnoreSampLA = false;
+
+ /* PSXRecomp oracle hook: KEYON took effect. */
+ psxrecomp_beetle_spu_event(1, voice_num, voice->CurAddr,
+ (unsigned)(uint16)voice->ADSR.EnvLevel);
}
if(!(SPUControl & 0x8000))
+4
View File
@@ -909,6 +909,10 @@ on a fixed region -> next.
rebuild runtime/cyctest. New ruler #2 loops `mmio_timer` (Timer0 read → +3 = 1 dev + 2 compl)
and `mmio_spu` (32-bit SPU read → +38 = 36 + 2). VALIDATED: cyctest COMPILED (4600) == Beetle
(4382) EXACT on ALL 15 loops incl. mmio_timer +3 / mmio_spu +38; the 13 prior loops unchanged.
(Correction, 2026-09-29: except `icache_miss`, which reads +16 on this 15-loop ROM on both
backends, not +14. The two new loops sit before it and move its loop top from 0x80010144 to
0x80010170, a different offset in its cache line, so each of its two misses costs one cycle
more. Re-measured against live psx-beetle; see tools/cycle_testrom/README.md.)
Tomba 2 boots past the BIOS to its "SCEA Presents" intro splash, total_checks advancing, no
freeze (the faster-MMIO change did not trigger a device-timing cascade like load=4 did).
RESIDUAL (documented, unmodeled dynamic axis): DMACycleSteal — Beetle adds the live DMA
+7 -3
View File
@@ -957,14 +957,18 @@ if(NOT MSVC)
)
endif()
# ---- psx-beetle target (Beetle PSX libretro standalone, MinGW) ----
# ---- psx-beetle target (Beetle PSX libretro standalone; MinGW, Linux, macOS) ----
#
# Standalone binary that runs Beetle PSX (mednafen-psx libretro core) with
# a debug server exposing the SAME wire-protocol commands as psx-runtime,
# on a different port (4380). No recomp code linked.
# on its own port (default 4382, set in beetle_main.cpp; --port N). No recomp
# code linked.
#
# Requires beetle-psx built via:
# Requires beetle-psx with the docs/beetle_*.patch hooks, built via:
# cd beetle-psx && make platform=mingw_x86_64 STATIC_LINKING=1 HAVE_LIGHTREC=0
# (platform=unix on Linux, platform=osx on macOS; there the archive is named
# mednafen_psx_libretro.so/.dylib; copy it to libmednafen_psx.a). Recipes:
# docs/beetle-linux.md, docs/beetle-macos.md.
set(BEETLE_LIB "${CMAKE_SOURCE_DIR}/../beetle-psx/libmednafen_psx.a")
+43 -6
View File
@@ -5,7 +5,8 @@
* set_input, clear_input, pad_status, screenshot_file, sio_trace_*,
* wtrace_*, fntrace_*.
*
* Default port: 4380 (compile-time DEFAULT_DEBUG_PORT).
* Port: psx-beetle passes 4382 unless --port is given (beetle_main.cpp);
* 4380 is only the fallback for a port <= 0.
* JSON-over-newline protocol, same as recomp side.
*/
@@ -15,6 +16,8 @@
#include <stdarg.h>
#include <stdint.h>
#include "host_time.h"
#ifndef DEFAULT_DEBUG_PORT
#error DEFAULT_DEBUG_PORT must be defined by the beetle runtime target.
#endif
@@ -28,6 +31,7 @@
# define sock_close closesocket
#else
# include <sys/socket.h>
# include <sys/select.h>
# include <netinet/in.h>
# include <unistd.h>
# include <fcntl.h>
@@ -189,17 +193,50 @@ static int s_input_override = -1;
static int s_input_frames = 0;
/* ---- Send helpers ---- */
/* The client socket is non-blocking. A reply larger than the free space in
* the kernel send buffer (a 2 MB read_ram is ~4 MB of hex; macOS starts a
* TCP send buffer at 128 KB) makes send() fail with EWOULDBLOCK
* part-way through. Wait for the socket to drain and carry on, like
* debug_server.c's send_all_blocking, instead of dropping the connection
* and leaving the client a truncated JSON line. */
#define SEND_TOTAL_BUDGET_MS 15000
static int send_would_block(void) {
#ifdef _WIN32
return WSAGetLastError() == WSAEWOULDBLOCK;
#else
return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR;
#endif
}
/* Wait up to 100 ms for the client socket to become writable. */
static int send_wait_writable(void) {
fd_set wr;
FD_ZERO(&wr);
FD_SET(s_client, &wr);
struct timeval tv = { 0, 100 * 1000 };
return select((int)s_client + 1, NULL, &wr, NULL, &tv) >= 0;
}
static void send_raw(const char *data, int n) {
if (s_client == SOCK_INVALID) return;
int off = 0;
const uint64_t t_start = psx_host_mono_ms();
while (off < n) {
int k = send(s_client, data + off, n - off, 0);
if (k <= 0) {
sock_close(s_client);
s_client = SOCK_INVALID;
return;
if (k > 0) {
off += k;
continue;
}
off += k;
if (k < 0 && send_would_block() &&
psx_host_mono_ms() - t_start < SEND_TOTAL_BUDGET_MS &&
send_wait_writable()) {
continue;
}
sock_close(s_client);
s_client = SOCK_INVALID;
return;
}
}
+87 -15
View File
@@ -1018,7 +1018,9 @@ extern "C" uint64_t beetle_spu_event_total(void) { return s_spu_event_seq; }
* Returns 0 if the CDC isn't up. */
extern "C" int beetle_cdc_decode_volume(unsigned out[4])
{
extern PS_CDC *PSX_CDC;
/* PSX_CDC is declared by mednafen/psx/psx.h. A block-scope redeclaration
* here would get C language linkage from the enclosing extern "C"
* function, which clang rejects against psx.h's C++ declaration. */
if (!PSX_CDC) return 0;
uint8 m[2][2];
PSX_CDC->PSXRecomp_GetDecodeVolume(m);
@@ -1289,26 +1291,90 @@ extern "C" int beetle_history_get_snapshot(int slot, uint32_t *out_addr, int *ou
return 1;
}
/* ---- cyc_watch / exc_ring stubs ----
* Full per-instruction sampling lives in a beetle-psx DebugMode CPUHook that
* was never checked into docs/*.patch. GPU/OT oracle work only needs these
* symbols to link; wire returns empty/unarmed until the hook patch lands. */
/* ---- cyc_watch: Beetle half of the per-anchor cycle comparator ----
* Same spec and wire format as the native cyc_watch (runtime/src/debug_server.c,
* tools/cycle_compare.py, tools/cycle_testrom/measure.py): an entry is taken
* BEFORE the anchor instruction is fetched, and its `cycles` is the absolute
* guest cycle count charged for all prior instructions. With an `end` anchor
* (region mode) each entry is instead the cycles of one A->B pass.
*
* Driven by the beetle-psx per-instruction PC hook g_psxrecomp_pc_cb
* (docs/beetle_guest_cycles_hook.patch). The hook is installed only while a
* watch is armed, so an idle oracle pays one untaken branch per instruction.
* Beetle samples before every instruction; native samples at block leaders,
* so anchor a block leader (branch target / function entry) on both. */
#define BEETLE_CYC_WATCH_CAP 1024 /* == native CYC_WATCH_RING_CAP */
struct BeetleCycWatchEntry {
uint32_t hit_index;
uint32_t pc; /* matched physical PC */
uint64_t cycles; /* absolute guest cycles (region mode: A->B delta) */
};
static BeetleCycWatchEntry s_cw_ring[BEETLE_CYC_WATCH_CAP];
static uint32_t s_cw_anchor_raw = 0, s_cw_anchor_phys = 0;
static uint32_t s_cw_end_raw = 0, s_cw_end_phys = 0;
static uint32_t s_cw_max_hits = 0, s_cw_hits = 0;
static int s_cw_armed = 0;
static int s_cw_armed = 0;
static int s_cw_in_region = 0;
static uint64_t s_cw_region_start = 0;
static void cyc_watch_disarm(void) {
s_cw_armed = 0;
g_psxrecomp_pc_cb = NULL;
}
/* Runs on the emulation thread before every instruction while armed. */
static void cyc_watch_pc_cb(uint32_t pc, uint64_t guest_cycles) {
const uint32_t phys = pc & 0x1FFFFFFFu;
if (s_cw_end_phys != 0u) { /* REGION mode (A..B) */
if (!s_cw_in_region) {
if (phys == s_cw_anchor_phys) {
s_cw_region_start = guest_cycles;
s_cw_in_region = 1;
}
} else if (phys == s_cw_end_phys) {
BeetleCycWatchEntry *e = &s_cw_ring[s_cw_hits];
e->hit_index = s_cw_hits;
e->pc = phys;
e->cycles = guest_cycles - s_cw_region_start;
s_cw_hits++;
s_cw_in_region = 0;
if (s_cw_hits >= s_cw_max_hits) cyc_watch_disarm();
}
return;
}
if (phys != s_cw_anchor_phys) return; /* single-anchor mode */
BeetleCycWatchEntry *e = &s_cw_ring[s_cw_hits];
e->hit_index = s_cw_hits;
e->pc = phys;
e->cycles = guest_cycles;
s_cw_hits++;
if (s_cw_hits >= s_cw_max_hits) cyc_watch_disarm();
}
extern "C" void beetle_cyc_watch_arm(uint32_t anchor_raw, uint32_t end_raw, int n) {
s_cw_anchor_raw = anchor_raw;
s_cw_anchor_phys = anchor_raw & 0x1FFFFFFFu;
s_cw_end_raw = end_raw;
s_cw_end_phys = end_raw & 0x1FFFFFFFu;
s_cw_max_hits = (n > 0) ? (uint32_t)n : 16u;
s_cw_hits = 0;
if (n < 1) n = 1;
if (n > BEETLE_CYC_WATCH_CAP) n = BEETLE_CYC_WATCH_CAP;
cyc_watch_disarm(); /* no sampling mid-reset */
s_cw_anchor_raw = anchor_raw;
s_cw_anchor_phys = anchor_raw & 0x1FFFFFFFu;
s_cw_end_raw = end_raw;
s_cw_end_phys = end_raw & 0x1FFFFFFFu;
s_cw_max_hits = (uint32_t)n;
s_cw_hits = 0;
s_cw_in_region = 0;
s_cw_region_start = 0;
std::memset(s_cw_ring, 0, sizeof(s_cw_ring));
s_cw_armed = 1;
g_psxrecomp_pc_cb = cyc_watch_pc_cb;
}
extern "C" void beetle_cyc_watch_clear(void) {
s_cw_armed = 0; s_cw_hits = 0;
cyc_watch_disarm();
s_cw_hits = 0;
s_cw_anchor_raw = s_cw_anchor_phys = 0;
s_cw_end_raw = s_cw_end_phys = 0;
s_cw_in_region = 0;
s_cw_region_start = 0;
std::memset(s_cw_ring, 0, sizeof(s_cw_ring));
}
extern "C" void beetle_cyc_watch_get_state(uint32_t *anchor_raw, uint32_t *anchor_phys,
uint32_t *end_raw, uint32_t *end_phys,
@@ -1323,10 +1389,16 @@ extern "C" void beetle_cyc_watch_get_state(uint32_t *anchor_raw, uint32_t *ancho
}
extern "C" int beetle_cyc_watch_get(uint32_t i, uint32_t *hit_index, uint32_t *pc,
unsigned long long *cyc) {
(void)i; (void)hit_index; (void)pc; (void)cyc;
return 0;
if (i >= s_cw_hits) return 0;
const BeetleCycWatchEntry *e = &s_cw_ring[i];
if (hit_index) *hit_index = e->hit_index;
if (pc) *pc = e->pc;
if (cyc) *cyc = (unsigned long long)e->cycles;
return 1;
}
/* exc_ring stays a stub: no psxrecomp tool reads it, and the exception-ring
* hook it wrapped was never published. The wire answers an empty ring. */
extern "C" int retro_psxref_exc_ring_dump(char *out, int cap) {
if (!out || cap < 32) return -1;
return std::snprintf(out, (size_t)cap, "{\"ok\":true,\"entries\":[]}");
+24 -1
View File
@@ -1,6 +1,7 @@
/* beetle_main.cpp — psx-beetle entry point.
* Standalone harness around Beetle PSX libretro core. SDL window for
* Beetle's framebuffer, keyboard input, TCP debug server on port 4380. */
* Beetle's framebuffer, keyboard input, TCP debug server on port 4382
* (--port N). */
#include "psx_sdl.h"
#include "frame_pacing.h"
@@ -15,6 +16,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 +153,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 +266,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
@@ -9991,24 +9991,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;
@@ -10023,6 +10011,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\","
@@ -14466,6 +14470,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"}
+7 -2
View File
@@ -15,8 +15,13 @@ set(PSXRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH
include("${PSXRECOMP_ROOT}/runtime/runtime.cmake")
set(GAME_GENERATED_FULL
"${CMAKE_CURRENT_SOURCE_DIR}/generated/cycle_testrom.exe_full.c")
# psxrecomp-game writes the recompiled text as shards (<exe>_full_NN.c).
file(GLOB GAME_GENERATED_FULL
"${CMAKE_CURRENT_SOURCE_DIR}/generated/cycle_testrom.exe_full_*.c")
if(NOT GAME_GENERATED_FULL)
message(FATAL_ERROR "cycle_testrom: no generated/cycle_testrom.exe_full_*.c; "
"run psxrecomp-game --config game.toml --project-root ../.. first (README)")
endif()
set(GAME_GENERATED_DISPATCH
"${CMAKE_CURRENT_SOURCE_DIR}/generated/cycle_testrom.exe_dispatch.c")
+57
View File
@@ -70,6 +70,40 @@ cd disc && ../../mkpsxiso/mkpsxiso-2.20-win64/mkpsxiso.exe -y cyctest.xml
copyrighted Sony data, local only. Everything else (gen, xml, SYSTEM.CNF) is
tracked, so each developer reproduces the disc from a disc they own.
### Without mkpsxiso (macOS, Linux)
`mkdisc.py` builds the same kind of disc in pure Python: an ISO9660 volume
with `SYSTEM.CNF` and the EXE as `CYCT_001.01`, in Mode 2 Form 1 sectors with
a correct EDC/ECC (Beetle checks them). It copies the license area (sectors
0-15) verbatim from the raw `.bin` of a disc you own. The license is needed
even with OpenBIOS, which does not check it: Beetle's CD controller reports a
disc without the license string as unlicensed, and OpenBIOS then retries
GetID forever.
```bash
python3 gen_testrom.py cycle_testrom.exe
python3 mkdisc.py --cnf disc/SYSTEM.CNF --exe cycle_testrom.exe \
--exe-name CYCT_001.01 --volume CYCT00101 --out disc/cyctest.bin \
--license-from "<a PS1 disc you own>.bin"
```
The native side, from the framework root (the recompiled OpenBIOS comes
from `tools/regen_bios.sh --config bios/OpenBIOS.toml`):
```bash
cd tools/cycle_testrom
../../recompiler/build/psxrecomp-game --config game.toml --project-root ../..
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DPSX_RECOMP_UI=OFF -DPSX_DEBUG_TOOLS=ON
ninja -C build psx-cyctest # binary: build/Cycle_Test_ROM
build/Cycle_Test_ROM --no-launcher --headless --game game.toml \
--bios ../../bios/openbios.bin --disc disc/cyctest.cue --debug-port 4600
```
`--headless` runs unpaced with no window; a plain `&` launch works on macOS.
Beetle runs headless too: `SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software
psx-beetle ...` (see `docs/beetle-macos.md`).
## Measure
```bash
@@ -134,6 +168,29 @@ STAGE 2 (pending): charge the same model in the compiled emitters (per cache-lin
leader) and flip PSX_ICACHE on by default; validate ruler #1's cold first-hit spike
(Beetle 84/77 vs steady 56) on the compiled path.
**The icache_miss cost depends on where the loop sits in its cache line.** A
miss charges +3 plus one cycle per word from the missed word to the end of the
16-byte line, so each of the loop's two misses costs 4 to 7 cycles. The +14
above was measured with the loop top at 0x80010144 (word 1 of its line: 6 per
miss). Adding the `mmio_timer`/`mmio_spu` loops moved it to 0x80010170 (word 0:
7 per miss), so the current 15-loop ROM reads **+16 (per-iter 19) on both
backends**. Re-measured 2026-09-29 with psx-beetle built on macOS
(`docs/beetle-macos.md`), OpenBIOS on both backends: the 13-loop ROM from
0edb9355 gives +14 and the current one +16. Beetle's per-iteration cycles for
the other loops (the mmio loops exist only on the current ROM; the rest read
the same on both):
| baseline | alu | load | load2 | load_use | div | div_spaced | mult |
|---|---|---|---|---|---|---|---|
| 3 | 4 | 8 | 14 | 8 | 41 | 41 | 18 |
| gte_rtps | gte_nclip | gte_read_use | ld_div | mmio_timer | mmio_spu |
|---|---|---|---|---|---|
| 14 | 7 | 14 | 49 | 6 | 41 |
(The gte rows in the 2026-06-26 table below are the warm-up values; the
steady-state ones are these, see FAITHFUL_TIMING_PLAN.md 2026-06-27.)
## Beetle ORACLE results (2026-06-26 — the HW cost targets)
Per-iteration cycle delta, and component cost (minus baseline=3):
+1 -1
View File
@@ -9,7 +9,7 @@ exe = "cycle_testrom.exe"
disc = "disc/cyctest.cue" # synthetic licensed disc (gen via tools/mkpsxiso)
load_address = "0x80010000"
entry_pc = "0x80010000"
text_size = "0x00000800"
text_size = "0x00002000" # whole 0x1800-byte text, page-rounded (the parser rejects an unaligned truncation)
[recompiler]
seeds = "seeds.txt"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""mkdisc.py - build a minimal PS1 boot disc (MODE2/2352 BIN + CUE) in pure Python.
A stand-in for mkpsxiso where it is not available (macOS/Linux without the
Windows binary). Writes an ISO9660 volume with SYSTEM.CNF and one boot EXE,
as Mode 2 Form 1 sectors with a correct EDC/ECC (Beetle verifies them).
Sectors 0-15 (the license area) are zero-filled unless --license-from names
a raw MODE2/2352 .bin of a PS1 disc you own; its first 16 sectors are copied
verbatim. That area is copyrighted Sony data: keep the built image local
(disc/*.bin and disc/*.cue are gitignored), never commit or redistribute it.
It is needed even with OpenBIOS: Beetle's CD controller reports a disc
without the license string in sector 4 as unlicensed, and OpenBIOS then
retries GetID forever instead of booting.
Usage (from tools/cycle_testrom, after gen_testrom.py):
python3 mkdisc.py --cnf disc/SYSTEM.CNF --exe cycle_testrom.exe \
--exe-name CYCT_001.01 --volume CYCT00101 --out disc/cyctest.bin \
--license-from "<a PS1 disc you own>.bin"
"""
import argparse, os, struct
SECTOR = 2352
USER = 2048
# ---- EDC / ECC (ECMA-130 Annex A/B; same tables as ecm.c / Mednafen lec) ----
EDC_TABLE = []
for i in range(256):
e = i
for _ in range(8):
e = (e >> 1) ^ (0xD8018001 if e & 1 else 0)
EDC_TABLE.append(e)
def edc(data):
e = 0
for b in data:
e = (e >> 8) ^ EDC_TABLE[(e ^ b) & 0xFF]
return e
ECC_F = [0] * 256
ECC_B = [0] * 256
for i in range(256):
j = ((i << 1) ^ (0x11D if i & 0x80 else 0)) & 0xFF
ECC_F[i] = j
ECC_B[i ^ j] = i
def ecc_block(sec, major_count, minor_count, major_mult, minor_inc, dest):
size = major_count * minor_count
for major in range(major_count):
index = (major >> 1) * major_mult + (major & 1)
a = b = 0
for _ in range(minor_count):
t = sec[0xC + index]
index += minor_inc
if index >= size:
index -= size
a ^= t
b ^= t
a = ECC_F[a]
a = ECC_B[ECC_F[a] ^ b]
sec[dest + major] = a
sec[dest + major + major_count] = a ^ b
def bcd(n):
return ((n // 10) << 4) | (n % 10)
def mode2_form1(lba, user, submode=0x08):
assert len(user) == USER
s = bytearray(SECTOR)
s[0] = 0x00
s[1:11] = b"\xFF" * 10
s[11] = 0x00
a = lba + 150
s[12:16] = bytes([bcd(a // 4500), bcd((a // 75) % 60), bcd(a % 75), 2])
sub = bytes([0, 0, submode, 0])
s[16:20] = sub
s[20:24] = sub
s[24:24 + USER] = user
s[2072:2076] = struct.pack("<I", edc(s[16:2072]))
hdr = bytes(s[12:16])
s[12:16] = b"\0\0\0\0" # mode 2: header excluded from ECC
ecc_block(s, 86, 24, 2, 86, 0x81C) # P
ecc_block(s, 52, 43, 86, 88, 0x8C8) # Q
s[12:16] = hdr
return bytes(s)
# ---- ISO9660 ----
def both16(v): return struct.pack("<H", v) + struct.pack(">H", v)
def both32(v): return struct.pack("<I", v) + struct.pack(">I", v)
def padstr(s, n): return s.encode("ascii").ljust(n, b" ")[:n]
DATE7 = bytes([126, 9, 29, 0, 0, 0, 0]) # 2026-09-29 00:00:00 GMT
DATE17 = b"2026092900000000" + b"\0"
def dirrec(ident, lba, size, is_dir):
ln = 33 + len(ident)
if ln & 1:
ln += 1
r = bytearray(ln)
r[0] = ln
r[2:10] = both32(lba)
r[10:18] = both32(size)
r[18:25] = DATE7
r[25] = 2 if is_dir else 0
r[28:32] = both16(1)
r[32] = len(ident)
r[33:33 + len(ident)] = ident
return bytes(r)
def sectors_for(n):
return max(1, (n + USER - 1) // USER)
def main():
p = argparse.ArgumentParser()
p.add_argument("--cnf", required=True)
p.add_argument("--exe", required=True)
p.add_argument("--exe-name", required=True, help="ISO name, e.g. CYCT_001.01")
p.add_argument("--volume", default="PSXDISC")
p.add_argument("--out", required=True, help="output .bin (a .cue is written beside it)")
p.add_argument("--license-from", help="raw MODE2/2352 .bin of a PS1 disc you own: "
"copy its sectors 0-15 (the license area) verbatim")
p.add_argument("--pad-sectors", type=int, default=300)
a = p.parse_args()
cnf = open(a.cnf, "rb").read()
exe = open(a.exe, "rb").read()
files = sorted([(a.exe_name.upper() + ";1", exe), ("SYSTEM.CNF;1", cnf)])
LBA_PVD, LBA_TERM, LBA_LPT, LBA_MPT, LBA_ROOT = 16, 17, 18, 19, 20
lba = 21
layout = []
for name, data in files:
layout.append((name, lba, data))
lba += sectors_for(len(data))
total = max(lba, a.pad_sectors)
root = bytearray()
root += dirrec(b"\x00", LBA_ROOT, USER, True)
root += dirrec(b"\x01", LBA_ROOT, USER, True)
for name, flba, data in layout:
root += dirrec(name.encode("ascii"), flba, len(data), False)
assert len(root) <= USER
root = bytes(root).ljust(USER, b"\0")
pt_l = struct.pack("<BBIH", 1, 0, LBA_ROOT, 1) + b"\0\0"
pt_m = struct.pack(">BBIH", 1, 0, LBA_ROOT, 1) + b"\0\0"
pvd = bytearray(USER)
pvd[0] = 1
pvd[1:6] = b"CD001"
pvd[6] = 1
pvd[8:40] = padstr("PLAYSTATION", 32)
pvd[40:72] = padstr(a.volume, 32)
pvd[80:88] = both32(total)
pvd[120:124] = both16(1)
pvd[124:128] = both16(1)
pvd[128:132] = both16(USER)
pvd[132:140] = both32(len(pt_l))
pvd[140:144] = struct.pack("<I", LBA_LPT)
pvd[148:152] = struct.pack(">I", LBA_MPT)
pvd[156:190] = dirrec(b"\x00", LBA_ROOT, USER, True)
pvd[190:318] = padstr(a.volume, 128)
pvd[318:446] = padstr("PSXRECOMP", 128)
pvd[446:574] = padstr("MKDISC.PY", 128)
pvd[574:702] = padstr("PLAYSTATION", 128)
pvd[702:739] = padstr("", 37)
pvd[739:776] = padstr("", 37)
pvd[776:813] = padstr("", 37)
for off in (813, 830, 847, 864):
pvd[off:off + 17] = DATE17
pvd[881] = 1
term = bytearray(USER)
term[0] = 0xFF
term[1:6] = b"CD001"
term[6] = 1
out = bytearray()
if a.license_from:
with open(a.license_from, "rb") as f:
lic = f.read(16 * SECTOR)
assert len(lic) == 16 * SECTOR
out += lic
else:
for i in range(16):
out += mode2_form1(i, bytes(USER))
out += mode2_form1(LBA_PVD, bytes(pvd), 0x09)
out += mode2_form1(LBA_TERM, bytes(term), 0x89)
out += mode2_form1(LBA_LPT, pt_l.ljust(USER, b"\0"), 0x89)
out += mode2_form1(LBA_MPT, pt_m.ljust(USER, b"\0"), 0x89)
out += mode2_form1(LBA_ROOT, root, 0x89)
cur = 21
for name, flba, data in layout:
n = sectors_for(len(data))
for k in range(n):
chunk = data[k * USER:(k + 1) * USER].ljust(USER, b"\0")
out += mode2_form1(cur, chunk, 0x89 if k == n - 1 else 0x08)
cur += 1
while cur < total:
out += mode2_form1(cur, bytes(USER))
cur += 1
with open(a.out, "wb") as f:
f.write(out)
cue = os.path.splitext(a.out)[0] + ".cue"
with open(cue, "w") as f:
f.write('FILE "%s" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n'
% os.path.basename(a.out))
print(f"wrote {a.out} ({total} sectors) and {cue}")
if __name__ == "__main__":
main()