mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
216eb31ef2
Boot anchors (reset, the kernel copy, the first A0/B0/C0 calls, the shell)
pass within the first frames, before a TCP cyc_watch arm can land, so a
native-vs-Beetle boot comparison could not be run from the tree.
psx-runtime (debug_server.c) and psx-beetle (beetle_main.cpp) now read
PSX_CYC_WATCH ("<pc>" or "<pc>-<end>" for region mode) and PSX_CYC_WATCH_N
(default 16) at start-up and arm the watch before the first guest
instruction. The native arm shares the TCP handler's code path
(cyc_watch_arm); psx-beetle calls beetle_cyc_watch_arm. With neither
variable set nothing changes. tools/cycle_compare.py --no-arm then reads
both rings.
PSX_BEETLE_UNPACED=1 skips psx-beetle's 59.94 Hz wall-clock pacing for
headless oracle runs. Guest timing is unaffected.
docs/beetle-macos.md step 7 documents the variables and the anchor-key
difference for BIOS copy windows declared dispatch_key = "rom": the shell
is keyed natively at its ROM address (OpenBIOS 0xBFC0A500, SCPH-1001
0xBFC18000) while Beetle sees 0x80030000. Checked on macOS: the documented
example records the OpenBIOS shell entry at 5,015,670 in Beetle, the value
of the #429 land gate, and the native watch arms from boot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
13 KiB
Python
357 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
cycle_compare.py — native<->Beetle per-anchor guest-cycle comparator.
|
|
|
|
Arms the SAME guest-PC anchor on both backends via the `cyc_watch` command,
|
|
lets both run from boot, then dumps and diffs the recorded cycle ring per
|
|
hit_index. Detects fine per-instruction cycle DRIFT (e.g. the -8 class) that
|
|
the gross per-frame rate (~565k/frame, already matched) hides — by comparing
|
|
elapsed cycles AT THE SAME GUEST ANCHOR on both backends.
|
|
|
|
CAPTURE SEMANTICS (must be identical on both sides):
|
|
The anchor is sampled at BLOCK ENTRY, BEFORE the anchor instruction (the
|
|
block leader at the anchor PC) executes. The recorded `cycles` is the
|
|
absolute guest cycle count charged for all PRIOR blocks, not including any
|
|
cycle of the anchor block itself. Anchor must be a basic-block leader PC
|
|
(function entry / branch target), not a mid-block PC.
|
|
|
|
The native side (psx-runtime) implements `cyc_watch` / `cyc_watch_dump` /
|
|
`cyc_watch_clear` in runtime/src/debug_server.c. The Beetle side
|
|
(psx-beetle) implements the SAME commands to the SAME spec (added parent-side).
|
|
|
|
BOOT ANCHORS: reset, the kernel copy, the first syscalls and the shell pass in
|
|
the first frames, before a TCP arm can land. Arm them from power-on instead:
|
|
start each backend with PSX_CYC_WATCH=<pc> (or <pc>-<end> for REGION mode) and
|
|
PSX_CYC_WATCH_N=<hits> (default 16) in its environment, then run this tool with
|
|
--no-arm to dump and diff. The anchor key can differ per side: a BIOS copy
|
|
window with dispatch_key = "rom" (the shell, in both bios/OpenBIOS.toml and
|
|
bios/SCPH1001.toml) is keyed natively at its ROM address, so the OpenBIOS shell
|
|
entry is 0x80030000 in Beetle but 0xBFC0A500 natively (SCPH-1001: 0xBFC18000).
|
|
Windows with dispatch_key = "ram" (the kernels) use the RAM PC on both sides.
|
|
|
|
Wire format (request):
|
|
{"cmd":"cyc_watch","pc":"0x80012345","n":16}
|
|
{"cmd":"cyc_watch_dump"}
|
|
{"cmd":"cyc_watch_clear"}
|
|
|
|
Wire format (cyc_watch_dump response):
|
|
{
|
|
"ok": true,
|
|
"anchor": "0x80012345", # as supplied
|
|
"anchor_phys": "0x00012345", # masked to physical (pc & 0x1FFFFFFF)
|
|
"armed": 0|1,
|
|
"max_hits": 16,
|
|
"hits": <N recorded>,
|
|
"entries": [
|
|
{"hit_index": 0, "pc": "0x00012345", "cycles": 1234567},
|
|
...
|
|
]
|
|
}
|
|
|
|
Usage:
|
|
python cycle_compare.py <anchor_pc_hex> [--hits N] [--wait SECONDS]
|
|
[--native-port P] [--beetle-port P] [--host H]
|
|
[--no-arm] [--clear]
|
|
|
|
<anchor_pc_hex> Guest PC to anchor (e.g. 0x80012345). A block leader.
|
|
|
|
Options:
|
|
--hits N Max anchor hits to record on each side (default 16).
|
|
--wait SECONDS Seconds to let both backends run after arming before
|
|
dumping (default 5.0). Both must already be running.
|
|
--native-port P psx-runtime debug port (default 4500).
|
|
--beetle-port P psx-beetle debug port (default 4382).
|
|
--host H Host for both (default 127.0.0.1).
|
|
--no-arm Skip arming; just dump+diff what's already recorded.
|
|
--clear Clear both rings and exit (no arm, no dump).
|
|
|
|
Exit status:
|
|
0 comparison completed (drift may or may not be present)
|
|
2 a backend was unreachable or its cyc_watch command is missing
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import time
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_NATIVE_PORT = 4500
|
|
DEFAULT_BEETLE_PORT = 4382
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transport (brace-balanced, string-aware reader; mirrors debug_client.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _recv_one_json(sock):
|
|
buf = bytearray()
|
|
pos = 0
|
|
depth = 0
|
|
in_str = False
|
|
esc = False
|
|
started = False
|
|
while True:
|
|
chunk = sock.recv(1 << 20)
|
|
if not chunk:
|
|
break
|
|
buf.extend(chunk)
|
|
n = len(buf)
|
|
while pos < n:
|
|
if in_str:
|
|
if esc:
|
|
esc = False
|
|
pos += 1
|
|
continue
|
|
q = buf.find(b'"', pos)
|
|
bs = buf.find(b"\\", pos)
|
|
if q == -1 and bs == -1:
|
|
pos = n
|
|
break
|
|
if bs != -1 and (q == -1 or bs < q):
|
|
esc = True
|
|
pos = bs + 1
|
|
continue
|
|
in_str = False
|
|
pos = q + 1
|
|
continue
|
|
c = buf[pos]
|
|
if c == 0x22: # '"'
|
|
in_str = True
|
|
elif c == 0x7B: # '{'
|
|
depth += 1
|
|
started = True
|
|
elif c == 0x7D: # '}'
|
|
depth -= 1
|
|
if started and depth == 0:
|
|
return json.loads(buf[:pos + 1].decode())
|
|
pos += 1
|
|
if not buf:
|
|
raise ConnectionError("empty response")
|
|
return json.loads(buf.decode().strip())
|
|
|
|
|
|
def query(host, port, cmd_dict, timeout=10.0):
|
|
"""One-shot: connect, send one command, receive one JSON object, close."""
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.settimeout(timeout)
|
|
s.connect((host, port))
|
|
try:
|
|
s.sendall((json.dumps(cmd_dict) + "\n").encode())
|
|
return _recv_one_json(s)
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backend helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class BackendError(Exception):
|
|
pass
|
|
|
|
|
|
def _is_unknown_command(resp):
|
|
"""Heuristic: the backend answered, but doesn't know cyc_watch yet."""
|
|
if not isinstance(resp, dict):
|
|
return True
|
|
if resp.get("ok") is True:
|
|
return False
|
|
err = str(resp.get("error", "")).lower()
|
|
return ("unknown" in err or "unrecognized" in err or
|
|
"no such" in err or "not found" in err or err == "")
|
|
|
|
|
|
def arm(host, port, label, anchor, hits, end=None):
|
|
cmd = {"cmd": "cyc_watch", "pc": anchor, "n": hits}
|
|
if end:
|
|
cmd["end"] = end # two-anchor REGION mode: each entry = Δcycles of one A->B pass
|
|
try:
|
|
resp = query(host, port, cmd)
|
|
except (ConnectionRefusedError, TimeoutError, OSError, ConnectionError) as e:
|
|
raise BackendError(f"{label} ({host}:{port}) unreachable: {e}")
|
|
if _is_unknown_command(resp):
|
|
raise BackendError(
|
|
f"{label} ({host}:{port}) does not implement 'cyc_watch' yet "
|
|
f"(response: {json.dumps(resp)}). The Beetle side must add the "
|
|
f"cyc_watch / cyc_watch_dump / cyc_watch_clear commands to the "
|
|
f"same spec as native (see header).")
|
|
return resp
|
|
|
|
|
|
def dump(host, port, label):
|
|
try:
|
|
resp = query(host, port, {"cmd": "cyc_watch_dump"})
|
|
except (ConnectionRefusedError, TimeoutError, OSError, ConnectionError) as e:
|
|
raise BackendError(f"{label} ({host}:{port}) unreachable: {e}")
|
|
if _is_unknown_command(resp):
|
|
raise BackendError(
|
|
f"{label} ({host}:{port}) does not implement 'cyc_watch_dump' yet "
|
|
f"(response: {json.dumps(resp)}).")
|
|
return resp
|
|
|
|
|
|
def clear(host, port, label):
|
|
try:
|
|
query(host, port, {"cmd": "cyc_watch_clear"})
|
|
except (ConnectionRefusedError, TimeoutError, OSError, ConnectionError) as e:
|
|
raise BackendError(f"{label} ({host}:{port}) unreachable: {e}")
|
|
|
|
|
|
def entries_by_index(resp):
|
|
"""Map hit_index -> absolute cycles from a cyc_watch_dump response."""
|
|
out = {}
|
|
for e in resp.get("entries", []):
|
|
out[int(e["hit_index"])] = int(e["cycles"])
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff / report
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def report(anchor, native_resp, beetle_resp):
|
|
nat = entries_by_index(native_resp)
|
|
bet = entries_by_index(beetle_resp)
|
|
|
|
n_phys = native_resp.get("anchor_phys", "?")
|
|
b_phys = beetle_resp.get("anchor_phys", "?")
|
|
|
|
print(f"=== cyc_watch compare @ anchor {anchor} ===")
|
|
print(f"native anchor_phys={n_phys} hits={native_resp.get('hits', 0)} "
|
|
f"beetle anchor_phys={b_phys} hits={beetle_resp.get('hits', 0)}")
|
|
if n_phys != b_phys:
|
|
print(f"WARNING: anchor_phys differs between backends "
|
|
f"(native {n_phys} vs beetle {b_phys}) — they are not "
|
|
f"sampling the same PC.")
|
|
if not nat:
|
|
print("native recorded NO hits — anchor never reached on native "
|
|
"(not a block leader, or not executed in the window).")
|
|
if not bet:
|
|
print("beetle recorded NO hits — anchor never reached on beetle.")
|
|
|
|
print()
|
|
print(f"{'hit':>4} {'native_cycles':>16} {'beetle_cycles':>16} "
|
|
f"{'delta(n-b)':>12} {'d_native':>10} {'d_beetle':>10}")
|
|
print("-" * 78)
|
|
|
|
common = sorted(set(nat) | set(bet))
|
|
first_drift = None
|
|
deltas = []
|
|
prev_n = prev_b = None
|
|
for i in common:
|
|
n = nat.get(i)
|
|
b = bet.get(i)
|
|
n_s = str(n) if n is not None else "(none)"
|
|
b_s = str(b) if b is not None else "(none)"
|
|
if n is not None and b is not None:
|
|
delta = n - b
|
|
deltas.append((i, delta))
|
|
if delta != 0 and first_drift is None:
|
|
first_drift = (i, delta)
|
|
d_str = str(delta)
|
|
else:
|
|
d_str = "(missing)"
|
|
dn = str(n - prev_n) if (n is not None and prev_n is not None) else ""
|
|
db = str(b - prev_b) if (b is not None and prev_b is not None) else ""
|
|
print(f"{i:>4} {n_s:>16} {b_s:>16} {d_str:>12} {dn:>10} {db:>10}")
|
|
if n is not None:
|
|
prev_n = n
|
|
if b is not None:
|
|
prev_b = b
|
|
|
|
print()
|
|
if first_drift is not None:
|
|
idx, d = first_drift
|
|
print(f"DRIFT ONSET: first delta != 0 at hit_index {idx} (delta {d}).")
|
|
# Steady delta: the most common nonzero delta among the tail.
|
|
tail = [d for (_, d) in deltas if _ >= idx]
|
|
if tail:
|
|
from collections import Counter
|
|
steady, cnt = Counter(tail).most_common(1)[0]
|
|
print(f"STEADY DELTA: {steady} (cycles, native - beetle; "
|
|
f"{cnt}/{len(tail)} of post-onset hits).")
|
|
# Per-hit growth gives the drift RATE.
|
|
if len(deltas) >= 2:
|
|
growth = deltas[-1][1] - deltas[0][1]
|
|
span = deltas[-1][0] - deltas[0][0]
|
|
if span > 0:
|
|
print(f"DRIFT RATE: ~{growth/span:+.2f} cycles per anchor hit "
|
|
f"(delta {deltas[0][1]} -> {deltas[-1][1]} over "
|
|
f"{span} hits).")
|
|
elif deltas:
|
|
print("NO DRIFT: native and beetle elapsed cycles match at every "
|
|
"compared hit_index.")
|
|
else:
|
|
print("NO COMPARABLE HITS: at least one backend recorded nothing.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(add_help=True, description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("anchor", nargs="?", help="anchor guest PC (region START), hex (e.g. 0x80012345)")
|
|
p.add_argument("--end", default=None,
|
|
help="region END PC, hex. Enables two-anchor REGION mode: each "
|
|
"recorded value is Δcycles of one START->END pass (the cost of "
|
|
"the known code path between two dispatch points). Both anchors "
|
|
"must be dispatch points (function entry / CPS continuation / "
|
|
"dirty block). Omit for single-anchor (absolute-cycles-at-anchor).")
|
|
p.add_argument("--hits", type=int, default=16)
|
|
p.add_argument("--wait", type=float, default=5.0)
|
|
p.add_argument("--native-port", type=int, default=DEFAULT_NATIVE_PORT)
|
|
p.add_argument("--beetle-port", type=int, default=DEFAULT_BEETLE_PORT)
|
|
p.add_argument("--host", default=DEFAULT_HOST)
|
|
p.add_argument("--no-arm", action="store_true",
|
|
help="skip arming; dump+diff already-recorded rings")
|
|
p.add_argument("--clear", action="store_true",
|
|
help="clear both rings and exit")
|
|
opts = p.parse_args()
|
|
|
|
host = opts.host
|
|
np_, bp_ = opts.native_port, opts.beetle_port
|
|
|
|
if opts.clear:
|
|
try:
|
|
clear(host, np_, "native")
|
|
clear(host, bp_, "beetle")
|
|
print("Cleared cyc_watch rings on both backends.")
|
|
except BackendError as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
if not opts.anchor:
|
|
p.error("anchor PC is required (unless --clear)")
|
|
|
|
anchor = opts.anchor
|
|
if not anchor.lower().startswith("0x"):
|
|
anchor = "0x" + anchor
|
|
|
|
try:
|
|
if not opts.no_arm:
|
|
r_n = arm(host, np_, "native", anchor, opts.hits, end=opts.end)
|
|
r_b = arm(host, bp_, "beetle", anchor, opts.hits, end=opts.end)
|
|
print(f"Armed native: {json.dumps(r_n)}")
|
|
print(f"Armed beetle: {json.dumps(r_b)}")
|
|
print(f"Letting both run for {opts.wait:.1f}s ...")
|
|
time.sleep(opts.wait)
|
|
|
|
native_resp = dump(host, np_, "native")
|
|
beetle_resp = dump(host, bp_, "beetle")
|
|
except BackendError as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
print()
|
|
report(anchor, native_resp, beetle_resp)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|