# RIO cockpit controls — passthrough tuning (Phase 5+) The RIO (Remote Input/Output) is the cockpit control board on **COM1** (the plasma display is COM2, handled later). DOSBox-X talks to a real RIO through `serial1=directserial realport:COM1` (`game_rio.conf`). On this host the RIO is a **Prolific USB-to-Serial adapter enumerated as COM1**. ## The analog-poll latency problem (solved) The initial RIO *check* request tolerates latency and passed easily. But the runtime control loop ([L4CTRL.CPP:1145](../CODE/RP/MUNGA_L4/L4CTRL.CPP)) sends an **analog request** every cycle and, if the reply doesn't return inside its window, logs `LBE4ControlsManager::Execute, lost RIO analog request` and re-requests. The board itself refuses/drops comms if the ACK is late by more than a few milliseconds — a hard real-time deadline. Empirical result (2026-07-03): a **slower** CPU made the RIO fail **sooner**. So the dominant latency was how fast the game processes the RIO packet and emits the ACK, not the serial wiring. The fix: ``` [cpu] core=dynamic ; recompiler — many x faster than the 'normal' interpreter cputype=pentium cycles=max ; full host speed ``` With `core=dynamic + cycles=max` the RIO **stays in sync** (the user confirmed "the rio behaved"). Notes: - `cycles=fixed 20000` / `150000` and `core=normal` were all too slow — the RIO dropped comms, faster at lower speeds. - The DOSBox-X serial path is already low-latency on **transmit** (`directserial.cpp` `transmitByte` calls `SERIAL_sendchar` immediately). - Prolific PL2303 has no adjustable `LatencyTimer` registry value (unlike FTDI); an FTDI adapter set to 1 ms latency would be the lowest-latency host option if ever needed. ## Receive-latency fork options (2026-07-03) Even with the fast CPU, intermittent timeouts remained and the game would drop into its **15-second analog retry fallback** — the source shows `limit = 15.0; // 0.2` in L4CTRL.CPP, so the shipped binary re-requests very slowly once replies stop arriving ("the polling is really slow"). Two emulator receive latencies were fixed with new `directserial` options: - **`rxpollus:`** (50–1000, stock 1000): host-port receive poll tick. Stock DOSBox-X discovers inbound bytes on a 1 ms tick; 100 µs discovers them ~10× sooner. First validated result: the sim advanced and the camera moved in the render window. - **`rxburst:`** (1–64): stock DOSBox re-serializes each received byte at emulated wire speed (~1 ms/byte at 9600) even though the bytes already paid their wire time on the physical cable and sit in the host buffer — a 15-byte analog reply gained ~14 ms of artificial latency, single-handedly blowing the RIO's few-ms ACK window. `rxburst:16` delivers buffered bytes 16× faster. `game_rio.conf` uses `realport:COM1 rxpollus:100 rxburst:16`. ## The crash-to-desktop: PCSPAK's DISABLE_AND_DIE (patched) The recurring hard fault (`Exception 0E`, write to `0xFFFFFFFF`-ish, e.g. at `BTL4OPT CODE+0x7D1D1` with `EAX=3`) is **deliberate**: the RIO serial packet driver (`CODE/RP/MUNGA_L4/PCSPAK.ASM`) was shipped built with `DIE_ON_ERROR equ 1`, which compiles a `DISABLE_AND_DIE ` debugging macro at 12 error sites — it retracts the UART IRQ, EOIs the PIC, and then "crashes loudly" by writing the error code to address `0xFFFFFFFF`. Our crash is error **3** (`PCSPAK.ASM:1630`): a byte ≥ 0x80 found in the TX ring body (the protocol reserves high-bit bytes for commands). It gets hit via the ACK/NAK-interrupt → restart path — exactly what physical RIO resets and timeout storms exercise. On clean pod serial timing this never fired; on a USB-serial rig with resets it does. The source's release configuration (`DIE_ON_ERROR equ 0`) makes the macro empty and the code **recovers** (the next instruction masks the byte with `and al,7Fh` and continues). We reproduce that intended behavior by patching all 12 die sequences (`50 52 BA FF FF FF FF B8 xx 00 00 00 89 02` → NOPs) in the working image's `BTL4OPT.EXE`. Original preserved as `BTL4OPT.EXE.orig`. Error-code map (from PCSPAK.ASM): 0/1/2 rx framing states, 3 tx body >0x7F, 4/5 tx state. **Patch v2 (2026-07-03): the first patch was incomplete and wedged the driver.** The macro's *prologue* also executes before the crash write: it retracts UART `MCR` bits `IRQ+RTS` (`RETRACT_MCR`) and EOIs the master PIC — 23 bytes (`66 8B 15 66 83 C2 04 EC 24 F5 EE 66 83 EA 04 B0 20 E6 20`) that a release build would not compile at all. With only the crash write NOPed, the first PCSPAK protocol error silently killed the UART IRQ and dropped RTS: the game went deaf/mute while the RIO kept retransmitting into the void (captured on the wire tap at t≈112s — board streaming `89 00 09 FF FF FF FF FE` forever, game emitting one byte per 15s retry). Fixed by NOPing the full 37-byte macro expansion at all 12 sites; the state before this extension is preserved as `BTL4OPT.EXE.nop14`. After patch v2 the driver recovers from every error, exactly like `DIE_ON_ERROR equ 0`. ## Serial wire tap (`RIO_TAP`, 2026-07-03) The fork's `directserial.cpp` logs every serial byte when the host env `RIO_TAP=` is set: ` T|R ` plus `#` lines for port config, RTS/DTR, and break changes. This is the instrument that proved all of the above. Findings from tapped runs (9600 8N1): - Analog request = `82 02 FF FF FF FF FE`; ACK = `FC`; packets are ` … FE`-framed. The shipped 15s retry shows up as exactly 15.02s between request storms. - **Without `rxburst`**: reply bytes reach the game at ~1ms/byte (wire-speed re-serialization), so the first long analog stream makes the game's ACK ~14ms late → board drops comms permanently (captured at t≈45.6s). With `rxburst:16` the same phase streams for minutes. The earlier "rxburst corrupts the boot handshake" belief was wrong — that corruption was the unpatched DISABLE_AND_DIE error-3 crash. Both confs now use `rxpollus:100 rxburst:16`. - **Focus loss caused the self-recovering dropouts.** A 4-minute run while the user multitasked showed ~25 self-recovering board-quiet windows (1.5–15s); an identical hands-off run showed **zero** gaps after boot (195s clean). DOSBox-X's default `[sdl] priority = higher,normal` demotes the process to NORMAL class when unfocused. Both gauge confs now set `priority=highest,highest` (HIGH_PRIORITY_CLASS; user saw one dropout at `higher,higher`). (The ~71–86ms "turnaround tail" in the tap analysis was benign — bursts that need no ACK.) ## Button-press livelock (2026-07-03, open) At `highest,highest` the link ran clean for 120s, then the user pressed a RIO button and the link died permanently (until board reset). Tap decode of the packet protocol (PCSPAK.ASM equates: `FC`=ACK, `FD`=NAK, `FE`=RESTART, `FF`=IDLE, cmd bytes 0x80-0xFB, packet = cmd + body + 7-bit checksum): - Steady state = game `82 02` analog request → board `87 <12 analog> 07` reply → game `FC` — at ~10Hz, clean for minutes. - At t=123.65s the board truncated its in-flight analog reply exactly when the button was pressed, then went ~3s silent, then began retransmitting the button event `88 03 0B` every ~10ms forever. The game ACKs (`FC`) every copy AND storms its own `82 02` retransmits (RESTART-paced: packet + `FF`×4 idle + `FE` restart, back-to-back). Neither side ever accepts: **livelock**. - Mechanism (PCSPAK.ASM `txBodyState`/`txWaitState`, and the board firmware is the same protocol): an ACK is honored only during the ~4ms post-checksum idle window (`TXMAXIDLE=4` idle chars); an ACK arriving during the peer's transmit phase = `TX_EARLY_ERR` → restart+retransmit. With both sides' timing driven by each other's bytes, the phases lock and the ACKs land in the wrong window forever. Retry budget `TXMAXRESET=3`/ `TXMAXERROR=3` per packet, but fresh packets keep the storm alive. - Why the pod never saw this: ISA-UART ACK latency is sub-ms, so the ACK always lands at the START of the 4ms window. Our Prolific USB-serial adds 1-10ms each way (invisible to the tap, which stamps host-side I/O), so a collision can push the exchange into the locked phase. Tried, in order: - Prolific FIFO disabled in Device Manager: **no effect** — button mash still livelocked the link at t≈110s. - **TXMAXIDLE binary patch (v3, 2026-07-03): FIXED the livelock.** The two `mov txIdleCount[esi],TXMAXIDLE` reloads (`C6 46 0B 04`, txIdleCount = struct offset 0x0B) at exe offsets 0x7dff0/0x7e093 changed `04`→`20` (4 → 32 idle chars ≈ 33ms). This widens the game's ACK-accept window 8x and calms its retransmit storm so its own ACKs to the board transmit promptly. Backup: `BTL4OPT.EXE.pre_idle`. Result of a 5-minute button-mash run: three board-quiet gaps (1.9s/9.3s/14.6s), **all self-recovered**; clean steady state otherwise. Previously one button press = permanent livelock until manual board reset. **Recovery-time patch (v4, 2026-07-04).** Collision recovery rode L4CTRL's analog re-request fallback: `limit = 15.0; // 0.2` (L4CTRL.CPP:1132 — the dev value was 0.2s; 15s looks like a forgotten debug slowdown). In the binary this is two `mov dword [ebp-24h], 41700000h` (15.0f) immediates — one per branch of the `RunningMission` if — at file offsets 0x763fa/0x76403, anchored by the `fld/fcomp` of delta_t and the "lost RIO analog request" string push at 0x76441 directly after. Both immediates changed to `3F000000h` (0.5f). Backup: `BTL4OPT.EXE.pre_limit`. **Validated 2026-07-04**: 5-minute two-handed button-mash run — forced dropouts now last 1.2s/2.9s (vs 9.3s/14.6s under the 15s limit), max turnaround 199ms (vs 11.8s), and most collisions no longer register as >1s gaps at all. User: "after one more button is pressed and the .5 seconds elapses it picks right back up." BTL4OPT.EXE patch lineage: `.orig` (pristine) → `.nop14` (v1: crash writes NOPed) → `.pre_idle` (v2: full 37-byte DISABLE_AND_DIE NOPs) → `.pre_limit` (v3: TXMAXIDLE 4→32) → current (v4: retry limit 15s→0.5s). ## Board firmware patch plan (user wants to pursue) Goal: fix the livelock's other half at the root — the board rejects ACKs arriving outside its ~4ms post-checksum window (its firmware mirrors the PCSPAK state machine, including the early-ACK=error behavior). The game-side patches make failures self-healing; a board patch would make collisions harmless entirely. 1. **Identify**: open the RIO board, photograph it, note the MCU part number and the ROM chip. Era suggests an 8051-family MCU (80C31/32 with external 27C256/27C512 EPROM) or 68HC11/Z80-class part. The observed wire behavior (9600 8N1, ~10ms retransmit cadence = 3-byte packet + ~4 idle chars + restart) confirms a TXMAXIDLE≈4-equivalent constant in firmware. 2. **Dump**: any TL866-class programmer reads the EPROM to a .bin. If the code is in MCU-internal ROM instead, dumping gets harder (part-specific tricks) — check the board first. 3. **Disassemble** (Claude's job): locate the protocol state machine by searching for 0xFC/0xFD/0xFE/0xFF handling, the idle-counter reload value 4, and checksum `AND 7Fh` operations — we know the protocol byte-for-byte from the game side, so this is pattern matching. 4. **Patch** (preference order): (a) accept ACK in any TX state — delete the early-ACK=error path; (b) widen the idle window 4→32 like the game-side v3 patch; (c) raise the retry budget. 5. **Burn to a NEW EPROM**, socket it, label and store the original chip untouched (preservation first). 6. **Validate** with the RIO_TAP button-mash protocol; compare dropout counts against the 2026-07-03/04 baseline captures in the session scratchpad (riotap_*.txt). No firmware source or image exists in the archive (searched sda4 + CODE for *.HEX/*.A51/*.S19/*.ROM and for the protocol constant names — only PCSPAK.ASM, the game side, matches). **UPDATE 2026-07-04: steps 1-3 DONE — ROOT CAUSE FOUND.** Full writeup: `emulator/rio-firmware/RIOv4_2-ANALYSIS.md` (disassembly via `disasm_6811.py` -> `RIOv4_2.disasm.asm`). The wedge = an orphaned "reply-in-progress" latch `$2521`: it gates every analog request (`$D758 TST $2521; BNE drop`), is set when a reply is generated (`$D84C`), but the retry-exhausted give-up path (`$D9DD JMP $DA2F`) tears down reply state WITHOUT clearing it (and the success teardown `$DA00` clears it only conditionally on `$2522`). Leaked -> all analog requests dropped -> mute; RX/event path stays alive; only a game-start host reset command (`$C686` clears `$2521`) revives it = the observed button-resync ritual. Proposed fix (untested, no spare chip): clear `$2521` on every teardown (redirect `$D9DD` to a free-space stub at `$DFF0` clearing `$2521/$2522`; make `$DA00`'s clear unconditional). Byte patches + validation plan in the doc. Original first-look (MCU=Toshiba TMP68HC11, EPROM=AM27C512, code $C000-$FFFF, reset $C000, SCI vector $D630) in `emulator/rio-firmware/README.md` - reset vector $C000, **SCI serial interrupt vector → $D630** (the protocol state machine's entry point for step 3's disassembly). Also new evidence for the wedge shape: a button press revives a mute board (event path alive, reply path dead), so step 4 should look for a reply-path-only dead state reachable from the SCI handler. ## Crash-on-advance fixed: arena terrain shadows With the RIO in sync the sim advances and the game crashed dereferencing the **mech terrain shadow** renderable: `*_TSHD.BGF` live only in `VIDEO/GEO/ARENA/` + `…/POLAR/`, which the object search path misses, so `thr_tshd.bgf` failed to load and left a null renderable that the moving sim eventually touched. Fix: the 11 `*_TSHD.BGF` files are copied from `ARENA/` into `VIDEO/GEO/` in the working image (reversible, one file each). After the fix the game runs sustained (500+ frames, no crash). ## Next wall: mission content (a game-data issue, not protocol/RIO) With the RIO feeding real input, the simulation advances — and then the game faults: ``` 32loader runtime error: Unhandled exception Exception 0E at 00FF:0040223B Module 'BTL4OPT.EXE' section 'CODE' offset 0000123B [..] 8B 4D 0C (mov ecx,[ebp+0C]) 8B 11 (mov edx,[ecx]) ECX=2 The instruction referenced illegal address 00000002 ``` A near-null pointer (`2`) dereference: a function got `2` where an object pointer was expected. This is downstream of the failed content load logged just before it: ``` Entity -1:63 class:42 couldn't figure out how to MakeEntityRenderables L4VIDEO.cpp couldn't load object thr_tshd.bgf ``` `thr_tshd.bgf` exists in `VIDEO/GEO/ARENA/` and `VIDEO/GEO/POLAR/`, but not the top `VIDEO/GEO/`. The game builds its object search path from **`objectpath` entries in the mission notation file** ([L4VIDEO.CPP:1852](../CODE/RP/MUNGA_L4/L4VIDEO.CPP)), i.e. from the `.egg`. The shipped `test.egg` is only mission parameters and does not set up the arena object path, so arena-specific objects aren't found; the entity gets a broken renderable and the sim eventually dereferences it. **To progress past this needs a real mission/arena `.egg`** (or an object path manually pointed at the selected arena's `VIDEO/GEO/` subdir). That is content/mission configuration, separate from the VPX protocol, the renderer, and the RIO — all of which now work. ## Board init handshake — what a virtual RIO must implement (2026-07-06) The user's **vRIO** (virtual RIO on COM1) passed analog polling but the game still printed its init complaint. Root cause decoded from [L4RIO.CPP:901-975](../CODE/RP/MUNGA_L4/L4RIO.CPP) + [L4RIO.HPP:138-152](../CODE/RP/MUNGA_L4/L4RIO.HPP) and verified byte-for-byte against a real-board tap (riotap_arena.txt, fw v4.2): Command map (packet = cmd + body + 7-bit checksum `sum&0x7F`; FC=ACK FD=NAK FE=RESTART FF=IDLE): game→board `80` CheckRequest, `81` VersionRequest, `82` AnalogRequest, `83` ResetRequest, `84` LampRequest; board→game `85` CheckReply(status,unit; status: 0=BoardOk 1=BoardMissing 2=BoardBad 3=LampBad 4/5/6=Restart/Abandon/FullBuffer counts), `86` VersionReply(maj,min), `87` AnalogReply(10 data bytes), `88`/`89` Button press/release, `8A`/`8B` Key press/release, `8C` TestModeChange(1=enter 0=exit). Init sequence (game side bombs with "RIO never came back from check request / test mode / version request!" if any step times out at 5s): 1. game pulses DTR (assert 0.1s, drop, wait 1s) = hardware board reset 2. game `80 00` → board `FC` 3. **board `8C 01 0D` (TestModeChange ENTER)** ← the packet vRIO was missing; game waits ≤5s for it, sets TestModeActive 4. board self-test (~0.6s on the real board), streaming `85 ` per subsystem (these are swallowed during test mode, not host events; the bench board reports status=1 BoardMissing for units 08,10,11,18,19,1A,30) 5. **board `8C 00 0C` (TestModeChange EXIT)** — game waits ≤5s 6. game `81 01` → board `FC`, then **`86 04 02 0C` (VersionReply v4.2)**; game loops until MajorRevision != 0xFF 7. normal ops: `82 02` analog polls / button+key events. NOTE: the game sends requests ONLY when `operational && !TestModeActive` — a board that enters test mode and never exits mutes the game permanently. Every board packet is retransmitted until the game ACKs `FC` (within the TXMAXIDLE window; ~33ms with the patched EXE). Byte pacing at the 9600-baud wire rate (~1ms/byte) matters: vRIO blasting bytes back-to-back caused NAK/ restart churn during init until the user added pacing.