Files
psxrecomp/tools/notify_diff.py
T
Alex Vanderveen 8ec081048f notify_diff: read-ahead past the load is not degradation
A restarted, healthy session still refused: two sectors "lost their
notification", both past 125117. The game Pauses right after that
sector, so the drive's continued read-ahead is never consumed and those
notifications are lost by design -- normal behaviour, flagged as the
failure state.

The window now ends at the load's last sector, and loss is only counted
inside it. The refusal also names the sectors instead of one example, so
a genuine case can be checked against the load's own extent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:58 -04:00

204 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""notify_diff.py -- diff how the two emulators NOTIFY the guest per sector.
python3 tools/notify_diff.py # both at/after the artifact screen
The transfers are understood: psx-runtime fills the game's buffers
109,111,111,113,113 where DuckStation fills 109,110,111,112,113. Sectors 110
and 112 -- the second of each two-sector read, one of which carries the
effect's palette -- are READ on psx-runtime (delivery record [D], never lost)
and simply never drained. Continuations are not broken in general: the
five-sector read 113..117 drains completely on both. It fails on the
TWO-sector reads.
So the question is no longer what arrives, but what the guest is TOLD about
what arrived. psx-runtime raises an immediate INT1 for a sector arriving with
the flag clear. DuckStation may instead DROP the notification and re-announce
it once the guest finishes draining. This prints, per sector, which of those
happened on each side.
psx-runtime: data / pended / lost (its per-sector timing records)
oracle: data / dropped / queued / delivered / redelivered / drained
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import ( # noqa: E402
DEFAULT_DUCKSTATION_PORT, DEFAULT_NATIVE_PORT, DebugConn, DebugError,
)
KIND = "psx-notify-diff"
def native_rows(entries):
"""LBA -> flags from the MOST RECENT record for that sector.
These rings accumulate for the whole session, so replaying the scene
legitimately produces several records per sector -- accumulating them
conflates passes, and one earlier degraded pass then poisons every
sector's summary. Entries arrive oldest-first, so the last one wins.
"""
out = {}
for e in entries:
lba = int(e.get("lba", -1))
if lba < 0:
continue
prev = out.get(lba, {"passes": 0})
out[lba] = {"passes": prev["passes"] + 1,
"data": int(e.get("data", 0)),
"dma": int(e.get("dma", 0)),
"pended": int(e.get("pended", 0)),
"lost": int(e.get("lost", 0))}
return out
def oracle_rows(entries):
"""LBA -> flags from the MOST RECENT event for that sector. See above."""
out = {}
for e in entries:
lba = int(e.get("lba", -1))
if lba < 0:
continue
prev = out.get(lba, {"passes": 0})
row = {"passes": prev["passes"] + 1}
for k in ("data", "dropped", "queued", "delivered", "redelivered",
"drained"):
row[k] = int(e.get(k, 0))
out[lba] = row
return out
def marks(r, keys):
return "".join(k[0].upper() if r.get(k) else "-" for k in keys)
NAT_KEYS = ("data", "dma", "pended", "lost")
ORC_KEYS = ("data", "dropped", "queued", "delivered", "redelivered", "drained")
def interpret(nat, orc, lo, hi):
"""What the two notification patterns say about the missed sectors."""
notes = []
for lba in range(lo, hi + 1):
n, o = nat.get(lba), orc.get(lba)
if not n or not o:
continue
# The interesting case: psx-runtime raised an immediate INT1 (data,
# not pended) while the oracle deliberately withheld and re-announced.
nat_immediate = n["data"] and not n["pended"]
orc_withheld = o["dropped"] or o["redelivered"]
if nat_immediate and orc_withheld:
notes.append(
f"LBA {lba}: psx-runtime raised an immediate INT1; the oracle "
f"{'dropped it' if o['dropped'] else 'withheld it'}"
f"{' and re-announced after the drain' if o['redelivered'] else ''}."
f" Announcing straight away moves the read slot before the "
f"guest has drained the previous sector -- which is how the "
f"sector goes missing.")
return notes
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=DEFAULT_NATIVE_PORT)
ap.add_argument("--ds-port", type=int, default=DEFAULT_DUCKSTATION_PORT)
ap.add_argument("--timeout", type=float, default=30.0)
ap.add_argument("--lba-lo", type=int, default=125104)
ap.add_argument("--lba-hi", type=int, default=125117,
help="last sector of the load. The game issues Pause "
"after 125117; sectors past it are read-ahead it "
"never consumes, so THEIR notifications are lost by "
"design and must not be read as degradation")
ap.add_argument("--ds-offset", type=int, default=150,
help="the oracle counts LBA from disc start; subtract the "
"2-second pregap to match psx-runtime's game LBAs")
ap.add_argument("--out", default="analysis/frames/notify_diff.json")
args = ap.parse_args()
try:
nrep = DebugConn(args.host, args.port, args.timeout).cmd(
"cdrom_timing_dump", tail=4096,
lba_lo=args.lba_lo, lba_hi=args.lba_hi)
nat = native_rows(nrep.get("entries", []))
except DebugError as e:
print(f"psx-runtime: {e}", file=sys.stderr)
return 2
try:
orep = DebugConn(args.host, args.ds_port, args.timeout).cmd(
"cd_sector_events", tail=400,
lba_lo=args.lba_lo + args.ds_offset,
lba_hi=args.lba_hi + args.ds_offset)
raw = orep.get("entries", [])
for e in raw:
e["lba"] = int(e["lba"]) - args.ds_offset
orc = oracle_rows(raw)
except DebugError as e:
print(f"oracle: {e}", file=sys.stderr)
print(" (rebuild + reinstall the oracle, restart it, replay the "
"scene there)", file=sys.stderr)
return 2
# A contaminated psx-runtime session invalidates the whole comparison:
# `lost` set, or a sector read more than once, is the degraded state, not
# the clean one. Refuse rather than diff it -- the first run of this tool
# compared a stale 2x-experiment session against a healthy oracle.
# Loss only counts inside the load itself. Past its last sector the drive
# keeps reading until the game's Pause, and those notifications are lost
# by design -- flagging them made a clean session look degraded.
dirty = sorted(lba for lba, r in nat.items()
if r["lost"] and lba <= args.lba_hi)
if dirty:
print(f"psx-runtime's most recent pass is DEGRADED inside the load: "
f"sector(s) {dirty} lost their notification. That is the "
f"failure state, not the baseline.\n"
f"Restart psx-runtime -- it picks up the reverted authentic-1x "
f"game.toml -- replay the scene, and rerun.", file=sys.stderr)
return 1
if not nat or not orc:
for label, m in (("psx-runtime", nat), ("oracle", orc)):
if not m:
print(f" {label} has no sector records in LBA "
f"{args.lba_lo}..{args.lba_hi} -- play the palette load "
f"there and rerun.")
return 1
print(f"{'LBA':>8} psx-runtime {'/'.join(k[:3] for k in NAT_KEYS):>16}"
f" oracle {'/'.join(k[:3] for k in ORC_KEYS)}")
for lba in range(args.lba_lo, args.lba_hi + 1):
n, o = nat.get(lba), orc.get(lba)
ns = f"{marks(n, NAT_KEYS)} p{n['passes']}" if n else "--"
os_ = f"{marks(o, ORC_KEYS)} p{o['passes']}" if o else "--"
print(f"{lba:>8} {ns:>27} {os_}")
notes = interpret(nat, orc, args.lba_lo, args.lba_hi)
doc = {"kind": KIND, "version": 1, "native": nat, "oracle": orc,
"notes": notes}
if notes:
print("\nnotification differences on the sectors that go missing:")
for t in notes:
print(f" - {t}")
else:
print("\nNo sector shows psx-runtime announcing immediately where the "
"oracle withheld. The notification patterns agree, so what the "
"guest is TOLD is not the difference either.")
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
with open(args.out, "w") as f:
json.dump(doc, f, indent=1)
print(f"\nreport: {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())