Files
TeslaRel410/RIOv4_2-ANALYSIS.md
T
CydandClaude Fable 5 df5804e835 Firmware: RIOv4_2_patched_31250.bin — wedge fix + SCI retuned to 31250 baud
make_patch.py gains --baud31250: one byte beyond the wedge patch — the SCI
init operand at $D62B ($30 -> $02). BAUD $30 = /13 prescale, /1 divider
(2MHz E / 208 = 9615); $02 = /1, /4 -> 31250 exactly, 3.3x faster. The
init write also confirms the 8MHz crystal, which is why 19200/38400 are
unreachable and why 62500/125k are left alone pending an ISR cycle count.

- 24 bytes changed vs original (sha256 9f866cf3...); re-disassembly diff
  vs the classic patched image shows exactly the one operand line, and
  the classic build still reproduces 3fc8170c... (script regression-safe).
- PC side: SerialPortTransport takes an optional baudRate (default 9600,
  runtime untouched); RioSerialMonitor + --mash accept --baud 31250.
- Caveats documented in README/ANALYSIS: non-standard rate (FTDI-class
  adapters only, no 16550s); native games still speak 9600 so this chip
  is bench/RIOJoy-only; validate the wedge patch at 9600 first.

275 tests green; mash --selftest regression unchanged (FAIL/exit-1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:25:00 -05:00

10 KiB
Raw Blame History

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.pyRIOv4_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 $DA2FJMP $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.

31250-baud variant (2026-07-17) — RIOv4_2_patched_31250.bin

make_patch.py --baud31250 adds one byte to the wedge patch: the SCI init at $D62A (LDAA #$30 ; STAA $102B BAUD) becomes LDAA #$02. BAUD $30 = prescale ÷13, divider ÷1 → 2 MHz E-clock / (16·13) = 9615 ("9600"); $02 = prescale ÷1, divider ÷4 → 2 MHz / (16·4) = 31250 exactly (3.3× faster; analog exchange ~15 ms → ~4.6 ms; 96-lamp burst ~0.4 s → ~0.12 s; every collision window shrinks 3×). The BAUD write confirms the 8 MHz crystal / 2 MHz E-clock — 19200/38400 are unreachable.

sha256 9f866cf353d04906e1d3e3847b6375eaae251c987b656f68f093e61ba1bd545b, 24 bytes changed (the 23 wedge bytes + $D62B: 30→02). Re-disassembly diff vs the classic patched image shows exactly the one operand line.

Caveats: 31250 is non-standard — FTDI-class USB adapters make it exactly, classic 16550 UARTs cannot; the native games still speak 9600, so a 31250 chip is bench/RIOJoy-only until Firestorm/Red Planet get BTL4OPT-style baud patches; and the 2 MHz HC11's RX ISR has ~640 cycles/byte at this rate (fine) — do NOT be tempted to $01/62500 or $00/125 k without cycle-counting the $D630 ISR worst path first. PC side: SerialPortTransport takes a baud parameter and the monitor/mash tools accept --baud 31250. Validate the wedge patch at 9600 FIRST (one variable at a time), then A/B this chip with --mash COM1 300 --label patched-31250 --baud 31250.

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.

Instrumented harness (2026-07-17): the mash protocol is mechanized in tools/RioSerialMonitor

dotnet run --project tools/RioSerialMonitor -- --mash COM1 300 --label patched

It disables the app's own >5 s reset-recovery (a wedge must stay observable), echoes lamps on every press (lamp/reply collisions are the trigger), and logs to riomash-<label>-<stamp>.log: analog-gap histogram + longest gaps, WEDGE events with beep/banner and a self-recovered vs button-revived classification (button within 300 ms of resume = the unpatched revival signature), and the board's own RestartCount/AbandonCount/FullBufferCount before/after with the delta (7-bit wrap-aware). Exit 0 = no wedge, 1 = wedge seen. Run once with --label baseline on the original chip (control: prove the test still bites), then --label patched; the fixed-layout summary blocks diff directly. --mash --selftest runs a scripted in-memory board that fakes a button-revived wedge — use it to sanity-check the alarm before a session.

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)