mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
432a44f64a
Supersedes the global HYBRID controller-mode shape from PR #233 with a trusted plugin callback that can choose analog or digital presentation per input sample. Keeps analog mode hardware-faithful by no longer folding physical D-pad input onto the left stick, while leaving debug injection able to steer stick-only paths. Includes the pad/cursor probe tooling from #233 for diagnosing input doubling.
346 lines
13 KiB
Python
Executable File
346 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
menu_scan.py — Find the menu cursor variable, then prove how far it moves.
|
|
|
|
The reported bug is that one D-pad tap moves the menu cursor twice. Both the
|
|
host pad state and the delivered SIO polls have been measured clean, so the
|
|
next question is what the *game* does with the input. This finds the RAM
|
|
location holding the cursor index by snapshotting main RAM while you tap, and
|
|
keeping only the locations whose value changes when — and only when — a tap
|
|
happened, by a small step.
|
|
|
|
Taps are located exactly, using the SIO byte ring rather than the snapshot
|
|
sampling rate: a 100 ms tap falls between two 250 ms snapshots and would be
|
|
invisible otherwise. Each snapshot records the ring's sequence counter, and
|
|
the taps decoded from the ring afterwards are placed into the interval they
|
|
fall in.
|
|
|
|
Usage:
|
|
python3 menu_scan.py --snapshots 40 --interval 0.25 --json /tmp/menu.json
|
|
|
|
Sit in the menu and tap the SAME direction several times, evenly spaced.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
|
|
|
RAM_BASE = 0x80000000
|
|
RAM_SIZE = 0x200000
|
|
CHUNK = 0x40000
|
|
BLOCK = 1024
|
|
|
|
|
|
def find_changed_offsets(a, b, block=BLOCK):
|
|
"""Byte offsets where two snapshots differ.
|
|
|
|
Compares block-at-a-time first: in a static menu almost all of RAM is
|
|
unchanged, so this skips the vast majority at C speed and only walks
|
|
bytes inside blocks that actually moved.
|
|
"""
|
|
out = []
|
|
n = min(len(a), len(b))
|
|
for base in range(0, n, block):
|
|
end = min(base + block, n)
|
|
if a[base:end] == b[base:end]:
|
|
continue
|
|
for i in range(base, end):
|
|
if a[i] != b[i]:
|
|
out.append(i)
|
|
return out
|
|
|
|
|
|
def held_spans(entries, button):
|
|
"""Ring-sequence spans during which `button` was held.
|
|
|
|
Rising edges alone are the wrong model: holding a direction makes the
|
|
game auto-repeat, so the cursor moves repeatedly with no new edge.
|
|
Treating those moves as "movement without input" disqualifies the very
|
|
variable being hunted, so the whole held period counts as input.
|
|
"""
|
|
import pad_delivery
|
|
polls = pad_delivery.decode_polls(entries)
|
|
spans, start, last = [], None, None
|
|
for p in polls:
|
|
if p["buttons"] is None:
|
|
continue
|
|
down = pad_delivery.pressed(p["buttons"], button)
|
|
if down and start is None:
|
|
start = p["seq"]
|
|
elif not down and start is not None:
|
|
spans.append((start, last))
|
|
start = None
|
|
last = p["seq"]
|
|
if start is not None:
|
|
spans.append((start, last))
|
|
return spans
|
|
|
|
|
|
def input_intervals(before, after, spans_by_button, settle=1):
|
|
"""Intervals where movement is legitimate, and which press caused each.
|
|
|
|
Every direction must be supplied, not just the one under study: a cursor
|
|
moves on `up` as well as `down`, and treating an unmonitored direction as
|
|
"no input" disqualifies the cursor itself.
|
|
|
|
A change seen between snapshots i and i+1 was caused by input somewhere
|
|
between the start of snapshot i's read and the end of snapshot i+1's --
|
|
reads take real time, so that window is the honest bound.
|
|
|
|
Returns (allowed, presses) where presses is a list of
|
|
(button, first_interval, last_interval) in capture order.
|
|
"""
|
|
allowed, presses = set(), []
|
|
n = len(before)
|
|
for button, spans in sorted(spans_by_button.items()):
|
|
for s0, s1 in spans:
|
|
touched = []
|
|
for i in range(n - 1):
|
|
if s0 < after[i + 1] and s1 >= before[i]:
|
|
touched.append(i)
|
|
if not touched:
|
|
continue
|
|
lo, hi = touched[0], touched[-1]
|
|
for i in range(lo, min(hi + settle, n - 2) + 1):
|
|
allowed.add(i)
|
|
presses.append((button, lo, min(hi + settle, n - 2)))
|
|
presses.sort(key=lambda p: p[1])
|
|
return allowed, presses
|
|
|
|
|
|
def press_deltas(values, presses, n):
|
|
"""Net movement across each press, measured settled-to-settled.
|
|
|
|
An animated cursor slides into place over several frames, so a per-interval
|
|
delta samples the animation rather than the step. Comparing the value
|
|
before the press with the value once it has settled measures the step the
|
|
game actually took.
|
|
"""
|
|
out = []
|
|
for button, lo, hi in presses:
|
|
a = values[lo]
|
|
b = values[min(hi + 1, n - 1)]
|
|
out.append((button, byte_delta(a, b)))
|
|
return out
|
|
|
|
|
|
def byte_delta(a, b):
|
|
"""Signed step from byte value a to b, accounting for 8-bit wrap."""
|
|
d = b - a
|
|
if d > 128:
|
|
d -= 256
|
|
elif d < -128:
|
|
d += 256
|
|
return d
|
|
|
|
|
|
def classify_offset(values, allowed, presses, max_multiple=2, min_moves=3):
|
|
"""Does this location behave like a cursor?
|
|
|
|
Disqualified if it ever moves with no input. Otherwise judged on the
|
|
net step per press, measured settled-to-settled:
|
|
|
|
* each direction must move it consistently one way -- `up` and `down`
|
|
move a cursor opposite ways, so a single global sign test would
|
|
reject the very thing we are looking for;
|
|
* every step must be one stride or exactly two, with the stride taken
|
|
from the data rather than assumed;
|
|
* it must respond to several presses, since one press cannot establish
|
|
a stride and coincidences are cheap.
|
|
"""
|
|
n = len(values)
|
|
for i in range(n - 1):
|
|
if values[i] != values[i + 1] and i not in allowed:
|
|
return None # moved with no input
|
|
|
|
per = press_deltas(values, presses, n)
|
|
moves = [(b, d) for b, d in per if d != 0]
|
|
if not moves:
|
|
return None
|
|
|
|
by_button = {}
|
|
for b, d in moves:
|
|
by_button.setdefault(b, []).append(d)
|
|
consistent = all(all(x > 0 for x in v) or all(x < 0 for x in v)
|
|
for v in by_button.values())
|
|
|
|
base = min(abs(d) for _, d in moves)
|
|
regular = base > 0 and all(abs(d) % base == 0 and abs(d) // base <= max_multiple
|
|
for _, d in moves)
|
|
# Opposite directions moving opposite ways is the signature of an axis.
|
|
opposed = False
|
|
for a, b in (("up", "down"), ("left", "right")):
|
|
if a in by_button and b in by_button:
|
|
if by_button[a][0] * by_button[b][0] < 0:
|
|
opposed = True
|
|
|
|
strict = consistent and regular and len(moves) >= min_moves
|
|
return {"moves": len(moves), "per_press": per, "base": base,
|
|
"strict": strict, "values": list(values), "opposed": opposed,
|
|
"deltas": [d for _, d in moves], "consistent": consistent,
|
|
"doubled": strict and any(abs(d) // base == 2 for _, d in moves)}
|
|
|
|
|
|
def decode_taps(entries, button):
|
|
"""Ring sequence of each rising edge of `button` in the SIO stream."""
|
|
import pad_delivery
|
|
polls = pad_delivery.decode_polls(entries)
|
|
taps, held = [], False
|
|
for p in polls:
|
|
if p["buttons"] is None:
|
|
continue
|
|
now = pad_delivery.pressed(p["buttons"], button)
|
|
if now and not held:
|
|
taps.append(p["seq"])
|
|
held = now
|
|
return taps
|
|
|
|
|
|
def read_ram(link, base, size):
|
|
out = bytearray()
|
|
for off in range(0, size, CHUNK):
|
|
n = min(CHUNK, size - off)
|
|
r = link.cmd_with({"cmd": "read_ram",
|
|
"addr": "0x%08X" % (base + off), "len": n})
|
|
if not r or not r.get("ok"):
|
|
raise RuntimeError("read_ram failed at 0x%08X: %r" % (base + off, r))
|
|
out.extend(bytes.fromhex(r["hex"]))
|
|
return bytes(out)
|
|
|
|
|
|
def capture(host, port, snapshots, interval, ring_count=512):
|
|
"""Snapshot RAM repeatedly, stitching the SIO ring as we go.
|
|
|
|
The ring is pulled every round and merged by sequence number. Pulling
|
|
once at the end instead would silently lose any tap that scrolled out of
|
|
the window, and a lost tap does not merely go unseen -- it disqualifies
|
|
the very variable we are looking for, because that variable then appears
|
|
to move with no input.
|
|
"""
|
|
import pad_trace
|
|
link = pad_trace.Link(host, port)
|
|
snaps, before, after, ring, seen_max, gaps = [], [], [], [], -1, 0
|
|
for _ in range(snapshots):
|
|
t0 = time.time()
|
|
r = link.cmd_with({"cmd": "sio_trace", "count": ring_count})
|
|
before.append(int(r["total"]) if r and "total" in r else -1)
|
|
for e in (r or {}).get("entries", []):
|
|
if e["seq"] > seen_max:
|
|
if seen_max >= 0 and e["seq"] > seen_max + 1 and not ring:
|
|
pass
|
|
ring.append(e)
|
|
seen_max = e["seq"]
|
|
snaps.append(read_ram(link, RAM_BASE, RAM_SIZE))
|
|
r2 = link.cmd_with({"cmd": "sio_trace", "count": ring_count})
|
|
after.append(int(r2["total"]) if r2 and "total" in r2 else -1)
|
|
for e in (r2 or {}).get("entries", []):
|
|
if e["seq"] > seen_max:
|
|
ring.append(e)
|
|
seen_max = e["seq"]
|
|
spent = time.time() - t0
|
|
if interval > spent:
|
|
time.sleep(interval - spent)
|
|
r = link.cmd_with({"cmd": "sio_trace", "count": ring_count})
|
|
for e in (r or {}).get("entries", []):
|
|
if e["seq"] > seen_max:
|
|
ring.append(e)
|
|
seen_max = e["seq"]
|
|
link.close()
|
|
ring.sort(key=lambda e: e["seq"])
|
|
for i in range(1, len(ring)):
|
|
if ring[i]["seq"] != ring[i - 1]["seq"] + 1:
|
|
gaps += ring[i]["seq"] - ring[i - 1]["seq"] - 1
|
|
return snaps, before, after, ring, gaps
|
|
|
|
|
|
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=4370)
|
|
ap.add_argument("--snapshots", type=int, default=40)
|
|
ap.add_argument("--interval", type=float, default=0.25)
|
|
ap.add_argument("--buttons", default="up,down,left,right",
|
|
help="all directions that should count as input")
|
|
ap.add_argument("--max-report", type=int, default=40)
|
|
ap.add_argument("--min-moves", type=int, default=3,
|
|
help="presses a location must respond to")
|
|
ap.add_argument("--settle", type=int, default=2,
|
|
help="intervals after a tap in which movement is still "
|
|
"allowed (animated cursors)")
|
|
ap.add_argument("--json")
|
|
args = ap.parse_args()
|
|
|
|
print("Capturing %d snapshots %.2fs apart (%.0fs) — sit in the menu and "
|
|
"tap %s several times."
|
|
% (args.snapshots, args.interval,
|
|
args.snapshots * args.interval, args.buttons))
|
|
snaps, before, after, ring, gaps = capture(
|
|
args.host, args.port, args.snapshots, args.interval)
|
|
if gaps:
|
|
print("WARNING: %d SIO bytes missed; a tap may be undetected." % gaps)
|
|
buttons = [b.strip() for b in args.buttons.split(",") if b.strip()]
|
|
spans_by_button = {b: held_spans(ring, b) for b in buttons}
|
|
tapped, presses = input_intervals(before, after, spans_by_button,
|
|
settle=args.settle)
|
|
counts = ", ".join("%s=%d" % (b, len(spans_by_button[b])) for b in buttons)
|
|
print("%d snapshots, presses in ring: %s -> %d located, %d interval(s) "
|
|
"where movement is allowed"
|
|
% (len(snaps), counts, len(presses), len(tapped)))
|
|
located = len(presses)
|
|
if not tapped:
|
|
print("No taps located inside the capture window — nothing to "
|
|
"correlate against. Tap during the capture and retry.")
|
|
return 1
|
|
|
|
# Union of everything that ever moved, then test each against the taps.
|
|
moved = set()
|
|
for i in range(len(snaps) - 1):
|
|
moved.update(find_changed_offsets(snaps[i], snaps[i + 1]))
|
|
print("%d bytes changed at least once" % len(moved))
|
|
|
|
strict, loose = [], []
|
|
for off in sorted(moved):
|
|
vals = [s_[off] for s_ in snaps]
|
|
sc = classify_offset(vals, tapped, presses,
|
|
min_moves=args.min_moves)
|
|
if not sc:
|
|
continue
|
|
sc["addr"] = RAM_BASE + off
|
|
(strict if sc["strict"] else loose).append(sc)
|
|
strict.sort(key=lambda h: (-h["moves"], not h["opposed"]))
|
|
loose.sort(key=lambda h: -h["moves"])
|
|
|
|
def show(rows, label):
|
|
print("\n%d %s:" % (len(rows), label))
|
|
for h in rows[:args.max_report]:
|
|
flag = " <-- DOUBLE STEP" if h.get("doubled") else ""
|
|
if h.get("opposed"):
|
|
flag += " [axis]"
|
|
per = " ".join("%s:%+d" % (b, d)
|
|
for b, d in h.get("per_press", []) if d)
|
|
print(" 0x%08X moves=%d/%d base=%d [%s]%s"
|
|
% (h["addr"], h["moves"], located, h["base"], per, flag))
|
|
if len(rows) > args.max_report:
|
|
print(" ... %d more (raise --max-report)"
|
|
% (len(rows) - args.max_report))
|
|
|
|
show(strict, "location(s) stepping consistently on presses")
|
|
if not strict:
|
|
show(loose, "location(s) that moved only on taps, but irregularly")
|
|
hits = strict or loose
|
|
|
|
if args.json:
|
|
with open(args.json, "w") as fh:
|
|
json.dump({"presses": presses, "allowed": sorted(tapped),
|
|
"strict": strict, "loose": loose}, fh, indent=2)
|
|
print("\nwrote %s" % args.json)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|