mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
91cd6db705
Review of #561. Guest time now has two entry points (psx_cycles.h): the CPU charge psx_cpu_charge() (instruction base, load fudge/region/completion, i-cache refills, BIOS HLE costs), which applies [timing] guest_cycle_scale, and the unscaled device advance psx_advance_cycles() (DMA, idle skips, mul/div and GTE deadline waits). The emitters' block-mode charges emit psx_cpu_charge; overlay DLLs accumulate it raw and publish through the cpu_charge callback (was advance_cycles, same slot) into the same host function, so nothing is scaled at the DLL boundary. The batched psx_cyc_charge path, the dirty-RAM interpreter, psx_cyc_readmem, i-cache refills and bios_hle.c all charge through it. At scale 1 it is exactly psx_advance_cycles. Gate in psxrecomp: [timing] guest_cycle_scale_gate, RAM predicates judged at every VBlank edge (deterministic, works online, no mod needed), plus psx_mod_set_guest_cycle_scale_gate() in mod_plugins.h for custom logic or to tie the scale to a mod (guest_cycle_scale_gated = true; every mod session reset shuts it). The carried fraction and both gate states are a boot_state section (BS_SEC_GCS, written only when the scale is not 1), so savestates and the netplay rollback snapshot re-simulate the same cycles. psx_guest_cycle_scale_gate_test pins tier identity, the gates and rollback re-simulation against the real psx_cycles.c. OpenBIOS regenerated (emitter stamp); retail SCPH1001 is not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
19 KiB
Python
435 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Compiled BIOS code charges instruction fetch exactly as the interpreter does.
|
|
|
|
Beetle's ReadInstruction, which runtime/src/psx_icache.c transcribes, never
|
|
fills an I-cache line for a fetch at 0xA0000000 or above. Every such fetch
|
|
costs +4 and clears the pending load give-back. The dirty-RAM interpreter
|
|
reaches that charge by calling psx_icache_fetch_interp at every PC. The BIOS
|
|
main ROM runs in place at KSEG1 (0xBFC0....), so compiled ROM code must charge
|
|
a fetch before EVERY instruction. The emitters used to charge only at block
|
|
leaders and 16-byte line starts, which is exact only for cached code. That
|
|
left 5,477 of OpenBIOS's 9,592 KSEG1 instruction sites 4 cycles short, each
|
|
also missing its give-back clear.
|
|
|
|
The test compiles runtime/src/psx_icache.c into a small harness and drives it
|
|
two ways. "Compiled" runs only the fetches the emitter wrote
|
|
(psx_icache_fetch). "Interpreter" runs a fetch at every PC
|
|
(psx_icache_fetch_interp, the dirty-RAM interpreter's own entry point). Each
|
|
instruction's (cycles, give-back cleared) pair must be the same both ways:
|
|
|
|
model psx_icache.c (both entry points) equals a transcription of Beetle
|
|
ReadInstruction on synthetic sequences: uncached repeat, cached cold
|
|
then warm, KSEG0 -> KUSEG alias refill, partial refill.
|
|
bios psxrecomp-bios on bios/OpenBIOS.toml. Every straight-line run from
|
|
a label is replayed from a cold cache: KSEG1 runs (ROM in place)
|
|
and cached runs (kernel at 0x500+, shell at 0x80030000+) alike.
|
|
Every KSEG1 instruction must carry a fetch naming its own runtime
|
|
PC. Cached runs pin the line-start test to the runtime PC:
|
|
OpenBIOS copies its kernel from ROM 0x1FC1E4D4 to RAM 0x500, so a
|
|
ROM-address test charged each kernel line crossing one
|
|
instruction early (a hit) and missed the real one.
|
|
stubs The A0/B0/C0 call-vector native stubs charge one fetch per
|
|
executed word, so a KSEG1 call pays like the interpreter.
|
|
|
|
The game/overlay emitter's half is uncached_fetch_codegen_test.cpp.
|
|
|
|
Usage: test_uncached_fetch_charge.py --bios-recompiler <psxrecomp-bios> [--compiler cc]
|
|
Exit 0 = every compared instruction matches.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
|
|
RUNTIME = os.path.join(ROOT, "runtime")
|
|
|
|
KUSEG, KSEG0, KSEG1 = 0x00000000, 0x80000000, 0xA0000000
|
|
|
|
|
|
def uncached(pc):
|
|
"""psx_fetch_uncached (runtime/include/psx_instr_cost.h)."""
|
|
return pc >= 0xA0000000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Beetle fetch model, transcribed from libretro/beetle-psx-libretro
|
|
# mednafen/psx/cpu.c @ a7f0811: ReadInstruction (738-835) with the I-cache on
|
|
# (BIU bit 11 set, as the BIOS leaves it). The tag compares the full virtual
|
|
# address. A miss clears the load give-back. KSEG1 then costs +4 and fills
|
|
# nothing. A cached miss costs 3, plus 1 for each word refilled from the
|
|
# missed word to the end of the line. (Same transcription as
|
|
# test_segment_aware_codegen.py in PR #419.)
|
|
# ---------------------------------------------------------------------------
|
|
class BeetleICache:
|
|
def __init__(self):
|
|
self.tv = [0x2] * 1024
|
|
|
|
def fetch(self, addr):
|
|
"""(cycles, give-back cleared) for one fetch."""
|
|
if self.tv[(addr & 0xFFC) >> 2] == addr:
|
|
return 0, 0
|
|
if addr >= 0xA0000000:
|
|
return 4, 1
|
|
line, base = addr & 0xFFFFFFF0, (addr & 0xFF0) >> 2
|
|
for i in range(4):
|
|
self.tv[base + i] = line | (i << 2) | 0x2
|
|
cost = 3
|
|
for i in range((addr & 0xC) >> 2, 4):
|
|
self.tv[base + i] &= ~0x2
|
|
cost += 1
|
|
return cost, 1
|
|
|
|
|
|
def beetle_per_insn(pcs):
|
|
model = BeetleICache()
|
|
return [model.fetch(pc) for pc in pcs]
|
|
|
|
|
|
# One line of input per instruction:
|
|
# R reset the cache (cold, as psx_icache_reset leaves it)
|
|
# E <hex> compiled instruction whose emitted fetch names <hex>
|
|
# N compiled instruction with no emitted fetch
|
|
# I <hex> interpreter instruction at <hex> (psx_icache_fetch_interp)
|
|
# Every E/N/I line prints "<cycles> <cleared>" for that instruction.
|
|
HARNESS = r"""
|
|
#include "cpu_state.h"
|
|
#include "psx_icache.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
int g_ls_replay_active = 0;
|
|
static unsigned long long cycles;
|
|
void psx_advance_cycles(uint32_t c) { cycles += c; }
|
|
void psx_cpu_charge(uint32_t c) { cycles += c; }
|
|
int main(void) {
|
|
static CPUState cpu;
|
|
char line[64];
|
|
while (fgets(line, sizeof line, stdin)) {
|
|
char op = line[0];
|
|
if (op == 'R') { psx_icache_reset(); g_psx_icache_active = 1; continue; }
|
|
if (op != 'E' && op != 'N' && op != 'I') continue;
|
|
unsigned long long before = cycles;
|
|
cpu.read_absorb_which = 1u;
|
|
cpu.read_absorb[1] = 1u;
|
|
if (op == 'E') psx_icache_fetch(&cpu, (uint32_t)strtoul(line + 2, 0, 16));
|
|
if (op == 'I') psx_icache_fetch_interp(&cpu, (uint32_t)strtoul(line + 2, 0, 16));
|
|
printf("%llu %d\n", cycles - before,
|
|
(cpu.read_absorb_which == 0u && cpu.read_absorb[1] == 0u) ? 1 : 0);
|
|
}
|
|
return 0;
|
|
}
|
|
"""
|
|
|
|
|
|
def cc(compiler, sources, include, out, defines=()):
|
|
name = os.path.basename(compiler).lower()
|
|
if name in ("cl", "cl.exe", "clang-cl", "clang-cl.exe"):
|
|
cmd = [compiler, "/nologo", "/Od", "/I" + include] + ["/D" + d for d in defines]
|
|
cmd += sources + ["/Fe:" + out]
|
|
else:
|
|
cmd = [compiler, "-std=c11", "-O2", "-I", include] + ["-D" + d for d in defines]
|
|
cmd += sources + ["-o", out]
|
|
subprocess.run(cmd, check=True, cwd=os.path.dirname(out))
|
|
|
|
|
|
class Harness:
|
|
def __init__(self, compiler, tmp):
|
|
src = os.path.join(tmp, "fetch_harness.c")
|
|
with open(src, "w") as f:
|
|
f.write(HARNESS)
|
|
self.exe = os.path.join(tmp, "fetch_harness" + (".exe" if os.name == "nt" else ""))
|
|
cc(compiler, [src, os.path.join(RUNTIME, "src", "psx_icache.c")],
|
|
os.path.join(RUNTIME, "include"), self.exe,
|
|
("PSX_ENABLE_BLOCK_CYCLES=1", "PSX_OVERLAY_DLL_BUILD=1"))
|
|
|
|
def run(self, programs):
|
|
"""programs: lists of ('E', tag) / ('N', None) / ('I', pc), each run
|
|
from a cold cache. Returns one [(cycles, cleared)] list per program."""
|
|
script = []
|
|
for prog in programs:
|
|
script.append("R\n")
|
|
for op, pc in prog:
|
|
script.append("N\n" if op == "N" else "%s %08X\n" % (op, pc))
|
|
out = subprocess.run([self.exe], input="".join(script), capture_output=True,
|
|
text=True, check=True).stdout.split("\n")
|
|
vals = [tuple(int(x) for x in ln.split()) for ln in out if ln.strip()]
|
|
res, i = [], 0
|
|
for prog in programs:
|
|
res.append(vals[i:i + len(prog)])
|
|
i += len(prog)
|
|
if i != len(vals):
|
|
raise SystemExit("harness output length mismatch")
|
|
return res
|
|
|
|
|
|
def interp_program(pcs):
|
|
return [("I", pc) for pc in pcs]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BIOS emitter output parsing
|
|
# ---------------------------------------------------------------------------
|
|
FUNC_RE = re.compile(r"^(?:static )?void \w+\(CPUState")
|
|
LABEL_RE = re.compile(r"^label_([0-9A-F]{8}):")
|
|
FETCH_RE = re.compile(r"psx_icache_fetch\(cpu, 0x([0-9A-F]{8})u\)")
|
|
INSN_RE = re.compile(r"/\* (DELAY \(orphaned\) )?0x([0-9A-F]{8}): [0-9A-F]{8} ")
|
|
|
|
|
|
def bios_runs(text, problems):
|
|
"""Straight-line runs of emitted instructions, split at labels, function
|
|
starts and address gaps. Each instruction is (runtime_pc, fetch tag or
|
|
None). Runtime PC = ROM address + the function's relocation delta, taken
|
|
from its fetches (relocate_ra moves a whole window, so one function has one
|
|
delta).
|
|
|
|
An in-function instruction's fetch precedes its comment. An orphaned delay
|
|
slot (inlined after its jr/branch) prints its comment first and then its
|
|
fetch, interlock and body, so a fetch that directly follows an orphan's
|
|
comment (only preprocessor lines between) belongs to the orphan."""
|
|
runs = []
|
|
run = None
|
|
delta = None
|
|
pending = None
|
|
last_rom = None
|
|
orphan = None # [rom, run insns index] of an orphan still awaiting its fetch
|
|
|
|
def close():
|
|
nonlocal run
|
|
if run and run["insns"]:
|
|
runs.append(run)
|
|
run = None
|
|
|
|
for line in text.splitlines():
|
|
if FUNC_RE.match(line):
|
|
close()
|
|
delta = pending = last_rom = orphan = None
|
|
continue
|
|
m = LABEL_RE.match(line)
|
|
if m:
|
|
close()
|
|
orphan = None
|
|
run = {"from_label": True, "label": int(m.group(1), 16), "insns": []}
|
|
continue
|
|
m = FETCH_RE.search(line)
|
|
if m and orphan is not None:
|
|
rom, idx = orphan
|
|
orphan = None
|
|
tag = int(m.group(1), 16)
|
|
d = (tag - rom) & 0xFFFFFFFF
|
|
if delta is None or d != delta:
|
|
problems.append("orphan fetch 0x%08X names another window than ROM 0x%08X"
|
|
% (tag, rom))
|
|
elif idx is not None:
|
|
run["insns"][idx] = (run["insns"][idx][0], tag)
|
|
continue
|
|
if orphan is not None and not line.lstrip().startswith("#"):
|
|
orphan = None
|
|
if m:
|
|
if pending is not None:
|
|
problems.append("two fetches with no instruction between them before 0x%08X" %
|
|
int(m.group(1), 16))
|
|
pending = int(m.group(1), 16)
|
|
continue
|
|
m = INSN_RE.search(line)
|
|
if not m:
|
|
continue
|
|
rom = int(m.group(2), 16)
|
|
if run is None or (last_rom is not None and rom != last_rom + 4):
|
|
close()
|
|
run = {"from_label": False, "label": rom, "insns": []}
|
|
last_rom = rom
|
|
tag = pending
|
|
pending = None
|
|
if tag is not None:
|
|
d = (tag - rom) & 0xFFFFFFFF
|
|
if delta is None:
|
|
delta = d
|
|
elif d != delta:
|
|
problems.append("fetch 0x%08X names another window than ROM 0x%08X" % (tag, rom))
|
|
if delta is None:
|
|
run.setdefault("unmapped", 0)
|
|
run["unmapped"] = run["unmapped"] + 1
|
|
orphan = [rom, None] if m.group(1) else None
|
|
continue
|
|
run["insns"].append(((rom + delta) & 0xFFFFFFFF, tag))
|
|
orphan = [rom, len(run["insns"]) - 1] if m.group(1) and tag is None else None
|
|
close()
|
|
return runs
|
|
|
|
|
|
STUB_FETCH_RE = re.compile(r"psx_icache_fetch\(cpu, addr(?: \+ (\d+)u)?\);")
|
|
|
|
|
|
def stub_shapes(dispatch_c):
|
|
"""Fetch offsets and step count for each shape of the native call stub."""
|
|
begin = dispatch_c.index("static int psx_bios_try_native_call_stub(")
|
|
end = dispatch_c.index("\n}\n", begin)
|
|
body = dispatch_c[begin:end]
|
|
shapes = {}
|
|
for name in ("shape A", "shape B"):
|
|
s = body.index(name)
|
|
e = body.find("} else", s)
|
|
seg = body[s:e if e >= 0 else len(body)]
|
|
events = re.findall(r"psx_icache_fetch\(cpu, addr(?: \+ \d+u)?\);|psx_cyc_step\(", seg)
|
|
offs = [int(m.group(1) or 0) for m in STUB_FETCH_RE.finditer(seg)]
|
|
shapes[name] = (offs, events)
|
|
return shapes
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--bios-recompiler",
|
|
default=os.path.join(ROOT, "recompiler", "build", "psxrecomp-bios"))
|
|
ap.add_argument("--compiler",
|
|
default=os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc"))
|
|
args = ap.parse_args()
|
|
if not os.path.isfile(args.bios_recompiler):
|
|
raise SystemExit("recompiler not found: %s (build it first)" % args.bios_recompiler)
|
|
if not args.compiler:
|
|
raise SystemExit("a C compiler is required")
|
|
if os.environ.get("PSX_CODEGEN_CYCLE_PER_INSN", "1")[:1] == "0":
|
|
print("SKIP: PSX_CODEGEN_CYCLE_PER_INSN=0 emits no fetch charges")
|
|
return 0
|
|
|
|
failures = []
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
harness = Harness(args.compiler, tmp)
|
|
|
|
# -- model: psx_icache.c (compiled and interpreter entry points) == Beetle
|
|
straight = [0x00010100 + 4 * i for i in range(10)]
|
|
synthetic = {
|
|
"uncached repeat": [KSEG1 | a for a in straight] * 2,
|
|
"cached cold then warm": [KSEG0 | a for a in straight] * 2,
|
|
"KSEG0 then KUSEG alias": [KSEG0 | a for a in straight] + [KUSEG | a for a in straight],
|
|
"partial refill": [KSEG0 | (straight[0] + 8), KSEG0 | straight[0]],
|
|
}
|
|
names = list(synthetic)
|
|
got_i = harness.run([interp_program(synthetic[n]) for n in names])
|
|
got_e = harness.run([[("E", pc) for pc in synthetic[n]] for n in names])
|
|
for n, gi, ge in zip(names, got_i, got_e):
|
|
want = beetle_per_insn(synthetic[n])
|
|
if gi != want or ge != want:
|
|
failures.append("model %s: psx_icache.c interp %s / compiled %s != Beetle %s"
|
|
% (n, gi, ge, want))
|
|
# The old leader-only rule on an uncached run must be caught (the
|
|
# comparison below is sensitive to exactly the bug this fixes).
|
|
k1 = [KSEG1 | a for a in straight]
|
|
leader_only = [("E", pc) if pc == k1[0] or (pc & 0xC) == 0 else ("N", None) for pc in k1]
|
|
old, ref = harness.run([leader_only, interp_program(k1)])
|
|
if old == ref:
|
|
failures.append("model: leader-only KSEG1 emission was not distinguishable from the interpreter")
|
|
|
|
# -- bios: every emitted run vs the interpreter at every PC ------------
|
|
out = os.path.join(tmp, "bios")
|
|
os.makedirs(out)
|
|
r = subprocess.run([args.bios_recompiler, "--config",
|
|
os.path.join(ROOT, "bios", "OpenBIOS.toml"), "--out-dir", out],
|
|
cwd=ROOT, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise SystemExit("psxrecomp-bios failed:\n%s" % (r.stderr or r.stdout))
|
|
with open(os.path.join(out, "OpenBIOS_full.c")) as f:
|
|
full_c = f.read()
|
|
with open(os.path.join(out, "OpenBIOS_dispatch.c")) as f:
|
|
dispatch_c = f.read()
|
|
|
|
problems = []
|
|
runs = bios_runs(full_c, problems)
|
|
failures += problems
|
|
unmapped = sum(r.get("unmapped", 0) for r in runs)
|
|
if unmapped:
|
|
failures.append("bios: %d instructions precede any fetch in their function" % unmapped)
|
|
# A run that does not start at a label is entered only by fall-through
|
|
# from code outside it (an orphaned delay slot's neighbour). Cached
|
|
# instructions there depend on earlier lines; compare them from their
|
|
# first fetch. Uncached instructions never depend on history.
|
|
compiled, interp, kinds, uncached_sites, uncached_missing = [], [], [], 0, 0
|
|
for run in runs:
|
|
insns = run["insns"]
|
|
if not run["from_label"]:
|
|
first = next((i for i, (_, t) in enumerate(insns) if t is not None), len(insns))
|
|
insns = [x for i, x in enumerate(insns) if i >= first or uncached(x[0])]
|
|
if not insns:
|
|
continue
|
|
for pc, tag in insns:
|
|
if uncached(pc):
|
|
uncached_sites += 1
|
|
if tag != pc:
|
|
uncached_missing += 1
|
|
compiled.append([("E", t) if t is not None else ("N", None) for _, t in insns])
|
|
interp.append(interp_program([pc for pc, _ in insns]))
|
|
kinds.append(insns)
|
|
got_c = harness.run(compiled)
|
|
got_i = harness.run(interp)
|
|
mismatch = {"uncached": 0, "cached": 0}
|
|
cycles_short = 0
|
|
beetle_bad = 0
|
|
first_bad = None
|
|
total = 0
|
|
for insns, gc, gi in zip(kinds, got_c, got_i):
|
|
want = beetle_per_insn([pc for pc, _ in insns])
|
|
if gi != want:
|
|
beetle_bad += sum(1 for a, b in zip(gi, want) if a != b)
|
|
for (pc, _), c, i in zip(insns, gc, gi):
|
|
total += 1
|
|
if c != i:
|
|
mismatch["uncached" if uncached(pc) else "cached"] += 1
|
|
cycles_short += i[0] - c[0]
|
|
if first_bad is None:
|
|
first_bad = (pc, c, i)
|
|
print("bios: %d runs, %d instructions compared (%d at KSEG1)"
|
|
% (len(kinds), total, uncached_sites))
|
|
if uncached_sites == 0:
|
|
failures.append("bios: no KSEG1 instructions found (parser drift?)")
|
|
if uncached_missing:
|
|
failures.append("bios: %d of %d KSEG1 instruction sites have no fetch naming their own PC"
|
|
% (uncached_missing, uncached_sites))
|
|
if mismatch["uncached"] or mismatch["cached"]:
|
|
pc, c, i = first_bad
|
|
failures.append("bios: compiled != interpreter on %d KSEG1 and %d cached instructions "
|
|
"(%d cycles short over one pass), first at 0x%08X: compiled %s, "
|
|
"interpreter %s" % (mismatch["uncached"], mismatch["cached"],
|
|
cycles_short, pc, c, i))
|
|
if beetle_bad:
|
|
failures.append("bios: psx_icache.c interpreter path != Beetle on %d instructions"
|
|
% beetle_bad)
|
|
|
|
# -- stubs: one fetch per executed word, in fetch-then-step order -----
|
|
want_offs = {"shape A": [0, 4, 8, 12], "shape B": [0, 4, 8]}
|
|
shapes = stub_shapes(dispatch_c)
|
|
progs, refs, labels = [], [], []
|
|
for name, (offs, events) in shapes.items():
|
|
if offs != want_offs[name]:
|
|
failures.append("stub %s: fetch offsets %s, executed words %s"
|
|
% (name, offs, want_offs[name]))
|
|
steps = [e for e in events if e.startswith("psx_cyc_step")]
|
|
order_ok = len(events) == 2 * len(steps) and all(
|
|
events[2 * k].startswith("psx_icache_fetch") and
|
|
events[2 * k + 1].startswith("psx_cyc_step") for k in range(len(steps)))
|
|
if not order_ok:
|
|
failures.append("stub %s: fetch/step order %s" % (name, events))
|
|
for seg in (KUSEG, KSEG0, KSEG1):
|
|
base = seg | 0xA0
|
|
progs.append([("E", base + o) if o in offs else ("N", None)
|
|
for o in want_offs[name]])
|
|
refs.append(interp_program([base + o for o in want_offs[name]]))
|
|
labels.append("%s at 0x%08X" % (name, base))
|
|
for label, gc, gi in zip(labels, harness.run(progs), harness.run(refs)):
|
|
if gc != gi:
|
|
failures.append("stub %s: compiled %s != interpreter %s" % (label, gc, gi))
|
|
|
|
for msg in failures:
|
|
print("FAIL: " + msg)
|
|
if failures:
|
|
return 1
|
|
print("PASS: compiled fetch charges == interpreter == Beetle (OpenBIOS, stubs, model)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|