# 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_cfg` protocol 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..0x1F80104E` handled in `sio_read`/`sio_write` (`sio.c:1306`, `sio.c:1361`). A byte is "transferred" when the guest writes `SIO_TX_DATA` (`0x1F801040`) with `TX_EN` set; we process it synchronously via `sio_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_countdown` decrements on every SIO register access and on `sio_tick`; fires IRQ7 + sets `STAT.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`) modelling `SIO_BAUD_CYCLES_DEFAULT=1088` shift + `170`-cycle ACK. *However* the **pad fast-path** (`sio.c:1386`) bypasses the shifter entirely: any pad byte (`active_device==DEV_PAD`, or `bus_owner==PAD`, or `tx==0x01`) is processed immediately and uses the access-paced `SIO_IRQ_DELAY_PAD` countdown. So in practice **pad timing is still access-paced, not cycle-paced.** - `active_device` (`NONE/PAD/MEMCARD`, `sio.c:173`) routes a byte to `pad_process_byte` or `mc_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 id `0x41`, 1=analog id `0x73`), `pad_stick[2][4]`, `pad_connected`. - `pad_in_config[2]` — DualShock config-mode latch (report `0xF3`). - `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 at `PAD_IDLE && !in_config` (`sio.c:100`, applied `sio.c:733`). - `g_pad_legacy_cfg` — per-game flag (Tomba only). When set, `0x43` always answers `0xF3` and the `0x45/0x46/0x47/0x4C/0x4D` config 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`): - `0x42` poll: emits `[id, 0x5A, btnlo, btnhi]` (4 bytes), or 8 bytes with the four stick axes when `pad_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 while `pad_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< **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. 1. **[P0] Make `0x43` transmit the live poll frame, not zeros** (D4 / §3a). In `pad_process_byte` `0x43` branch (`sio.c:787`), populate `pad_response[2..]` with the same button (+axis when analog) bytes as the `0x42` branch instead of `0x00`. Mirror `dualshock.cpp:471-490`. This is the single highest-impact fix: removes the phantom "all-pressed" at its source and is the prerequisite for retiring `g_pad_legacy_cfg`. 2. **[P0] Make `0x45` report the live analog state** (D8 / §3b). Set `pad_response[3] = pad_analog[slot] ? 0x01 : 0x00` in both the modern and legacy `0x45` answers (`sio.c:808`,`831`), matching `dualshock.cpp:743`. Lets the driver track frame length across a hybrid flip and stops the off-by-frame-length garbage reads. 3. **[P1] Implement analog-mode lock (`0x44`/`0x4402`)** (D6). Track `analog_mode_locked` per slot; when locked, `sio_request_pad_type` / `pad_type_req` must be ignored (the HYBRID auto-flip must not override a game-locked mode) — mirror `dualshock.cpp:203,714-725`. Prevents the hybrid heuristic from fighting games that pin DualShock. 4. **[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. 5. **[DONE 2026-08-06] Echo-back `0x4D` rumble map and forward motors.** The SIO model now routes negotiated weak/strong values to the SDL frontend. 6. **[P2] Move pad ACK/IRQ timing onto the cycle-paced shifter** (D1). Remove the pad fast-path bypass (`sio.c:1386`) so pad bytes use `baud<,,`. After the fix the native RX bytes for `0x43` must equal the `0x42` RX bytes captured in the same state. Confirm `pad_status` shows no spurious all-pressed (`pad:0xFFFF` at rest on both). - **Fix 2 (`0x45` status):** drive HYBRID, toggle stick↔d-pad, capture `0x45` transactions. Native byte[3] must follow `pad_status.slotN.analog` (true→0x01, false→0x00) and equal Beetle's `0x4501` byte[1] in the matching analog state. - **Fix 3 (lock):** issue a `0x44`/`0x4402` lock sequence (or drive a game that does), then deflect the stick. `pad_status.analog` must 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_status` at rest = `0xFFFF` both slots, no disconnect blips; menu responsive; in-game d-pad↔stick transitions produce no phantom dash/unpause. Diff a 600-byte `sio_trace` window 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 native `delay_applied`/byte_seq vs Beetle's `ClockDivider`/`dsr_pulse_delay` cadence (`frontio.cpp:756,783`). Native pad-byte IRQ spacing should land within the `baud<