RIOv4_2.bin dumped from our own board EPROM (64KB, code $C000-$FFFF). Vector table confirms 68HC11: RESET->$C000, SCI serial interrupt->$D630 = the protocol state machine entry for the planned disassembly. Board patch plan steps 1-2 done (RIO-NOTES.md updated); next: disassemble from the SCI handler, find the reply-path wedge (button/event path survives it), patch, burn to a fresh EPROM, preserve the original. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
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) 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/150000andcore=normalwere all too slow — the RIO dropped comms, faster at lower speeds.- The DOSBox-X serial path is already low-latency on transmit
(
directserial.cpptransmitBytecallsSERIAL_sendcharimmediately). - Prolific PL2303 has no adjustable
LatencyTimerregistry 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:<us>(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:<n>(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:16delivers 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 <code> 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 <addr> 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=<path> is set: <host-us relative> <emu-ms> T|R <hex> 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<cmd≥0x80> … 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). Withrxburst:16the 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 userxpollus: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,normaldemotes the process to NORMAL class when unfocused. Both gauge confs now setpriority=highest,highest(HIGH_PRIORITY_CLASS; user saw one dropout athigher,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 02analog request → board87 <12 analog> 07reply → gameFC— 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 0Bevery ~10ms forever. The game ACKs (FC) every copy AND storms its own82 02retransmits (RESTART-paced: packet +FF×4 idle +FErestart, 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=4idle 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 budgetTXMAXRESET=3/TXMAXERROR=3per 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],TXMAXIDLEreloads (C6 46 0B 04, txIdleCount = struct offset 0x0B) at exe offsets 0x7dff0/0x7e093 changed04→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.
- 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.
- 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.
- 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 7Fhoperations — we know the protocol byte-for-byte from the game side, so this is pattern matching. - 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.
- Burn to a NEW EPROM, socket it, label and store the original chip untouched (preservation first).
- 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-2 DONE. The board's MCU is a Toshiba
TMP68HC11 (read off the chip) and the user dumped the EPROM:
emulator/rio-firmware/RIOv4_2.bin (v4.2, 64KB image, code in
$C000-$FFFF). First-look analysis 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),
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/<ARENA> subdir). That is
content/mission configuration, separate from the VPX protocol, the renderer,
and the RIO — all of which now work.