Files
psxrecomp/tools/sio_flip_trace.py
T
Matthew Stanley af303f3454 runtime: Tomba "Hybrid" legacy pad-config compatibility (per-game opt-in)
The launcher Hybrid controller mode (built first, for Tomba, to mimic Tomba:
Special Edition) flips the emulated pad's reported type 0x41<->0x73 on
d-pad<->stick. After the real DualShock config state machine was added for MMX6
(98aa688), that flip regressed every game's libpad re-detect into a 1-frame
"pad unplugged": Tomba unpauses its menu / drops input, MMX6 phantom-dashes.

The modern config SM is correct and required by MMX6, so instead of weakening it
globally, restore the pre-98aa688 "config commands always answer 0xF3" behaviour
as a per-game opt-in: [controller] legacy_pad_config (default OFF). Only Tomba
sets it; with the flag off the modern pad path is byte-for-byte unchanged, so no
other title is affected. A/B verified live: legacy = clean Tomba flip / MMX6
input; modern = Tomba disconnect + MMX6 dash.

Explicitly LEGACY: a correct findpad/re-detect refactor would make the flip
benign for every game with no per-game branch (real hardware tolerates unlimited
analog-button presses); when that lands this whole feature set is to be removed.
Full rationale in sio.c (g_pad_legacy_cfg).

Also includes the deferred coherent pad-type apply (pad_type_req, applied only at
an idle/non-config bus boundary so a flip can't split a poll/handshake) and a
`pad_cfg` debug command + tools/padcfg.py + tools/sio_flip_trace.py used to A/B
the config SM live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:43:34 -07:00

111 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Pull the always-on sio_trace ring and isolate pad CONFIG handshakes + type
flips, to find the byte our config SM emits that trips libpad's disconnect.
Usage: python sio_flip_trace.py [PORT] [COUNT]
"""
import socket, json, sys
port = int(sys.argv[1]) if len(sys.argv) > 1 else 4470
count = int(sys.argv[2]) if len(sys.argv) > 2 else 16000
def fetch(port, count):
s = socket.create_connection(("127.0.0.1", port), 5)
s.sendall((json.dumps({"cmd": "sio_trace", "count": count}) + "\n").encode())
buf = b""
while True:
chunk = s.recv(1 << 20)
if not chunk:
break
buf += chunk
if buf.endswith(b"\n"):
try:
return json.loads(buf.decode())
except Exception:
continue
return json.loads(buf.decode())
resp = fetch(port, count)
ents = resp.get("entries", [])
print(f"port={port} total_seq={resp.get('total')} fetched={len(ents)}")
def b(x):
return int(x, 16) if isinstance(x, str) else int(x)
CFG = {0x43, 0x44, 0x45, 0x46, 0x47, 0x4C, 0x4D, 0x4F}
# Reconstruct pad transactions from the byte stream.
# A pad transaction begins at tx==0x01 with dev going to PAD(1).
txns = []
cur = None
for e in ents:
tx = b(e["tx"]); rx = b(e["rx"])
dev_pre = e.get("dev_pre"); dev_post = e.get("dev_post")
if tx == 0x01:
# close previous
if cur: txns.append(cur)
cur = {"seq": e["seq"], "tx": [tx], "rx": [rx], "func": e.get("func")}
continue
if cur is not None:
# continue only while this looks like the same pad txn (PAD device)
if dev_pre == 1 or dev_post == 1:
cur["tx"].append(tx); cur["rx"].append(rx)
else:
txns.append(cur); cur = None
if cur: txns.append(cur)
# Classify: command = 2nd tx byte; id = 2nd rx byte (controller id).
def cmd_of(t): return t["tx"][1] if len(t["tx"]) > 1 else None
def id_of(t): return t["rx"][1] if len(t["rx"]) > 1 else None
# Histogram to confirm parsing + see which poll IDs/commands actually occur.
from collections import Counter
hcmd = Counter(); hid = Counter()
for t in txns:
hcmd[cmd_of(t)] += 1
if cmd_of(t) == 0x42: hid[id_of(t)] += 1
print(f"\ntxns reconstructed: {len(txns)}")
print("cmd histogram: " + ", ".join(f"{(('%02X'%k) if k is not None else 'None')}:{v}" for k,v in sorted(hcmd.items(), key=lambda x:-x[1])))
print("poll(0x42) ID histogram: " + ", ".join(f"{(('%02X'%k) if k is not None else 'None')}:{v}" for k,v in sorted(hid.items(), key=lambda x:-x[1])))
# Find poll (0x42) transactions and detect ID flips between consecutive polls.
print("\n=== TYPE FLIPS (consecutive 0x42 poll ID changes) + nearby CONFIG handshakes ===")
last_poll_id = None
flip_idx = []
for i, t in enumerate(txns):
c = cmd_of(t)
if c == 0x42:
pid = id_of(t)
if last_poll_id is not None and pid != last_poll_id:
flip_idx.append(i)
last_poll_id = pid
print(f"detected {len(flip_idx)} poll-ID flips")
def fmt(t):
cmd = cmd_of(t)
tlist = " ".join(f"{x:02X}" for x in t["tx"])
rlist = " ".join(f"{x:02X}" for x in t["rx"])
tag = ""
if cmd in CFG: tag = " <CONFIG>"
if cmd == 0x42 and id_of(t) is not None: tag = f" <POLL id={id_of(t):02X} len={len(t['rx'])}>"
# flag the disconnect-class anomalies
if 0xFF in t["rx"][1:3]: tag += " !!buf0/ID=FF!!"
return f" seq={t['seq']:>9} cmd={cmd:02X} func={t['func']} TX[{tlist}] RX[{rlist}]{tag}"
# Print a window around each flip (a few txns before/after).
W = 6
shown = set()
for fi in flip_idx:
lo = max(0, fi - W); hi = min(len(txns), fi + W + 1)
print(f"\n--- flip at txn #{fi} (window {lo}..{hi-1}) ---")
for j in range(lo, hi):
marker = ">>" if j == fi else " "
print(marker + fmt(txns[j]))
# Also: every CONFIG transaction in the capture + its response (the smoking gun).
print("\n=== ALL CONFIG transactions in capture (cmd in 43/44/45/46/47/4C/4D/4F) ===")
for t in txns:
if cmd_of(t) in CFG:
print(fmt(t))