feat(timing): ruler #2 foundation — cycle micro-benchmark test ROM + universal block observe

Ruler #2 (the in-tree cycle-model integration-test ecosystem):
- tools/cycle_testrom/gen_testrom.py: hand-encoded MIPS PS-X EXE of cycle-
  isolation loops (baseline/alu/load/load2/div/div_spaced/mult). Each loop top is
  a block leader, so a cyc_watch single-anchor Δ = one iteration; baseline
  subtraction isolates each component (overhead + fetch cancel). Encoding
  validated by a clean recompile (divu -> gpr[10]/gpr[11], etc.).
- game.toml/seeds.txt/README.md; .gitignore for the regenerable EXE/json/generated.

Universal per-block-leader observe:
- Added debug_server_cyc_observe at every block leader in the GAME emitter
  (code_generator.cpp) to match the BIOS emitter, so ANY recompiled code (game,
  BIOS, overlay) is anchorable on both backends — not just function entries.
  #ifndef PSX_NO_DEBUG_TOOLS so prod = zero overhead.

Regression-checked: regen Tomba 2 + rebuild, FMV still streams (i_stat 0x8D), no
visual regression. Builds clean.

Remaining for ruler #2: native test-runtime delivery (boot->entry handoff) +
Beetle .exe sideload + the per-component compare harness (see README TODO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-26 22:30:14 -07:00
parent 9cec60a30d
commit 2b5ad88dc6
6 changed files with 328 additions and 0 deletions
+13
View File
@@ -1179,6 +1179,16 @@ std::string CodeGenerator::translate_basic_block(
// Block label
ss << fmt::format("block_{:08X}:\n", block.start_addr);
// Per-block-leader cycle observe (cyc_watch ruler — universal with the BIOS
// emitter). Sampled at the block leader BEFORE any cycle is charged, matching
// Beetle's before-instruction sample and the cycle_compare.py anchor
// semantics, so ANY block-leader PC is anchorable on both backends (not just
// function entries). Debug-only: prod (PSX_NO_DEBUG_TOOLS) emits nothing.
ss << "#ifndef PSX_NO_DEBUG_TOOLS\n";
ss << config_.indent
<< fmt::format("debug_server_cyc_observe(0x{:08X}u);\n", block.start_addr);
ss << "#endif\n";
// Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). At the block
// leader — before any cycle is charged or the body runs — divert to the
// per-instruction interpreter when an interrupt could be taken inside this
@@ -2322,6 +2332,9 @@ std::string CodeGenerator::generate_file(
// This provides CPUState, GTE/trap declarations, and call_by_address().
ss << "#include \"psx_runtime.h\"\n\n";
ss << "extern void debug_server_log_call_entry(uint32_t func_addr);\n";
ss << "#ifndef PSX_NO_DEBUG_TOOLS\n";
ss << "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n";
ss << "#endif\n";
ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n";
ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n";
ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n";
+4
View File
@@ -0,0 +1,4 @@
# Regenerable build artifacts (produced by gen_testrom.py / psxrecomp-game).
cycle_testrom.exe
*.anchors.json
generated/
+70
View File
@@ -0,0 +1,70 @@
# Cycle-isolation micro-benchmark test ROM (ruler #2)
A purpose-built, in-tree, version-controlled cycle test program — the
**integration-test ecosystem** for the faithful R3000A cost model
(FAITHFUL_TIMING_PLAN.md §3c, ACCURACY_BURNDOWN.md axis 2). Complements ruler #1
(the BIOS-kernel function ruler) by isolating ONE cost component per loop, which
organic BIOS/game code can't (it mixes components in a single basic block).
This is game-independent: it validates the shared `psx_instr_base_cycles` +
memory-path model that EVERY recompiled PSX title uses, not just the current
bring-up vehicle.
## What it is
`gen_testrom.py` emits `cycle_testrom.exe`, a tiny PS-X EXE of counted loops.
Each loop's top is a basic-block leader (branch target), so a cyc_watch
single-anchor consecutive-hit Δ = exactly one iteration's cycle cost — the same
measurement on both backends.
Loops (each adds ONLY its op(s) over the shared loop overhead):
- `baseline` — loop overhead only (addiu; bne; nop)
- `alu` — + one addu (pure execute)
- `load` — + one lw from main RAM (memory read wait-state)
- `load2` — + two lw (linearity check)
- `div` — + divu, mflo (div latency + stall-on-read, worst case)
- `div_spaced` — + divu, 2 filler addu, mflo (stall partly absorbed)
- `mult` — + multu, mflo (mult latency)
**Isolation by baseline subtraction:** (component_per_iter − baseline_per_iter)
= the component's cost; loop overhead AND instruction fetch (both fully
cache-resident after warm-up) cancel.
## Two-backend measurement
Same EXE, both backends, compare per-iteration Δ:
- **native** (psx-runtime cost model): recompiled by `psxrecomp-game`; the game
emitter emits `debug_server_cyc_observe` at every block leader (debug builds),
so each anchor is observable. (Native delivery harness: see "Native side" TODO.)
- **Beetle** (HW oracle): mednafen sideloads the raw PS-EXE (it auto-detects the
"PS-X EXE" header). cyc_watch the same anchors.
Each component is then transcribed/calibrated into `psx_instr_base_cycles`
(execute latencies: mult/div, GTE) or the memory-path wait-state (`memory.c`),
Δ-gated against Beetle on these loops + FMV-no-regression, one at a time.
## Regenerate
```bash
cd tools/cycle_testrom
python gen_testrom.py cycle_testrom.exe # writes the EXE + .anchors.json
# recompile for native:
../../recompiler/build-t2/psxrecomp-game.exe --config game.toml
```
Tracked: `gen_testrom.py`, `game.toml`, `seeds.txt`, this README.
Ignored (regenerable): `cycle_testrom.exe`, `*.anchors.json`, `generated/`.
## Status / TODO
- [x] Generator + hand-encoded MIPS isolation loops; encoding validated by a
clean recompile (divu→`gpr[10]/gpr[11]`, etc.).
- [x] Universal per-block-leader `debug_server_cyc_observe` in BOTH emitters
(BIOS `full_function_emitter.cpp` + game `code_generator.cpp`).
- [ ] Native delivery: a test-runtime target (link the generated C) that reaches
`entry_pc` and free-runs the loops while the debug server serves cyc_watch.
The boot→entry handoff (boot_state.c snapshot is disc-keyed) is the one
piece to solve — options: a minimal boot disc, or a direct-entry harness.
- [ ] Beetle delivery: extend `beetle_main.cpp` to sideload a `.exe` content path.
- [ ] `compare.py`: arm each anchor on both backends, report per-component Δ vs
the analytic expectation.
+20
View File
@@ -0,0 +1,20 @@
# Cycle-isolation micro-benchmark test ROM — ruler #2 (in-tree).
# Paths are relative to the psxrecomp worktree root (find_project_root walks up
# to it). Generated EXE: gen_testrom.py. Recompiled by psxrecomp-game for the
# native cost-model side; sideloaded raw into Beetle for the HW-oracle side.
[game]
name = "CycleTestROM"
id = "CYCTEST-001"
exe = "tools/cycle_testrom/cycle_testrom.exe"
load_address = "0x80010000"
entry_pc = "0x80010000"
text_size = "0x00000800"
[recompiler]
seeds = "tools/cycle_testrom/seeds.txt"
out_dir = "tools/cycle_testrom/generated"
strict = true
[runtime]
debug_port = 4600
window_title = "Cycle Test ROM"
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
gen_testrom.py — emit a PS-X EXE of cycle-isolation micro-benchmark loops.
This is ruler #2 (FAITHFUL_TIMING_PLAN.md §3c / ACCURACY_BURNDOWN.md axis 2): a
purpose-built, in-tree, version-controlled cycle test program. Unlike organic
BIOS/game code (which mixes cost components in one basic block), each loop here
exercises EXACTLY ONE cost component, so its per-iteration cycle delta isolates
that component. We know the analytic answer by hand, and we run the SAME EXE on
both backends:
- native: recompiled by psxrecomp-game, invoked + cyc_watch'd (compiled model)
- Beetle: sideloaded raw PS-EXE (mednafen EXE loader), cyc_watch'd (HW oracle)
and Δ-compare per-iteration. It is a permanent regression harness for the cost
model, good for ANY future PSX title, not just the current vehicle.
ISOLATION METHOD (baseline subtraction): every loop shares the SAME loop overhead
(addiu counter; bne; nop). A `baseline` loop measures that overhead alone; each
component loop adds ONLY its op(s). (component_per_iter − baseline_per_iter) = the
component's cycle cost, with loop overhead AND fetch (both fully cache-resident
after warm-up) cancelled out.
ANCHOR semantics: each loop's TOP is a basic-block leader (it is a branch target),
so cyc_watch single-anchor consecutive-hit Δ = one iteration's cost — the same
measurement on both backends.
The MIPS is hand-encoded (tiny built-in assembler for the subset used) so there is
NO external toolchain dependency; the output is deterministic and reproducible.
"""
import struct
import sys
# --- minimal MIPS32 (R3000, little-endian) encoder for the subset we use -------
REG = {f"${i}": i for i in range(32)}
REG.update({ # ABI names we use
"$zero": 0, "$at": 1, "$v0": 2, "$v1": 3,
"$a0": 4, "$a1": 5, "$a2": 6, "$a3": 7,
"$t0": 8, "$t1": 9, "$t2": 10, "$t3": 11,
"$t4": 12, "$t5": 13, "$t6": 14, "$t7": 15,
"$s0": 16, "$s1": 17, "$ra": 31, "$sp": 29, "$gp": 28,
})
def _r(x):
if isinstance(x, int): return x
return REG[x]
def R(funct, rs=0, rt=0, rd=0, sa=0):
return (0 << 26) | (_r(rs) << 21) | (_r(rt) << 16) | (_r(rd) << 11) | (sa << 6) | funct
def I(op, rs=0, rt=0, imm=0):
return (op << 26) | (_r(rs) << 21) | (_r(rt) << 16) | (imm & 0xFFFF)
# instruction helpers
def nop(): return 0
def addu(rd, rs, rt): return R(0x21, rs, rt, rd)
def addiu(rt, rs, imm): return I(0x09, rs, rt, imm)
def ori(rt, rs, imm): return I(0x0D, rs, rt, imm)
def lui(rt, imm): return I(0x0F, 0, rt, imm)
def sll(rd, rt, sa): return R(0x00, 0, rt, rd, sa)
def divu(rs, rt): return R(0x1B, rs, rt)
def multu(rs, rt): return R(0x19, rs, rt)
def mflo(rd): return R(0x12, 0, 0, rd)
def mfhi(rd): return R(0x10, 0, 0, rd)
def lw(rt, off, base): return I(0x23, base, rt, off)
def sw(rt, off, base): return I(0x2B, base, rt, off)
def bne(rs, rt, off): return I(0x05, rs, rt, off) # off in words, signed
def beq(rs, rt, off): return I(0x04, rs, rt, off)
def jr(rs): return R(0x08, rs)
def j(target_word): return (0x02 << 26) | (target_word & 0x03FFFFFF)
def jal(target_word): return (0x03 << 26) | (target_word & 0x03FFFFFF)
def li(rt, val):
"""load-immediate macro → (lui, ori) or a single addiu for small values."""
val &= 0xFFFFFFFF
if val < 0x10000:
return [ori(rt, 0, val)]
if (val & 0xFFFF) == 0:
return [lui(rt, val >> 16)]
return [lui(rt, val >> 16), ori(rt, rt, val & 0xFFFF)]
# --- EXE layout ----------------------------------------------------------------
LOAD_ADDR = 0x80010000
ENTRY = LOAD_ADDR # entry == first instruction
STACK_TOP = 0x801FFF00
SCRATCH_RAM = 0x80012000 # a valid main-RAM address we lw/sw against
ITER = 20000 # inner-loop trip count (large → many anchor hits)
# Each test loop is laid out as a function. We record the loop-top address
# (the anchor) for the harness/README to consume. The outer entry runs the
# tests round-robin forever so anchors keep getting hit.
class Asm:
def __init__(self, base):
self.base = base
self.words = []
self.labels = {}
def here(self):
return self.base + 4 * len(self.words)
def label(self, name):
self.labels[name] = self.here()
def emit(self, *ws):
for w in ws:
if isinstance(w, list):
self.words.extend(w)
else:
self.words.append(w)
def word_addr(self, addr):
return (addr & 0x0FFFFFFF) >> 2
def build():
a = Asm(LOAD_ADDR)
anchors = {}
# ---- entry: set up sp, operands, then round-robin call the tests forever ----
a.label("entry")
a.emit(li("$sp", STACK_TOP))
a.emit(li("$t5", SCRATCH_RAM)) # load/store base
a.emit(li("$t2", 0x12345678)) # div/mult operand (dividend/multiplicand)
a.emit(li("$t3", 0x0000007F)) # div/mult operand (divisor/multiplier, !=0)
# store something at the scratch addr so loads read a known value
a.emit(sw("$t2", 0, "$t5"))
a.label("outer")
# call each test (jal needs absolute word target; patch after layout). We
# use placeholders and fix up below by re-emitting once addresses are known.
# Simpler: emit tests INLINE in the outer loop body (no jal), each as its own
# counted inner loop. The inner-loop tops are the anchors. After all tests,
# branch back to outer. This keeps it call-free (no $ra juggling).
def inner_loop(name, body_ops):
"""Emit: li counter; TOP: <body>; addiu counter,-1; bne TOP; nop."""
a.emit(li("$t0", ITER))
top = a.here()
anchors[name] = top
a.label(name + "_top")
a.emit(*body_ops)
a.emit(addiu("$t0", "$t0", -1))
# bne offset: from delay-slot+4 to top. off_words = (top - (here+4))/4
br_here = a.here()
off = (top - (br_here + 4)) >> 2
a.emit(bne("$t0", "$zero", off))
a.emit(nop())
# baseline: loop overhead only
inner_loop("baseline", [])
# alu: one addu (pure execute, should be +1 over baseline)
inner_loop("alu", [addu("$t6", "$t2", "$t3")])
# load: one lw from main RAM (isolates memory read wait-state)
inner_loop("load", [lw("$t4", 0, "$t5")])
# load2: two lw (linearity check: should be ~2x the load delta)
inner_loop("load2", [lw("$t4", 0, "$t5"), lw("$t7", 0, "$t5")])
# div: divu + mflo (isolates div latency + stall-on-read, worst case)
inner_loop("div", [divu("$t2", "$t3"), mflo("$t4")])
# div_spaced: divu, 2 filler addu, then mflo (stall partly absorbed)
inner_loop("div_spaced", [divu("$t2", "$t3"),
addu("$t6", "$t6", "$t3"),
addu("$t6", "$t6", "$t3"),
mflo("$t4")])
# mult: multu + mflo (isolates mult latency)
inner_loop("mult", [multu("$t2", "$t3"), mflo("$t4")])
# loop outer forever
o = a.labels["outer"]
here = a.here()
off = (o - (here + 4)) >> 2
a.emit(beq("$zero", "$zero", off))
a.emit(nop())
return a, anchors
def make_psexe(words):
text = b"".join(struct.pack("<I", w) for w in words)
text_size = len(text)
# PS-EXE: pad text size to 0x800 multiple
if text_size % 0x800:
text += b"\x00" * (0x800 - (text_size % 0x800))
text_size_field = len(text)
hdr = bytearray(0x800)
hdr[0x00:0x08] = b"PS-X EXE"
# 0x10 init PC, 0x14 init GP, 0x18 text load addr, 0x1C text size
struct.pack_into("<I", hdr, 0x10, ENTRY)
struct.pack_into("<I", hdr, 0x14, 0) # gp
struct.pack_into("<I", hdr, 0x18, LOAD_ADDR)
struct.pack_into("<I", hdr, 0x1C, text_size_field)
# 0x30 stack base, 0x34 stack size (BIOS sets sp; we also set it in code)
struct.pack_into("<I", hdr, 0x30, STACK_TOP)
struct.pack_into("<I", hdr, 0x34, 0)
# marker region region text (0x4C..) often holds an ASCII license string; leave blank
return bytes(hdr) + text
def main():
out = sys.argv[1] if len(sys.argv) > 1 else "cycle_testrom.exe"
a, anchors = build()
blob = make_psexe(a.words)
with open(out, "wb") as f:
f.write(blob)
print(f"wrote {out} ({len(blob)} bytes, {len(a.words)} instrs, "
f"text@0x{LOAD_ADDR:08X}, entry@0x{ENTRY:08X})")
print("anchors (loop-top block leaders) — arm cyc_watch single-anchor on these:")
for name, addr in anchors.items():
print(f" {name:12s} 0x{addr:08X}")
# emit an anchors json for the harness
import json
meta = {
"load_address": f"0x{LOAD_ADDR:08X}",
"entry_pc": f"0x{ENTRY:08X}",
"text_size": f"0x{(len(blob)-0x800):08X}",
"iter": ITER,
"scratch_ram": f"0x{SCRATCH_RAM:08X}",
"anchors": {k: f"0x{v:08X}" for k, v in anchors.items()},
}
with open(out + ".anchors.json", "w") as f:
json.dump(meta, f, indent=2)
print(f"wrote {out}.anchors.json")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
0x80010000