Make the repo approachable for outside contributors and an IDE integrator:
- New contributor docs:
- docs/EXECUTION_MODEL.md — semi-technical tour of the static / native-overlay
/ interpreter execution model and how coverage gaps get plugged.
- docs/ARCHITECTURE.md — component-level view (recompiler, runtime, LLE+HLE
BIOS, overlay backend static->gcc->tcc, renderers, oracle model).
- docs/BUILDING.md — accurate dependency table, per-platform prereqs, build
steps, and the submodule + local-junction framework-linking setup.
- CONTRIBUTING.md — distilled core rules, verification expectations, PR flow.
- Declutter root: move ~14 internal dev-logs into docs/internal/ (history
preserved via rename) with an index README; repoint CLAUDE.md references.
- README: correct build recipe, LLE+HLE + overlay-backend summary, links to the
new docs.
- code_provider.c: update the stale backend comment to the real static->gcc->tcc
resolution (dead SLJIT.md reference removed).
- config_schema.md: drop the obsolete "once Phase A2/A3 ships --config" note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.5 KiB
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):
- Scratchpad
0x1F800000..0x1F8003FF(cpu.cpp:414-422): early return,LDAbsorb=0, +0. Scratchpad is the D-cache — fast, no stall. - Otherwise:
timestamp += (ReadFudge>>4)&2(cpu.cpp:424) — a +0 or +2 read-after-load "fudge" (penalty when the previous op set ReadFudge). - Region wait-state:
PSX_MemRead*(lts, address)advancesltsby 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). lts += (LWC_timing ? 1 : 2)(cpu.cpp:442-445) — the load completion cost (+2 normal CPU load, +1 LWC2/GTE load).LDAbsorb = lts - timestamp(cpu.cpp:447) — the whole data-access cost becomes the load-delay absorb: it is handed toReadAbsorb[rt]in DO_LDS (cpu.cpp:800), so the next instruction(s) that readrtconsume 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_cyclescharges 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_Tab24lookup 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.