This reverts commit b836f2198c.
20 KiB
Axis 5 — SIO / Controller / Memory-Card Accuracy
Cross-reference of runtime/src/sio.c (+ main.cpp pad driver, memcard.c)
against the in-tree Beetle/Mednafen oracle
(beetle-psx/mednafen/psx/:
frontio.cpp, input/dualshock.cpp, input/gamepad.cpp, input/memcard.cpp)
and nocash psx-spx "Controllers and Memory Cards".
Historical read-only research; citations use the line numbers from that audit.
Status 2026-07-26: Tomba now offers explicit Analog / D-Pad modes instead of Hybrid. The Tomba-only
g_pad_legacy_cfgprotocol fork, its game-config key, debug command, and helper script have been removed. References to them below document the retired implementation and are not descriptions of the current runtime.
1. What our implementation does
1.1 Bus / transfer model (sio.c)
- SIO0 MMIO at
0x1F801040..0x1F80104Ehandled insio_read/sio_write(sio.c:1306,sio.c:1361). A byte is "transferred" when the guest writesSIO_TX_DATA(0x1F801040) withTX_ENset; we process it synchronously viasio_process_byte(sio.c:1131) and arm a delayed ACK→IRQ7. - Two timing models behind
SIO_MODEL_CYCLE_PACED(default 1,sio.c:1767):- Access-paced legacy path —
sio_irq_countdowndecrements on every SIO register access and onsio_tick; fires IRQ7 + setsSTAT.ACK/STAT.IRQ(sio.c:1983). Delays:SIO_IRQ_DELAY_PAD=4,SIO_IRQ_DELAY_CARD=8(sio.c:490,498). - Cycle-paced shifter —
sio_shift_*+sio_pending_ack+sio_advance(sio.c:1882) modellingSIO_BAUD_CYCLES_DEFAULT=1088shift +170-cycle ACK. However the pad fast-path (sio.c:1386) bypasses the shifter entirely: any pad byte (active_device==DEV_PAD, orbus_owner==PAD, ortx==0x01) is processed immediately and uses the access-pacedSIO_IRQ_DELAY_PADcountdown. So in practice pad timing is still access-paced, not cycle-paced.
- Access-paced legacy path —
active_device(NONE/PAD/MEMCARD,sio.c:173) routes a byte topad_process_byteormc_process_byte. First byte selects:0x01→pad,0x81→card (sio.c:1148).
1.2 Pad state machine (pad_process_byte, sio.c:727)
A 3-state FSM (PAD_IDLE → PAD_WAIT_ACCESS → PAD_SEND_RESPONSE, sio.c:55),
byte-indexed not bit-indexed. Per-slot state:
pad_buttons[2](active-low),pad_analog[2](0=digital id0x41, 1=analog id0x73),pad_stick[2][4],pad_connected.pad_in_config[2]— DualShock config-mode latch (report0xF3).pad_supports_config[2]— 1 = DualShock (answers config cmds), 0 = plain digital pad (ignores them) (sio.c:87).pad_type_req[2]— deferred host type-change request, applied only atPAD_IDLE && !in_config(sio.c:100, appliedsio.c:733).g_pad_legacy_cfg— per-game flag (Tomba only). When set,0x43always answers0xF3and the0x45/0x46/0x47/0x4C/0x4Dconfig queries answer unconditionally with no enter/exit tracking (sio.c:718,sio.c:793,sio.c:802).
Command handling at PAD_WAIT_ACCESS (sio.c:752):
0x42poll: emits[id, 0x5A, btnlo, btnhi](4 bytes), or 8 bytes with the four stick axes whenpad_analog || pad_in_config(sio.c:767).0x43(modern): emits[cur_id, 0x5A, 0,0,0,0,0,0](8 bytes), latches enter/exit from the data byte at response index 2 (sio.c:862).0x44(modern): mode-set; latches analog/digital from data byte at index 2 (sio.c:870).0x45/0x46/0x47/0x4C/0x4D/0x4F(modern): canned responses, only whilepad_in_config(sio.c:823).- Anything else, or a config cmd outside config: hi-z (
0xFF), end txn (sio.c:845).
1.3 Host pad driver (main.cpp)
sample_pad_into_sio (main.cpp:1240) runs once (twice w/ low-latency) per
frame: computes eff_analog from mode (DIGITAL/ANALOG/HYBRID, main.cpp:1261),
pushes sticks via sio_set_pad_sticks, and requests the type via
sio_request_pad_type (main.cpp:1288). HYBRID auto-flips analog on stick
deflection / digital on d-pad (main.cpp:1267).
1.4 Memory card FSM (mc_process_byte, sio.c:906)
Per-slot mc_slots[2] saved/restored across device switches (sio.c:176).
Read (0x52), write (0x57), get-id (0x53); FLAG byte 0x08→0x00 after
first access (sio.c:934); sector echo, checksum, 0x47/0x4E/0xFF end
codes. This is broadly faithful to memcard.cpp and is not the focus here.
2. Discrepancies vs Beetle + psx-spx
D1 — Pad protocol is byte-framed, the hardware/Beetle is bit-clocked (model)
Beetle clocks one bit per Clock() call, 8 bits per byte
(dualshock.cpp:402-418, gamepad.cpp:143-157), with DSR pulses scheduled per
byte (frontio.cpp:756, dualshock.cpp:1077). We process a whole byte per
SIO_TX_DATA write and fake one ACK per byte. This is an acceptable abstraction
for the data exchanged (the guest only sees whole RX bytes + ACK IRQs), but it
means our ACK/IRQ timing is a hand-tuned constant (DELAY_PAD=4 accesses)
rather than baud<<scaleshift cycles (frontio.cpp:520,783). Low risk for
correctness, real risk for any timing-sensitive pad probe. Logged for the cycle
axis; not the hybrid bug.
D2 — 0x42 returns analog (8-byte) frame whenever pad_in_config is set, even with analog OFF (semantic, WRONG)
Ours: if (pad_analog || pad_in_config) → 8-byte stick frame (sio.c:775).
Beetle: 0x4200 returns the 6-byte analog payload if(analog_mode || mad_munchkins)
(dualshock.cpp:592) — i.e. analog frame in config mode is correct but see
D3 for the framing-count mismatch. The conceptual match is OK; the danger is the
byte count (D3) and that we tie stick reporting to our pad_in_config
which can be set/cleared on a different schedule than Beetle's mad_munchkins.
D3 — Config-mode poll byte counts differ from a real DualShock (semantic)
Real DualShock / Beetle digital 0x42 = ID 0x41 + 0x5A + 2 button
bytes = the device transmits 0x41,0x5A,btn0,btn1 framed as id then transmit_count=2
buttons (dualshock.cpp:604-606); analog 0x42 = id 0x73 + 0x5A + 2 btn +
4 axis bytes (dualshock.cpp:594-600). Our digital frame is 4 bytes
(id,5A,lo,hi) and analog is 8 bytes (id,5A,lo,hi,rx,ry,lx,ly) — counts agree
with hardware. But note our stick axis order is rx,ry,lx,ly
(sio.c:776-779) whereas a real pad reports right stick first only in the
config layout; the standard analog 0x42 order is RX,RY,LX,LY — this
matches. No defect here, but it is the opposite of Beetle's internal
axes[0]=left, axes[1]=right ordering, so a naive trace diff will look
"swapped" — call it out in validation so it isn't chased as a bug.
D4 — 0x43 semantics are fundamentally wrong vs a real DualShock (semantic, the core bug — see §3)
On a real DualShock NOT in config mode, command 0x43 is a poll that
also arms config entry: it returns the button/analog data frame
(dualshock.cpp:471-490 — 0x43 in command_phase==1 transmits buttons+axes,
identical framing to 0x42), and only the second data byte == 0x01 sent
during that same transaction flips mad_munchkins=true afterwards
(dualshock.cpp:503-515). Ours instead returns an all-zero 8-byte frame
[cur_id,5A,0,0,0,0,0,0] for 0x43 (sio.c:793-798). So during the
enter-config handshake we feed the driver zeros where the real pad sends live
button/stick data. A driver that reads those bytes as input (many do, since
0x43-with-data is indistinguishable from a poll on the wire) sees "all
pressed" / centered-stick garbage. This is the phantom-input mechanism.
D5 — Digital InputDevice_Gamepad does NOT implement config at all (confirms our pad_supports_config=0 path is correct)
Beetle's plain gamepad only handles 0x42; any other command sets
command_phase=-1 and transmits nothing (gamepad.cpp:202-209). Our
pad_supports_config==0 else-branch returns hi-z for config commands
(sio.c:845) — faithful. Good. The Tomba2 digital-pad fix (memory:
tomba2_menu_input_fix) is correct and matches hardware.
D6 — No analog-mode lock, no mad_munchkins gating of 0x44 (semantic, minor)
Beetle: 0x44 only takes effect inside MAD MUNCHKINS (config) mode
(dualshock.cpp:679,689-712), and 0x4402 sets analog_mode_locked
(dualshock.cpp:714-725); a locked pad ignores the physical analog button
(dualshock.cpp:203). Ours honours 0x44 set-mode (sio.c:870) but has no
lock state, so a game that locks analog (common: forces DualShock and locks)
will still see our HYBRID auto-flip yank it back to digital — exactly the kind
of "type flips underneath the game" desync the deferred-request machinery was
built to avoid but cannot prevent, because we never learned the pad is locked.
D7 — 0x4D rumble-map is a canned constant, not an echo-back map (semantic)
Beetle's 0x4D echoes the previous rumble_magic[] while latching the new
mapping bytes (dualshock.cpp:993-1024); the map then routes 0x42 weak/strong
rumble params (dualshock.cpp:617-644). Ours returns a fixed
{0xF3,0x5A,0,0,0,0,0,0} (sio.c:812/830) and implements no rumble at
all. Acceptable (no rumble output device), but a game that reads back the
0x4D map to confirm rumble init will get the wrong echo and may decide rumble
is absent. Low player-visible impact (no rumble hardware anyway).
D8 — 0x45 analog-status byte is hard-coded 0x03 (analog on) (semantic)
Beetle 0x4501 reports transmit_buffer[1] = analog_mode ? 0x01 : 0x00
(dualshock.cpp:743) — i.e. the current analog state. Ours always answers
{0xF3,0x5A,0x03,0x02,0x01,0x02,0x01,0x00} (sio.c:808/831), reporting analog
ON unconditionally. A driver polling 0x45 to discover the live mode reads
"analog" even when we are presenting digital → mode-confusion, another route to
the hybrid desync.
D9 — Config enter/exit latches on the wrong byte index (semantic, subtle)
Beetle enters MAD MUNCHKINS only when, during the 0x43 transaction,
transmit_pos==2 && receive_buffer==0x01 (dualshock.cpp:503) — i.e. the
third byte of the transaction (idx 2, the byte after id+0x5A). We latch
pad_in_config when pad_current_cmd==0x43 && pad_response_idx==2
(sio.c:862). Because our pad_response_idx starts at 1 after the cmd byte and
increments per response byte, idx 2 corresponds to the 3rd exchanged byte —
which does line up. No defect, but it is fragile and undocumented relative to
Beetle's transmit_pos convention; verify with a trace before trusting it.
D10 — DTR/SELECT-deassert does not run the analog-button evaluation (model)
On real hardware the analog toggle is evaluated when DTR goes inactive
(dualshock.cpp:383-388, CheckManualAnaModeChange). We have no DTR-edge hook
into pad type; our type change is host-driven via pad_type_req. This is by
design (the host emulates the analog button) but means our "analog button" can
fire at a different bus phase than hardware would, which is the seam the
phantom bug lives in.
3. HYBRID PAD BUG — root-cause analysis (priority)
Symptom: in HYBRID mode the pad's reported type flips between digital
(0x41) and analog (0x73) as the player moves between d-pad and stick; under
the modern config SM this produces phantom "all-pressed"/disconnect input in
Tomba (and a phantom dash in MMX6). Tomba is shipped on the legacy flag
(g_pad_legacy_cfg=1) to dodge it; that flag is explicitly marked LEGACY/
remove-me (sio.c:665-717).
Root cause — there are two compounding faults, both in 0x43 handling:
(a) 0x43 returns zeros instead of the live data frame (D4 — the real bug)
A real DualShock treats 0x43 exactly like 0x42 for the bytes it sends
— it transmits the current button + (in analog mode) axis frame
(dualshock.cpp:471-490) — and merely additionally uses the host's 0x01/0x00
data byte to toggle config on/off after the transaction
(dualshock.cpp:503-515). Tomba's libpad, like most, issues the config
enter/poll with 0x43 and reads the returned frame as controller state.
Our 0x43 returns [id,0x5A,0,0,0,0,0,0] (sio.c:793). Active-low PSX buttons:
0x00 = all buttons pressed, sticks 0x00 = hard up-left. So every frame
the driver runs a 0x43 (which it does to manage analog mode) it reads a
phantom "everything pressed." That is the phantom input, independent of any type
flip. The legacy flag hides it only because Tomba's legacy driver path happens
not to consume the 0x43 frame the same way — it is luck, not correctness.
(b) The type flip changes the ID byte across a transaction boundary the game treats as continuous (D8/D10)
Even with (a) fixed, our HYBRID flip changes pad_analog (hence the reported
id 0x41↔0x73 and the 4↔8 byte frame length) at an idle boundary
(sio.c:733). libpad's findpad caches the pad type; when the id changes it
re-runs detection, and the modern SM manufactures a one-frame
"id changed / re-detect" that the game reads as unplug→replug (memory:
tomba_phantom_input_v050). Beetle never has this problem because the type only
changes via the physical analog button evaluated at DTR-inactive
(dualshock.cpp:171-213), and crucially Beetle's 0x45 reports the live
analog state (dualshock.cpp:743) and 0x44 can lock it — so a well-written
driver always knows the current mode and never mis-reads the frame length. Our
0x45 lies (always 0x03/analog-on, D8), so the driver's cached mode and the
actual frame length we send can disagree → it parses an 8-byte analog frame as a
4-byte digital one (or vice-versa), shifting every subsequent byte → garbage
buttons.
Why the per-poll flip diverges from a real DualShock: a real pad's type is
stable within and across polls and only changes on a deliberate analog-button
press (debounced, DTR-gated) or an explicit 0x44 from the game; the game is
always told the truth by 0x45. Our hybrid mode flips the type from an
external signal (host stick/d-pad heuristic) on a schedule the game's pad
driver never sanctioned, and we then mis-report the current mode (0x45) and
send zero frames for 0x43 so the driver cannot recover. The deferred
pad_type_req machinery (sio.c:642) correctly prevents a flip mid-transaction,
but it cannot fix that the content of the 0x43 frame is wrong and that 0x45
lies — those are the actual desync sources.
Bottom line: The hybrid bug is not primarily a "flip races the
handshake" timing bug (that part is already mitigated). It is that our 0x43
sends a zero data frame instead of the live poll frame (§3a / D4) and our
0x45 reports a fixed analog-on status instead of the live mode (§3b / D8).
Fix those two to match dualshock.cpp and the HYBRID flip becomes benign for
every title — which is exactly what the LEGACY comment predicted, letting the
g_pad_legacy_cfg opt-in be deleted.
4. Prioritized fix list (player-visible impact first)
STATUS 2026-07-26: Fixes 1, 2, 3 are implemented. Fix 4 is also complete: Tomba no longer uses Hybrid, and the legacy pad-config branch has been removed.
Prior status 2026-06-27 (branch wt/tomba2-axis5-controller): Fixes 1, 2, 3 IMPLEMENTED in sio.c (0x43 live frame; 0x45 live analog byte; 0x44 analog-mode-lock + hybrid-flip lock gate), transcribed from dualshock.cpp. Validated no-regression on Tomba 2 (digital pad, pad 0xFFFF at rest, boots). These are gated to the MODERN SM and leave Tomba 1's legacy path untouched, so they're safe to ship; MMX6 (modern SM) gains the phantom-dash fix. Fix 4 (retire
g_pad_legacy_cfg) is GATED on a behavioral DualShock-game validation (MMX6 phantom-dash gone / Tomba 1 benign hybrid on the modern SM) — that needs a Tomba 1/MMX6 build against THIS framework worktree (a full regen, since the runtime ABI now carries the cycle-axis CPUState fields), so it's a separate validated pass, not a blind delete. Fixes 5/6 not yet done.
-
[P0] Make
0x43transmit the live poll frame, not zeros (D4 / §3a). Inpad_process_byte0x43branch (sio.c:787), populatepad_response[2..]with the same button (+axis when analog) bytes as the0x42branch instead of0x00. Mirrordualshock.cpp:471-490. This is the single highest-impact fix: removes the phantom "all-pressed" at its source and is the prerequisite for retiringg_pad_legacy_cfg. -
[P0] Make
0x45report the live analog state (D8 / §3b). Setpad_response[3] = pad_analog[slot] ? 0x01 : 0x00in both the modern and legacy0x45answers (sio.c:808,831), matchingdualshock.cpp:743. Lets the driver track frame length across a hybrid flip and stops the off-by-frame-length garbage reads. -
[P1] Implement analog-mode lock (
0x44/0x4402) (D6). Trackanalog_mode_lockedper slot; when locked,sio_request_pad_type/pad_type_reqmust be ignored (the HYBRID auto-flip must not override a game-locked mode) — mirrordualshock.cpp:203,714-725. Prevents the hybrid heuristic from fighting games that pin DualShock. -
[DONE 2026-07-26] Retire
g_pad_legacy_cfg. Tomba now exposes only explicit Analog / D-Pad modes, so there is no live type flip to accommodate. The flag, legacy SIO branches, config key, debug command, helper script, and Tomba opt-in were removed. All titles use the modern DualShock state machine. -
[P2] Echo-back
0x4Drumble map (D7). Even with no rumble output, echo the previousrumble_magic[]bytes (dualshock.cpp:993-1024) so a game that confirms rumble-init reads sane values. Low visible impact. -
[P2] Move pad ACK/IRQ timing onto the cycle-paced shifter (D1). Remove the pad fast-path bypass (
sio.c:1386) so pad bytes usebaud<<scaleshifttiming like the card path and likefrontio.cpp:520,783. Cross-listed with the cycle axis. Needed for any title that times pad probes. -
[P3] Document/verify the config-latch byte index and stick-order conventions (D3, D9) so trace diffs aren't mistaken for bugs.
5. Validation method (native port 4500 vs Beetle oracle port 4382)
Tomba2 dev runtime debug port is 4500 (memory: tomba2_bringup); Beetle
oracle is the psx-beetle.exe JSON server (CLAUDE.md §16 names 4380; this task
specifies 4382 — use whichever port the running beetle binary bound). Both
expose the identical wire protocol, including pad_status (debug_server.c:5713)
and sio_trace (debug_server.c:9963), and Beetle fires a per-byte SIO trace
callback sio_trace_cb(TX, RX, Control) (frontio.cpp:772-774).
Per fix, free-run BOTH processes to the same screen (do NOT pause/step — query the always-on rings), drive the same input, then diff:
-
Fix 1 (
0x43frame):sio_traceon both. Filter to transactions whose 2nd byte is0x43. Native (current): RX =..,id,5A,00,00,... Beetle: RX =..,id,5A,<btn0^FF>,<btn1^FF>,<axes..>. After the fix the native RX bytes for0x43must equal the0x42RX bytes captured in the same state. Confirmpad_statusshows no spurious all-pressed (pad:0xFFFFat rest on both). -
Fix 2 (
0x45status): drive HYBRID, toggle stick↔d-pad, capture0x45transactions. Native byte[3] must followpad_status.slotN.analog(true→0x01, false→0x00) and equal Beetle's0x4501byte[1] in the matching analog state. -
Fix 3 (lock): issue a
0x44/0x4402lock sequence (or drive a game that does), then deflect the stick.pad_status.analogmust NOT change while locked; cross-check Beetle stays in its locked mode across the same input. -
Fix 4 (retire legacy): run Tomba in both explicit Analog and D-Pad modes on the modern SM.
pad_statusat rest =0xFFFFboth slots, no disconnect blips; menu responsive; in-game d-pad↔stick transitions produce no phantom dash/unpause. Diff a 600-bytesio_tracewindow vs Beetle for the same navigation — RX streams should match transaction-for-transaction (allowing the D3 stick-order note). -
Fix 6 (timing): compare ACK→IRQ7 spacing. Use the SIO IRQ ring (
sio_get_irq_ring,sio.c:378) for nativedelay_applied/byte_seq vs Beetle'sClockDivider/dsr_pulse_delaycadence (frontio.cpp:756,783). Native pad-byte IRQ spacing should land within thebaud<<scaleshiftwindow, not the fixed 4-access constant.
General invariant to assert on every diff: at rest, pad_status =
{pad:0xFFFF, slot0.connected:true} on BOTH; any deviation from 0xFFFF while
no key is held is a phantom-input regression and must reproduce identically (or
better) than Beetle.