Files
psxrecomp/accuracy/axis5_sio_controller.md
T
2026-08-06 22:36:15 -07:00

348 lines
20 KiB
Markdown

# 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<<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 negotiation and host output** (resolved)
Resolved: `0x4D` now echoes the previous six-byte motor map while latching the
new map. Mapped motor bytes from later `0x42` polls are retained per slot and
forwarded to SDL's low/high-frequency rumble channels by the frontend. The
protocol path is pinned by `sio_dualshock_rumble_test`; legacy savestates remain
loadable and receive the conventional `{0x00,0x01,0xFF,...}` map with stopped
motors.
### 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.
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<<scaleshift` timing like the card path and like `frontio.cpp:520,783`.
Cross-listed with the cycle axis. Needed for any title that times pad probes.
7. **[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 (`0x43` frame):** `sio_trace` on both. Filter to transactions whose
2nd byte is `0x43`. **Native (current):** RX = `..,id,5A,00,00,..`.
**Beetle:** RX = `..,id,5A,<btn0^FF>,<btn1^FF>,<axes..>`. 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<<scaleshift` window,
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.