# RIO v4.2 firmware — protocol wedge analysis Reverse-engineering of `RIOv4_2.bin` (Toshiba TMP68HC11, AM27C512) to find the board-side cause of the "reply path wedges under stress, button-press revives it" fault. Disassembly by `disasm_6811.py` → `RIOv4_2.disasm.asm`. **Research only** — the fix below is proposed, not yet burned or tested (no spare EPROM on hand). Validate on hardware with the `RIO_TAP` mash test before trusting. Addresses are CPU = file offset (EPROM at `$C000-$FFFF`; reset `$FFFE`→ `$C000`). RAM lives at `$20xx-$31xx`. ## How the serial protocol is structured - **SCI interrupt** (`$FFD6`→`$D630`): `JSR $D634; RTI`. `$D634` runs BOTH workers every interrupt: `JSR $D6EA` (RX) then `JSR $D887` (TX). So the transmitter is poked after every received byte, not only on TX-empty interrupts. - **RX ISR** `$D6EA`: reads SCSR/SCDR, stores the byte at `$3172`, then `LDX $292F; JMP $00,X` — dispatches through a **state-handler pointer** at `$292F`. Handlers classify bytes (`$D717`: `FE`=RESTART, `FF`=IDLE, `FC`/`FD`=game ACK/NAK, `$82`=analog request, table lookup at `$3144`), accumulate the body + checksum (`AND $7F`), and on a complete packet run the ACK/NAK decision at `$D81F`. - **TX ISR** `$D887`: if TDRE, send a pending ACK (`$316F`→`$FC`) or NAK (`$3170`→`$FD`), else dispatch through the TX state pointer `$2D3B` (`$D8C2` ring-drain → `$D90E` reply/retry machine). When idle it disarms the TX interrupt (SCCR2 `#$2C`, TIE off) at `$D918`; the enqueue routine `$D63B` re-arms it (SCCR2 `#$AC`, TIE on) at `$D664`. ## The wedge: an orphaned "reply-in-progress" latch (`$2521`) `$2521` = "an analog reply is in progress." The analog-request handler gates on it: ``` D74F CMPB #$82 ; analog request from the game D753 LDAA #$01 D755 STAA $2520 ; arm reply generation D758 TST $2521 ; already replying? D75B BNE $D77A ; YES -> D77A: CLR $2520, drop this request ``` So while `$2521` is set, **every analog request is silently dropped**. The latch is set when a reply is generated: ``` D847 JSR $C5EC ; build the analog reply D84C STAA $2521 ; reply-in-progress = 1 ``` and is cleared in only three places: power-on init (`$C0A3`), a host reset/init command handler (`$C686`), and the reply **success** teardown (`$DA00`). The success teardown is reached at `$D9C1` when the game ACKs the reply, and clears the latch — but only conditionally: ``` DA21 LDAA $2522 ; did the $87 reply byte actually start sending? DA24 CMPA #$01 DA26 BNE $DA2E ; if not, skip the clears <-- fragile DA28 CLR $2521 DA2B CLR $2522 ``` `$2521` is set the instant the reply is *generated* (`$D84C`), but `$2522` is set only once the `$87` command byte *starts transmitting* (`$D8FD`). **The leak** is the retry-exhausted give-up path, which is *separate* from the success teardown. When the game fails to ACK a reply, `$D90E`/`$D9BE` retries up to 4 times, then gives up: ``` D9D5 LDAB #$FE ; give up: send RESTART D9D7 STAB $102F ; SCDR D9DA INC $317A D9DD JMP $DA2F ; teardown -- but DA2F never touches $2521 ``` `$DA2F` resets the TX pointers and calls `$D5F2` (a debug-counter formatter that does *not* clear the latch), then returns. **`$2521` is left set forever.** From then on every `$82` analog request is dropped at `$D758` → the board is mute to analog while its RX/event path stays fully alive. ### Why a button press / new game revives it The only mid-run code that clears `$2521` is the host command handler at `$C669-$C689` (it clears `$2520`/`$2521`/`$2522` plus a raft of state). That runs for a host-level reset/init command — exactly what the game sends at game-start / on the player's opening button actions. Mid-mission button-mashing sends no such command, so the leaked latch stays stuck until the next game-start reset. This matches the field observation precisely: the board goes mute under stress and only a new-game/button resync brings analog back. ### Why mash stress triggers it Button-event traffic floods the link while the board is mid-analog-reply; the reply's ACKs collide/drop, the 4-retry budget exhausts, and the give-up path (`$DA2F`) fires — leaking the latch. Light traffic rarely exhausts the retries, so it's a stress-only fault. Two different USB adapters showed the identical stall because the defect is in the board, not the transport — consistent with this being firmware, not timing. ## Proposed fix (minimal, in-place; UNTESTED) Clear `$2521` on *every* reply teardown, not just the `$2522`-gated success path. Two edits, no code-size change, 8 KB of free ROM exists at `$DFF0-$FFBF` for the stub: 1. **Give-up path** — redirect its teardown through a stub that clears the latch first. At `$D9DD` change `JMP $DA2F` (`7E DA 2F`) → `JMP $DFF0` (`7E DF F0`), and place at `$DFF0`: ``` DFF0 7F 25 21 CLR $2521 DFF3 7F 25 22 CLR $2522 DFF6 7E DA 2F JMP $DA2F ``` 2. **Success path** — make the clear unconditional (belt-and-suspenders, covers an abort before `$87` is sent). Replace `$DA21-$DA2D` (13 bytes) in place: ``` DA21 7F 25 21 CLR $2521 DA24 7F 25 22 CLR $2522 DA27 01 01 01 01 01 01 01 (NOP x7) DA2E 39 RTS (unchanged) ``` Rationale: `$2521` means "a reply is in progress"; any path that tears down reply state must release it. There is no case where you reset the reply machine yet want the latch to stay set, so unconditional clearing is safe. This is the board-side analogue of the game-side "make collisions harmless" patches (BTL4OPT v2-v4) — instead of widening a timing window it removes the latch leak entirely. ### Patched binary — built & statically verified (2026-07-04) `make_patch.py` applies both edits to `RIOv4_2.bin` (asserting the exact original bytes at each site first) → **`RIOv4_2_patched.bin`** (sha256 `3fc8170caf60e2580641724ff995176c93c4f2e706f31487beded8233142493f`, 23 bytes changed). Re-disassembling it (`RIOv4_2_patched.disasm.asm`) and diffing against the original confirms the change is confined to exactly three regions with no downstream desync: - `$D9DD` `JMP $DA2F` → `JMP $DFF0` - `$DFF0` new stub: `CLR $2521 ; CLR $2522 ; JMP $DA2F` - `$DA21` `CLR $2521 ; CLR $2522 ; NOP×7` (RTS at `$DA2E` intact) Flash `RIOv4_2_patched.bin` directly to the W27C512 (DIP-28). This is static verification only; dynamic proof still needs the burned chip. ### Validation plan (when a chip is available) Burn the two edits to a W27C512, socket it (preserve the original AMD chip), then run the `RIO_TAP` two-handed 8-button mash test. Expect: no permanent analog mute; any collision self-recovers without a game-start reset. Compare dropout counts to the 2026-07-03/04 baseline taps. ## Firmware memory map (as decoded so far) | addr | meaning | |---|---| | `$292F` | RX state-handler pointer (`JMP $00,X` dispatch) | | `$2D3B` | TX state-handler pointer | | `$2D34/$36/$38` | TX ring read/write/aux pointers (ring `$2932-$2D31`) | | `$2520` | reply gate (analog request pending) | | `$2521` | **reply-in-progress latch — the wedge** | | `$2522` | `$87` analog-reply-byte-sent flag | | `$316C/$6D` | game ACK / NAK received | | `$316E` | unknown-command seen | | `$316F/$70` | ACK / NAK pending to send (→ TX ISR) | | `$3172` | last received byte | | `$3173/$74/$75` | ACK/NAK/wait retry counters (limit 4) | | `$317A/$7B` | RESTART / IDLE keep-alive counters | | `$3184/$85` | give-up / error diagnostic counters | | `$3186` | RX overrun flag (set at `$D701`, **never read** — not the cause) | | `$102D/$2E/$2F` | SCCR2 / SCSR / SCDR (HC11 SCI) |