- RIOv4_2.bin: 64K image dumped from the board's AM27C512 (code at $C000-$FFFF, TMP68HC11). - disasm_6811.py + RIOv4_2.disasm.asm: vector-rooted 68HC11 disassembly; SCI ISR at $D630 traced to the $2521 reply-in-progress latch leak that wedges the analog reply path under button-mash stress. - make_patch.py + RIOv4_2_patched.bin: two in-place edits (abort-path stub at $DFF0, unconditional latch clear at $DA21) statically verified by re-disassembly diff. Dynamic proof awaits a burned W27C512. - Analysis + burn/validation plan in RIOv4_2-ANALYSIS.md and README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
7.5 KiB
Markdown
176 lines
7.5 KiB
Markdown
# 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) |
|