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>
This commit is contained in:
Matthew Stanley
2026-06-27 18:17:07 -07:00
parent 0edb9355bb
commit 9ae534dc42
3 changed files with 78 additions and 16 deletions
+20
View File
@@ -213,6 +213,26 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-27 (device-region MMIO read waits — DONE, branch wt/tomba2-mmio-waits off the
I-cache tip):** Replaced the placeholder `region = (phys<RAM_SIZE)?3:0` in psx_cyc_readmem
(memory.c) with the full Beetle MemRW device-region read-wait table (libretro.cpp:859-1131),
size-aware: main RAM (phys<0x800000) +3; SPU 0x1F801C00-1FFF +36 (32-bit) / +16 (8/16-bit);
CDC 0x1F801800-180F +6×size; GPU/MDEC/SysControl/FrontIO/SIO/IRQ/DMA/Timers (within
0x1F801000-113F) +1; BIOS ROM / Expansion-PIO / unmatched +0; scratchpad +0 (early-out).
Threaded the access size (1/2/4) from psx_cyc_load_word/half/byte + psx_cyc_lwc2_read into
psx_cyc_readmem (the 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, so NO emitter regen; just
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.
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
bus-steal count to EVERY read (libretro.cpp:868); non-zero only during active DMA, needs the
steal count threaded out of the DMA controller, can't be isolated by a static ruler.
memory.c + gen_testrom.py.
- **2026-06-27 (I-cache fetch — Stage 2 DONE: compiled-path emit + production default-on):**
Both static emitters now charge the I-cache fetch cost at each cache-line LEADER, BEFORE
the per-instruction interlock/load (Beetle ReadInstruction order, so a fetch miss clears
+47 -15
View File
@@ -883,15 +883,46 @@ uint8_t psx_read_byte(uint32_t addr) {
* data-access cost is charged exactly once, here). Keyed on the runtime effective
* physical address (KUSEG/KSEG0/KSEG1 alias the same DRAM).
*
* Region residual: only main-RAM (+3) and scratchpad (+0) region waits are modeled;
* MMIO/BIOS-ROM device read waits (e.g. SPU +36) remain a separate unmodeled axis —
* those loads still get the +2 completion (faithful) but region 0. */
* DMACycleSteal residual: Beetle adds the (dynamic) DMACycleSteal to EVERY read
* (libretro.cpp:868-869). That is non-zero only while a DMA channel is actively
* stealing the bus; modeling it needs the live steal count threaded out of the DMA
* controller, and it can't be isolated by a static ruler. It remains an unmodeled
* dynamic axis; the per-region device waits below are the static, validatable piece. */
extern void psx_advance_cycles(uint32_t cycles);
/* Beetle MemRW device-region READ wait (libretro.cpp:859-1131), the device-dependent
* part of a load's access cost (added to the timestamp before the +completion). `size`
* is the access width in bytes (1/2/4) — the SPU and CDC waits are width-dependent.
* Reads only; writes are posted (~free) in MemRW. phys is the masked physical address. */
static inline uint32_t psx_mmio_read_wait(uint32_t phys, uint32_t size) {
/* Main RAM, mirrored across phys 0..0x7FFFFF (libretro.cpp:874 `A < 0x00800000`). */
if (phys < 0x00800000u) return 3u;
/* BIOS ROM (905) and Expansion 1 / PIO (1134): no extra device wait. */
if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) return 0u;
if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0u;
/* Hardware MMIO window 0x1F801000..0x1F802FFF (921). */
if (phys >= 0x1F801000u && phys <= 0x1F802FFFu) {
if (phys >= 0x1F801C00u && phys <= 0x1F801FFFu) /* SPU (929) */
return (size == 4u) ? 36u : 16u;
if (phys >= 0x1F801800u && phys <= 0x1F80180Fu) /* CDC (979) */
return 6u * size;
if (phys >= 0x1F801810u && phys <= 0x1F801817u) return 1u; /* GPU (994) */
if (phys >= 0x1F801820u && phys <= 0x1F801827u) return 1u; /* MDEC (1007) */
if (phys >= 0x1F801000u && phys <= 0x1F801023u) return 1u; /* SysControl (1020) */
if (phys >= 0x1F801040u && phys <= 0x1F80104Fu) return 1u; /* FrontIO/pad (1043) */
if (phys >= 0x1F801050u && phys <= 0x1F80105Fu) return 1u; /* SIO (1055) */
if (phys >= 0x1F801070u && phys <= 0x1F801077u) return 1u; /* IRQ (1094) */
if (phys >= 0x1F801080u && phys <= 0x1F8010FFu) return 1u; /* DMA (1106) */
if (phys >= 0x1F801100u && phys <= 0x1F80113Fu) return 1u; /* Timers (1119) */
return 0u; /* unmatched MMIO: Beetle adds no device wait */
}
return 0u; /* unknown / open-bus region */
}
/* Beetle ReadMemory data-access timing (cpu.cpp:369-448), after §1/deps/DO_LDS.
* compl_cost = 2 (CPU load) / 1 (LWC2); arm_rt = GPR to arm as pending load, or
* 0x20 = none (LWC2, dest is a GTE reg). */
static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys,
* 0x20 = none (LWC2, dest is a GTE reg). size = access width in bytes (1/2/4). */
static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys, uint32_t size,
uint32_t compl_cost, uint32_t arm_rt) {
/* ReadMemory start (369-370): clear the current give-back slot. */
cpu->read_absorb[cpu->read_absorb_which] = 0u;
@@ -904,8 +935,8 @@ static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys,
}
/* fudge (424): +2 iff the predecessor committed no load (read_fudge==0x20). */
psx_advance_cycles((uint32_t)((cpu->read_fudge >> 4) & 2u));
uint32_t region = (phys < RAM_SIZE) ? 3u : 0u; /* main RAM +3; others: separate axis */
uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */
uint32_t region = psx_mmio_read_wait(phys, size); /* device-region wait */
uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */
cpu->ld_absorb = cost;
psx_advance_cycles(cost);
cpu->ld_which_t = (uint8_t)arm_rt;
@@ -913,37 +944,38 @@ static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys,
/* The interlock half of a load (§1+deps+(cancel)+DO_LDS+ReadMemory). Gated on
* PSX_ENABLE_BLOCK_CYCLES so the Beetle-oracle build (cycles off) does a plain read. */
static inline void psx_cyc_load_timing(CPUState* cpu, uint32_t addr,
static inline void psx_cyc_load_timing(CPUState* cpu, uint32_t addr, uint32_t size,
uint32_t rt, uint32_t reg_mask) {
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_cyc_base(cpu);
psx_cyc_deps(cpu, reg_mask);
if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; /* cancel pending load to same dest */
psx_cyc_lds(cpu);
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 2u, rt);
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, size, 2u, rt);
#else
(void)cpu; (void)addr; (void)rt; (void)reg_mask;
(void)cpu; (void)addr; (void)size; (void)rt; (void)reg_mask;
#endif
}
uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask);
return psx_read_word(addr);
}
uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
psx_cyc_load_timing(cpu, addr, 2u, rt, reg_mask);
return psx_read_half(addr);
}
uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
psx_cyc_load_timing(cpu, addr, 1u, rt, reg_mask);
return psx_read_byte(addr);
}
/* LWC2 (GTE load): §1/DO_LDS done by psx_cyc_step(cpu,0); the GTE deadline stall by
* psx_gte_stall — both emitted before this call. Completion +1, no LDWhich arm. */
* psx_gte_stall — both emitted before this call. 32-bit access, completion +1, no
* LDWhich arm. */
uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr) {
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 1u, 0x20u);
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 4u, 1u, 0x20u);
#else
(void)cpu;
#endif
+11 -1
View File
@@ -38,7 +38,7 @@ REG.update({ # ABI names we use
"$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,
"$s0": 16, "$s1": 17, "$t8": 24, "$t9": 25, "$ra": 31, "$sp": 29, "$gp": 28,
})
def _r(x):
@@ -119,6 +119,7 @@ def build():
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
@@ -183,6 +184,15 @@ def build():
# 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