Files
psxrecomp/tools/cycle_testrom/gen_testrom.py
Matthew Stanley 9ae534dc42 timing: faithful device-region MMIO read waits (SPU +36, CDC +6*size, +1, ...)
Replace the placeholder region cost (RAM +3 / everything-else +0) in
psx_cyc_readmem with the full Beetle MemRW device-region read-wait table
(libretro.cpp), size-aware:

  main RAM (<0x800000)            +3
  SPU   0x1F801C00-1FFF          +36 (32-bit) / +16 (8/16-bit)
  CDC   0x1F801800-180F          +6 * size
  GPU/MDEC/SysCtrl/FIO/SIO/IRQ/DMA/Timers (0x1F801000-113F)  +1
  BIOS ROM / Expansion-PIO / unmatched / scratchpad           +0

Thread the access size (1/2/4) from psx_cyc_load_word/half/byte and
psx_cyc_lwc2_read into psx_cyc_readmem (SPU/CDC waits are width-dependent).
The device wait combines with the existing +2 completion (+1 LWC2) and fudge
exactly as Beetle ReadMemory (LDAbsorb = region + completion). Runtime-only:
psx_cyc_load_* signatures unchanged, no emitter regen.

New ruler #2 loops mmio_timer (Timer0 read -> +3) and mmio_spu (32-bit SPU
read -> +38) added to gen_testrom.py. Validated: cyctest compiled (4600) ==
Beetle (4382) EXACT on all 15 loops incl. mmio_timer +3 / mmio_spu +38; the 13
prior loops unchanged; Tomba 2 boots past the BIOS to its SCEA-Presents intro
splash, no freeze.

Documented residual: DMACycleSteal (dynamic per-read DMA bus-steal) remains
unmodeled — needs the live steal count from the DMA controller; not statically
rulerable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:17:07 -07:00

274 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, "$t8": 24, "$t9": 25, "$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 cop2cmd(cmd): return (0x12 << 26) | (1 << 25) | (cmd & 0x1FFFFFF) # GTE command
def mfc2(rt, rd): return (0x12 << 26) | (0x00 << 21) | (_r(rt) << 16) | ((rd & 0x1F) << 11) # MFC2 rt,gte[rd]
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("$t8", 0x1F800000)) # MMIO base (scratchpad base; +0x1100 timer, +0x1C00 SPU)
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")])
# load_use: lw then an addu that DEPs the loaded reg ($t4) — the dependent
# use ends the LDAbsorb give-back (GPR_DEP), so the following instrs do NOT
# absorb it. Isolates the give-back-TERMINATION path (load/load2 load into
# dead regs and never exercise it). Δ vs Beetle pins psx_cyc_deps.
inner_loop("load_use", [lw("$t4", 0, "$t5"), addu("$t6", "$t4", "$t3")])
# 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")])
# gte_rtps: GTE RTPS command (0x01) + mfc2 result read. Isolates the GTE
# per-command completion-stall: the COP2 read stalls to the command deadline.
# Cost is operand-independent (gte.cpp RTPS returns 15), so delta == 15.
inner_loop("gte_rtps", [cop2cmd(0x01), mfc2("$t4", 14)])
# gte_nclip: NCLIP command (0x06, cost 8) + mfc2 MAC0 (reg 24) read.
inner_loop("gte_nclip", [cop2cmd(0x06), mfc2("$t4", 24)])
# gte_read_use: RTPS + mfc2 result read + an addu that DEPs the mfc2 dest
# ($t4). PROBE for the GTE-read LDAbsorb give-back (Beetle MFC2 sets
# LDAbsorb=gte_ts_done-ts). If our stall-only model diverges from Beetle this
# loop reveals it; if native==Beetle there is no give-back divergence to fix.
inner_loop("gte_read_use", [cop2cmd(0x01), mfc2("$t4", 14), addu("$t6", "$t4", "$t3")])
# ld_div: lw arms a load give-back, then divu+mflo. PROBE for the muldiv-stall
# give-back consumption (Beetle decrements ReadAbsorb while stalling MFLO to
# muldiv_ts_done). Divergence here would mean the muldiv stall must consume the
# pending load give-back; native==Beetle means no divergence to fix.
inner_loop("ld_div", [lw("$t4", 0, "$t5"), divu("$t2", "$t3"), mflo("$t7")])
# mmio_timer: one lw from Timer0 (0x1F801100). Isolates a device-region MMIO read
# wait: Beetle MemRW charges +1 for the timer/counter region, then ReadMemory adds
# the +2 completion -> component +3 (vs main-RAM load's region 3 -> +5). Timer reads
# are side-effect-free + value-independent, so the per-iter delta is deterministic.
inner_loop("mmio_timer", [lw("$t4", 0x1100, "$t8")])
# mmio_spu: one 32-bit lw from the SPU region (0x1F801C00, voice 0). The dramatic
# case: Beetle MemRW charges +36 for a 32-bit SPU read, + the +2 completion ->
# component +38. Pins the size-dependent SPU wait (the largest device wait modeled).
inner_loop("mmio_spu", [lw("$t4", 0x1C00, "$t8")])
# icache_miss: force an I-cache miss every iteration via 4KB aliasing. The loop
# top and a "victim" block 0x1000 bytes away map to the SAME direct-mapped line
# (4 KB / 256-line cache, index = addr bits 4-11) with DIFFERENT tags, so each
# fetch evicts the other -> a refill miss every iteration. Isolates the I-cache
# refill cost; every other loop is small enough to be all-hits after warm-up.
a.emit(li("$t0", ITER))
ic_top = a.here()
anchors["icache_miss"] = ic_top
a.label("icache_miss_top")
a.emit(addiu("$t0", "$t0", -1)) # ic_top + 0
victim_addr = ic_top + 0x1000 # aliases ic_top's line (only bit 12 differs)
a.emit(j(victim_addr >> 2)) # ic_top + 4: jump to the aliased victim
a.emit(nop()) # ic_top + 8: delay slot
while a.here() < victim_addr: # pad (not executed; the j skips it)
a.emit(nop())
# victim @ ic_top+0x1000: loop back to ic_top (or fall through on exit)
voff = (a.labels["icache_miss_top"] - (a.here() + 4)) >> 2
a.emit(bne("$t0", "$zero", voff)) # victim + 0
a.emit(nop()) # victim + 4: delay slot
# 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()