feat(timing): BIOS-kernel cycle ruler + fix load double-count

Build the game-independent cycle-attribution ruler the plan called for, and
fix two real bugs it exposed.

Tooling (debug_server.c, full_function_emitter.cpp):
- cyc_watch double-fire dedupe: observe fired from BOTH the dispatcher and the
  function prologue at the same cycle, double-recording every dispatched entry.
  Key on (phys,cycle); a genuine re-entry is always >=1 cycle later.
- Per-block-leader cycle observe (debug_server_cyc_observe), emitted by the BIOS
  emitter under #ifndef PSX_NO_DEBUG_TOOLS (prod = zero overhead). Native now
  samples at EVERY compiled block leader (matching Beetle's before-instruction
  sample), so cyc_watch can anchor any block-leader PC, not just fn entries.
  Emitted at normalize_address() so relocated-kernel anchors match the RAM space.

Bug fix (psx_instr_cost.h):
- Loads were double-counted. Stage-2 #1a (2ef47bd) returned 3 for loads (+2
  data-access) in psx_instr_base_cycles WHILE memory.c charge_main_ram_read
  already charged +6 per main-RAM read. The ruler region [0x80001C5C->0x80001CA4]
  decomposed native=34 as 16(base) + 2 + 12(mem) with loads counted twice. The
  opaque 0x80017FC4 window hid it because the load over-charge masked the entirely
  unmodeled divu->mflo stall. Reverted the opcode fn to pure execute base (loads=1)
  per its own stated contract; memory.c is the single address-keyed data-access
  owner (like Beetle ReadMemory). Ruler: native 34->30 (exact), FMV unaffected.

Docs: CYCLE_MODEL_BEETLE.md (full R3000A model transcribed from in-tree Beetle
cpu.cpp, cited), plan + burndown updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-26 22:14:35 -07:00
parent 2b5e69fe8f
commit 9cec60a30d
6 changed files with 275 additions and 30 deletions
+19 -6
View File
@@ -84,14 +84,27 @@ Status: STRONG (recompiler core; proven byte-identical to interp on many funcs).
## Axis 2 — Cycle/timing ← ACTIVE (see FAITHFUL_TIMING_PLAN.md)
Status: Stage-1 (1 cycle/insn, single-source seam in place); Stage-2 in progress.
Oracle model fully transcribed in `CYCLE_MODEL_BEETLE.md`. Game-independent
BIOS-kernel ruler BUILT (region [0x80001C5C→0x80001CA4]); per-block-leader cycle
observe added to the recompiler so ANY block leader is anchorable on both backends.
- [x] Single-source `psx_instr_base_cycles` seam (identity), both backends.
- [ ] Mult/div latency — Beetle cpu.cpp MULT_Tab/muldiv; psx-spx.
- [x] cyc_watch dispatcher+prologue double-fire dedupe (was corrupting native Δ).
- [x] Per-block-leader observe (native now samples like Beetle, not only fn-entry).
- [x] **Load double-count FIXED**: Stage-2 #1a put +2 data-access in
psx_instr_base_cycles while memory.c already charged +6/main-RAM-load → counted
twice. Reverted opcode fn to pure execute base; memory.c is the single address-
keyed owner. Ruler [c5c→ca4]: native 34→30 (exact), no FMV regression.
- [ ] Memory wait-state CALIBRATION: memory.c flat +6/RAM-read vs Beetle ReadMemory
(region wait + completion +2/+1 − load-delay absorb; scratchpad=0). Δ-gate.
- [ ] Mult/div latency — Beetle cpu.cpp MULT_Tab/muldiv; psx-spx. (Ruler shows the
unmodeled divu→mflo stall is the dominant remaining −26 in [c5c→ca4].)
- [ ] GTE per-command cycles — Beetle gte.cpp GTE_Instruction; DuckStation; psx-spx.
- [ ] Memory wait-states by region (RAM/BIOS-ROM/scratchpad/MMIO) — Beetle
ReadMemory/PSX_MemRead timing; psx-spx "Memory Control".
- [ ] Instruction-fetch / I-cache timing — Beetle ReadInstruction; psx-spx.
- [ ] Validation: native cumulative cycles == Beetle at convergence (needs the
comparator above).
- [ ] Instruction-fetch / I-cache timing — Beetle ReadInstruction (+0 hit / +4 KSEG1
/ +3+refill miss). Ruler's 56→84 cold spread is the I-cache line-refill transient.
- [ ] **HW test-ROM ruler (#2)** — Amidog CPU/GTE timing ROMs for hand-crafted
single-COMPONENT isolation (div-only, load-only loops) that organic BIOS code
can't give (the prologue combines div+loads in one block). Strongest validator.
- [ ] Validation: native cumulative cycles == Beetle == analytic on the rulers.
## Axis 3 — Interrupt / event timing
+136
View File
@@ -0,0 +1,136 @@
# R3000A Cycle Model — extracted from the Beetle/Mednafen oracle
Companion to FAITHFUL_TIMING_PLAN.md (§3b/§3c) and ACCURACY_BURNDOWN.md (axis 2).
This is the **ground-truth model** our shared `psx_instr_base_cycles` (+ the
memory-path wait-state charger) must reproduce. Every number here is transcribed
**verbatim from the in-tree oracle** `psxrecomp/beetle-psx/mednafen/psx/cpu.cpp`
(main checkout) with the exact line cited, cross-referenced against nocash psx-spx.
These are HARDWARE FACTS (cycle counts), not GPL-protected expression — we
re-implement the model in our own code, we do not paste Beetle's code.
> Method (CLAUDE.md §15, Rule -1): a number here is only trusted once it has been
> VALIDATED at runtime via the cyc_watch Δ gate (native==Beetle==analytic on a
> clean ruler). Until then it is a transcription HYPOTHESIS.
---
## The accumulator
Beetle accumulates a single per-slice `pscpu_timestamp_t timestamp`. The cyc_watch
oracle samples it **at block entry, BEFORE the anchor instruction executes**
(cpu.cpp:748-749), identical to the native capture point. So the Δ between two
consecutive same-anchor hits (or a two-anchor A→B region) is the total guest
cycles charged for exactly the code between them. THIS is what we match.
## 1. Per-instruction base cost (cpu.cpp:795-798)
```
if (ReadAbsorb[ReadAbsorbWhich]) ReadAbsorb[ReadAbsorbWhich]--; // absorb a pending load-delay
else timestamp++; // otherwise +1
```
- **Base = +1 per retired instruction.** This is the Stage-1 identity model we
already have.
- BUT a pending **load-delay absorb** (see §3) is consumed *instead* of charging
the +1. So an instruction that reads a just-loaded register can be effectively
free. This is the pipeline overlap; it makes naive "loads cost +2" an
OVER-count when the result is consumed immediately.
## 2. Instruction fetch / I-cache (cpu.cpp:534-601, ReadInstruction)
Charged into `timestamp` at fetch time, BEFORE the base +1. Depends on the
address region and I-cache state:
- **I-cache HIT** (`ICache[idx].TV == address`): **+0**. (Steady-state of any loop
living in cached RAM after its first pass.)
- **Cache-DISABLED** — `address >= 0xA0000000` (KSEG1) or BIU bit 0x800 clear:
**+4 every fetch** (cpu.cpp:550; "approximate best-case cache-disabled time,
can be 5 in some nop runs"). **BIOS ROM at 0xBFC00000 is KSEG1 → +4/fetch
always.** Game/kernel code relocated to KSEG0/KUSEG RAM is cached.
- **Cache-ENABLED MISS** (cached RAM, line tag mismatch): **+3** (cpu.cpp:565)
then **+1 per word refilled** from the missing word index to the end of the
4-word (16-byte) line (fall-through switch cpu.cpp:568-595). So a miss on:
- word 0 of the line → +3 +4 = **+7**, then next 3 fetches in that line HIT (+0)
- word 1 → +3 +3 = +6 ; word 2 → +5 ; word 3 → +4.
Average over a long straight cached run ≈ (7+0+0+0)/4 ≈ **+1.75/instr**; a tight
loop fully resident in cache after warm-up → **+0/instr**.
Recompiler consequence: fetch is a per-PC/region constant we can fold into the
per-block charge (cache-enabled steady state for RAM; flat +4 for KSEG1 ROM
blocks). The cached-miss warm-up is a per-line transient — model the steady state,
account the warm-up only where a region is entered cold.
## 3. Data loads — ReadMemory (cpu.cpp:364-451)
For a load instruction (LB/LH/LWL/LW/LBU/LHU/LWR; LWC2 = LWC timing):
1. **Scratchpad** `0x1F800000..0x1F8003FF` (cpu.cpp:414-422): early return,
`LDAbsorb=0`, **+0**. Scratchpad is the D-cache — fast, no stall.
2. Otherwise: `timestamp += (ReadFudge>>4)&2` (cpu.cpp:424) — a **+0 or +2
read-after-load "fudge"** (penalty when the previous op set ReadFudge).
3. Region wait-state: `PSX_MemRead*(lts, address)` advances `lts` by the bus
wait-state for that region (main RAM, BIOS ROM, MMIO each differ — see the bus/
memory map; main RAM is the fast case).
4. `lts += (LWC_timing ? 1 : 2)` (cpu.cpp:442-445) — the load completion cost
(**+2** normal CPU load, **+1** LWC2/GTE load).
5. `LDAbsorb = lts - timestamp` (cpu.cpp:447) — the whole data-access cost becomes
the **load-delay absorb**: it is handed to `ReadAbsorb[rt]` in DO_LDS
(cpu.cpp:800), so the next instruction(s) that read `rt` consume it via §1
instead of charging their own +1. Net: a load costs (fudge + region + 2), but up
to that many following dependent-instruction +1's are absorbed back.
> Our current `psx_instr_base_cycles` charges a flat +3 for CPU loads (1 base + 2),
> +2 for LWC2. That captures step 4 only — it MISSES scratchpad=+0 (over-counts),
> the region wait-state (under-counts non-RAM), the ReadFudge (+0/2), and the
> absorb give-back (over-counts when the load result is used immediately). Those
> are the next Δ-gated refinements; some push native UP, some DOWN.
## 4. Stores — WriteMemory (cpu.cpp:453+)
Stores are **POSTED**: WriteMemory adds **no CPU stall** to `timestamp` (scratchpad
and RAM both just write). So a store = the +1 base only. (Bus-level write latency
is hidden behind the write buffer.)
## 5. Mult/Div latency (cpu.cpp: MULT_Tab24 ~L101, muldiv_ts_done ~L154, 724/758/764)
MULT/DIV set a **completion timestamp** `muldiv_ts_done`; a later **MFHI/MFLO
stalls** until then (does not block other instructions). Documented latencies:
- **MULT/MULTU**: ~6–13 cycles by operand magnitude (`MULT_Tab24` lookup on the
leading bits of the multiplier).
- **DIV/DIVU**: ~**36** cycles (fixed).
Model: a small latency attached to the mult/div op + a stall-on-read at MFHI/MFLO
if the consumer arrives before `muldiv_ts_done`. (In straight code with enough
spacing the stall is hidden.)
## 6. GTE / COP2 per-command (gte.cpp GTE_Instruction ~L1713)
Each COP2 op returns its own cycle count; GTE sets `gte_ts_done` and a COP2 read
stalls until then (cpu.cpp:1334/1369 set LDAbsorb from `gte_ts_done`). Well-known
table (psx-spx; verify each against the gte.cpp op-fn return before trusting):
RTPS=15 RTPT=23 MVMVA=8 SQR=5 OP=6 AVSZ3=5 AVSZ4=6 NCLIP=8 NCDS=19 NCDT=44
NCCS=17 NCCT=39 NCS=14 NCT=30 CC=11 CDP=13 DPCS=8 DPCT=17 DCPL=8 INTPL=8
GPF=5 GPL=5.
## 7. Branches / jumps
No extra base cost beyond §1 (+1) and the fetch of the delay-slot instruction.
The R3000 has no branch-likely; the delay slot always executes and is charged its
own +1 (this is the −8 delay-slot-ownership class already fixed in our emitter).
Exception entry has its own path (Exception()).
---
## What our model currently has vs. this
| Component | Beetle (this doc) | Our psx_instr_base_cycles | Gap |
|------------------|--------------------------------------|--------------------------------|-----|
| base/instr | +1 (minus absorb) | +1 | absorb give-back not modeled |
| CPU load | fudge + region + 2, becomes absorb | flat +3 | scratchpad/region/fudge/absorb |
| LWC2 load | region + 1, becomes absorb | +2 | same |
| store | +0 (posted) | +1 (the base) — OK | — |
| fetch / I-cache | +0 hit / +4 KSEG1 / +3+refill miss | not modeled (0) | the big RAM-cached vs ROM lever |
| mult/div | mult 6-13, div 36, stall-on-read | not modeled (+1) | yes |
| GTE per-command | per-op table 5..44 | not modeled (+1) | yes |
Implement one row at a time, each Δ-gated on a clean ruler (BIOS leaf fn +
HW test ROM), each verified native==Beetle==analytic, FMV-no-regression, commit.
+44
View File
@@ -213,6 +213,50 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-26 (RULER #1 BUILT + load double-count bug found & fixed):** Built the
game-independent BIOS-kernel cycle ruler the §3c "TOOLING NEXT" called for, and
it immediately paid off. Details:
- **New oracle-model doc `CYCLE_MODEL_BEETLE.md`** — transcribed the full R3000A
cycle model verbatim from in-tree Beetle cpu.cpp (base +1/insn minus load-delay
absorb; I-cache fetch +0 hit / +4 KSEG1 / +3+refill miss; ReadMemory loads
scratchpad=0/region-wait+2, posted stores; mult 6-13 / div 36 stall-on-MFHI/LO;
GTE per-command table). This is the calibration ground truth.
- **cyc_watch double-fire FIXED** (debug_server.c): observe was called from BOTH
the dispatcher (trace_dispatch) AND the function prologue (log_call_entry) at the
same cycle → every dispatched entry double-recorded. Added (phys,cycle) dedupe.
Real tooling bug (Rule 15); native deltas were corrupted before this.
- **Per-block-leader cycle observe ADDED** (full_function_emitter.cpp →
debug_server_cyc_observe, #ifndef PSX_NO_DEBUG_TOOLS so prod = zero overhead).
Native previously observed only at FUNCTION ENTRIES; now it samples at EVERY
compiled block leader, matching Beetle's before-every-instruction sample. This
lets cyc_watch anchor ANY block-leader PC (interior loop tops, prologue exits) →
a clean KNOWN-instruction region on both backends. Emitted at normalize_address()
(runtime phys) so relocated-kernel anchors match.
- **THE RULER:** BIOS kernel EvCB-search fn at guest 0x80001C5C (relocated ROM,
identical in every PSX title). Region [0x80001C5C→0x80001CA4] (18-insn prologue,
contains divu+mflo + 2 RAM loads, no MMIO/GTE). Both backends now record it.
- **BUG FOUND — loads double-counted.** Native [c5c→ca4] = 34, fully decomposed:
block c5c advance 20 (=14×1 + **2 loads×3**) + block c9c 2 + **memory.c +6×2 = 12**
= 34. The Stage-2 #1a commit (2ef47bd) made psx_instr_base_cycles return 3 for
loads (+2 data-access) WHILE memory.c's charge_main_ram_read already charged +6
per main-RAM read — the load data-access cost was counted TWICE. The opaque
0x80017FC4 window hid this because the load over-charge masked the entirely-
unmodeled divu→mflo stall (~30 cyc Beetle, 0 native). **FIX:** reverted
psx_instr_base_cycles to pure execute base (loads=1), per the header's own stated
"data access charged separately in the memory path" contract. memory.c is the
single address-keyed owner (like Beetle's ReadMemory). Regen BIOS+game, rebuild.
- **RESULT:** native [c5c→ca4] 34→**30** (exact: 16+2+12), dead stable. Beetle 56
steady (84 cold = I-cache line refill). FMV still streams (i_stat 0x8D, no
regression). The remaining −26 gap is now HONEST and decomposed: native is
missing the divu→mflo execute stall, and memory.c's flat +6/load needs Beetle
calibration. NEXT: isolate those two components — the BIOS prologue combines
div+loads in one block (leader anchors can't split them), so the principled next
step is **ruler #2 (HW test ROM, Amidog)** for hand-crafted single-component
isolation loops (div-only, load-only), per the user's "do both rulers /
completeness not convenience" directive. Then Δ-gate each EXECUTE-latency
component (mult/div, GTE) into psx_instr_base_cycles and CALIBRATE memory.c's
wait-state per region. All on wt/tomba2-cycle-audit, uncommitted.
- **2026-06-26 (measure: Beetle cycle clock BUILT + VALIDATED):** Added absolute
guest-cycle exposure to the Beetle oracle (MAIN checkout, additive diagnostic):
beetle-psx/libretro.cpp accumulates per-frame `timestamp` (CPU->Run slice) into
+16
View File
@@ -371,6 +371,19 @@ bool FullFunctionEmitter::emit_function(
// can service vblank and other hardware interrupts.
if (block_leaders.count(addr)) {
out += fmt::format("label_{:08X}:\n", addr);
// Per-block-leader cycle observe (cyc_watch ruler). Sampled BEFORE
// this block's cycle advance, so it reports cumulative cycles for
// all PRIOR blocks — matching Beetle's before-instruction sample
// and the cycle_compare.py anchor semantics. Debug-only: prod
// (PSX_NO_DEBUG_TOOLS) emits nothing → zero overhead.
out += "#ifndef PSX_NO_DEBUG_TOOLS\n";
// Use the NORMALIZED (runtime) phys so anchors match the address
// space the entry hook + cyc_watch use — relocated kernel funcs
// (ROM 0x1FC10000+) run at RAM 0x500+, so the raw ROM addr would
// never match a RAM anchor.
out += fmt::format(" debug_server_cyc_observe(0x{:08X}u);\n",
normalize_address(addr));
out += "#endif\n";
// Phase 1.0e-d: advance guest cycles for this block. Macro-
// gated; when off, generated code matches pre-1.0e-d output.
uint32_t bcyc = 0;
@@ -1473,6 +1486,9 @@ EmitStats FullFunctionEmitter::emit(
full_c += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n";
full_c += "extern void debug_server_log_call_entry(uint32_t func_addr);\n";
full_c += "extern void debug_server_log_probe(uint32_t pc, CPUState *cpu);\n";
full_c += "#ifndef PSX_NO_DEBUG_TOOLS\n";
full_c += "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n";
full_c += "#endif\n";
full_c += "extern uint32_t g_debug_last_store_pc;\n";
full_c += "/* Phase 1.0e-d: per-block guest cycle accounting.\n";
full_c += " * Compile generated code with -DPSX_ENABLE_BLOCK_CYCLES=1 to\n";
+20 -20
View File
@@ -36,27 +36,27 @@ extern "C" {
* each component verified against Beetle via the cyc_watch DELTA gate
* (FAITHFUL_TIMING_PLAN.md §3c). Both backends consume this one function.
*
* STAGE 2 component #1 — data-memory access (the dominant ~2x gap):
* Beetle PS_CPU::ReadMemory: a normal CPU load adds `lts += 2` (LWC loads +1);
* stores are POSTED (WriteMemory adds no CPU stall); scratchpad reads add 0.
* So: load opcode = 1 (execute) + 2 (data access) = 3; LWC2 = 2; store/other = 1.
* NOT YET MODELED (later Δ-gated components, will adjust here / in the mem path):
* - scratchpad-vs-RAM region (scratchpad load should be 1, not 3),
* - the read-after-load "fudge" (+0/2) and load-delay ABSORB (next-insn discount),
* - I-cache instruction-fetch timing, mult/div latency, GTE per-command costs.
* This RAM-load base is an upper bound on the load component (no absorb yet); the
* Δ gate shows whether we under/overshoot and what to add next. */
* DATA-MEMORY ACCESS IS *NOT* CHARGED HERE — it lives in the memory path.
* The runtime already owns the load/store data-access cost address-keyed in
* runtime/src/memory.c (charge_main_ram_read → psx_guest_read_*: +6 cyc per
* main-RAM read wait-state, scratchpad/MMIO/ROM excluded), exactly like Beetle
* charges it in PS_CPU::ReadMemory (region wait + completion), NOT in the
* opcode dispatch. The opcode word alone cannot know the target region
* (RAM vs scratchpad vs MMIO), so a per-opcode load surcharge here is wrong by
* construction and DOUBLE-COUNTS what memory.c already charges.
*
* HISTORY: Stage-2 #1a (commit 2ef47bd) returned 3 for loads / 2 for LWC2
* ("1 execute + 2 data-access"). The BIOS-kernel cycle ruler (region
* [0x80001C5C→0x80001CA4]) proved this double-counts: native charged the 2
* loads +2 each HERE *and* +6 each in memory.c (8 over base), reconciling the
* opaque 0x80017FC4 window only because that over-charge masked the entirely-
* unmodeled divu→mflo stall. Reverted to pure execute base (this header's own
* stated contract). The real Stage-2 components are EXECUTE-pipeline latencies
* (mult/div stall, GTE per-command) added here, and CALIBRATING the memory-path
* wait-state (memory.c) per Beetle region — each Δ-gated on the ruler. */
static inline uint32_t psx_instr_base_cycles(uint32_t insn) {
uint32_t op = insn >> 26;
switch (op) {
case 0x20: case 0x21: case 0x22: case 0x23: /* LB LH LWL LW */
case 0x24: case 0x25: case 0x26: /* LBU LHU LWR */
return 3u; /* 1 execute + 2 data-access */
case 0x32: /* LWC2 (GTE load): +1 */
return 2u;
default:
return 1u;
}
(void)insn;
return 1u; /* pure CPU execute base; data access charged in memory.c */
}
#ifdef __cplusplus
+40 -4
View File
@@ -2077,22 +2077,40 @@ static uint64_t s_cyc_watch_region_start = 0;
* (debug_server_trace_dispatch), the universal function-entry hook
* (debug_server_log_call_entry), and the dirty-RAM path
* (debug_server_dirty_break_maybe_pause). `block_leader_phys` is physical. */
/* Dedupe state for the dispatch+prologue double-fire (see below). */
static uint32_t s_cyc_watch_last_phys = 0xFFFFFFFFu;
static uint64_t s_cyc_watch_last_cycle = 0xFFFFFFFFFFFFFFFFull;
static inline void cyc_watch_observe(uint32_t block_leader_phys)
{
if (!s_cyc_watch_armed) return; /* disarmed: no cost */
/* DEDUPE the double-fire: a block reached via the dispatcher is observed
* BOTH by debug_server_trace_dispatch (routing) AND by the function's own
* debug_server_log_call_entry (prologue) — same phys, same cycle, 0 cycles
* apart. That double-records every dispatched entry (a function reached by a
* direct jal fires only the prologue, so it looked like alternating pairs in
* the ring). A genuine re-entry of the same PC is always ≥1 cycle later, so
* keying on (phys,cycle) drops ONLY the spurious double, never a real hit. */
uint64_t cyc_now = psx_get_cycle_count();
if (block_leader_phys == s_cyc_watch_last_phys &&
cyc_now == s_cyc_watch_last_cycle) {
return;
}
s_cyc_watch_last_phys = block_leader_phys;
s_cyc_watch_last_cycle = cyc_now;
if (s_cyc_watch_end_phys != 0u) { /* ── REGION mode (A..B Δ) ── */
if (!s_cyc_watch_in_region) {
if (block_leader_phys == s_cyc_watch_anchor_phys) {
s_cyc_watch_region_start = psx_get_cycle_count();
s_cyc_watch_region_start = cyc_now;
s_cyc_watch_in_region = 1;
}
} else if (block_leader_phys == s_cyc_watch_end_phys) {
uint64_t now = psx_get_cycle_count();
CycWatchEntry *e = &s_cyc_watch_ring[s_cyc_watch_hits];
e->hit_index = s_cyc_watch_hits;
e->pc = block_leader_phys;
e->psx_cycle_count = now - s_cyc_watch_region_start; /* Δ(B-A) */
e->psx_cycle_count = cyc_now - s_cyc_watch_region_start; /* Δ(B-A) */
s_cyc_watch_hits++;
s_cyc_watch_in_region = 0;
if (s_cyc_watch_hits >= s_cyc_watch_max_hits) s_cyc_watch_armed = 0;
@@ -2108,11 +2126,27 @@ static inline void cyc_watch_observe(uint32_t block_leader_phys)
CycWatchEntry *e = &s_cyc_watch_ring[s_cyc_watch_hits];
e->hit_index = s_cyc_watch_hits;
e->pc = block_leader_phys;
e->psx_cycle_count = psx_get_cycle_count();
e->psx_cycle_count = cyc_now;
s_cyc_watch_hits++;
if (s_cyc_watch_hits >= s_cyc_watch_max_hits) s_cyc_watch_armed = 0;
}
/* Exported per-basic-block-leader cycle observer. Emitted by the recompiler at
* EVERY compiled block leader (under #ifndef PSX_NO_DEBUG_TOOLS, so prod builds
* emit nothing — zero overhead) so native's cycle observation matches Beetle's
* (which samples before every instruction). This lets cyc_watch anchor ANY
* block-leader PC — interior loop tops, prologue exits — not just function
* entries, which is required for a clean, KNOWN-instruction-sequence ruler.
* Disarmed cost = one volatile load + return (see cyc_watch_observe). */
void debug_server_cyc_observe(uint32_t block_leader_phys) {
#ifdef PSX_NO_DEBUG_TOOLS
(void)block_leader_phys;
return;
#else
cyc_watch_observe(block_leader_phys & 0x1FFFFFFFu);
#endif
}
void debug_server_trace_dispatch(uint32_t func_addr) {
#ifdef PSX_NO_DEBUG_TOOLS
(void)func_addr;
@@ -7266,6 +7300,8 @@ static void handle_cyc_watch(int id, const char *json)
s_cyc_watch_region_start= 0;
s_cyc_watch_max_hits = (uint32_t)n;
s_cyc_watch_hits = 0;
s_cyc_watch_last_phys = 0xFFFFFFFFu; /* reset dedupe state per arm */
s_cyc_watch_last_cycle = 0xFFFFFFFFFFFFFFFFull;
memset(s_cyc_watch_ring, 0, sizeof(s_cyc_watch_ring));
s_cyc_watch_armed = 1;