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>
218 lines
10 KiB
Markdown
218 lines
10 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.
|
||
|
||
### 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) |
|