RIO working: serial wire tap, conf tuning, four-patch writeup

The RIO cockpit board now runs sustained sessions with button mashing;
dropouts self-heal in ~1-3s. Documented in RIO-NOTES.md:

- directserial RIO_TAP=<path> (host env): logs every TX/RX byte with
  host-us + emu-ms timestamps, plus config/RTS/DTR/break lines. This
  instrument found every root cause below.
- Confs: rxburst:16 restored (no-burst reply pacing made the game ACK
  ~14ms late -> board dropped on the first long analog stream; the old
  'rxburst corrupts boot' belief was the then-unpatched PCSPAK crash).
  priority=highest,highest (unfocused DOSBox was demoted and blew the
  ACK deadline).
- BTL4OPT.EXE patch lineage (in ALPHA_1/, zip left pristine):
  v2 full DISABLE_AND_DIE NOPs (v1 left the IRQ/RTS-retract prologue
  live -> first protocol error deafened the driver), v3 TXMAXIDLE 4->32
  (kills the button-press ACK-window livelock), v4 analog retry limit
  15s->0.5s (dev value was 0.2s; recovery now near-instant).
- Board firmware patch plan recorded for the EPROM-dump route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-04 00:22:14 -05:00
co-authored by Claude Fable 5
parent aea8b3eab4
commit 377047f37d
5 changed files with 215 additions and 3 deletions
+131
View File
@@ -79,6 +79,137 @@ 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). 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.515s); 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 ~7186ms "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).
## Crash-on-advance fixed: arena terrain shadows
With the RIO in sync the sim advances and the game crashed dereferencing the
+6 -1
View File
@@ -1,5 +1,10 @@
[sdl]
output=opengl
# keep scheduling priority when unfocused -- the default (higher,normal)
# demotes DOSBox in the background, adding latency that blows the RIO's
# few-ms ACK deadline (confirmed: hands-off run = zero dropouts).
# highest = HIGH_PRIORITY_CLASS (higher,higher still showed a dropout).
priority=highest,highest
[dosbox]
memsize=32
machine=svga_s3
@@ -10,7 +15,7 @@ cycles=max
[serial]
# RIO on COM1 with the low-latency options (rxpollus/rxburst) so the board's
# few-ms ACK deadline is met; plasma COM2 later.
serial1=directserial realport:COM1 rxpollus:100
serial1=directserial realport:COM1 rxpollus:100 rxburst:16
serial2=disabled
[autoexec]
mount c "C:\VWE\TeslaRel410\ALPHA_1"
+2 -1
View File
@@ -1,5 +1,6 @@
[sdl]
output=opengl
priority=highest,highest
[dosbox]
memsize=32
machine=svga_s3
@@ -8,7 +9,7 @@ core=dynamic
cputype=pentium
cycles=max
[serial]
serial1=directserial realport:COM1 rxpollus:100
serial1=directserial realport:COM1 rxpollus:100 rxburst:16
serial2=disabled
[autoexec]
mount c "C:\VWE\TeslaRel410\ALPHA_1"
@@ -29,9 +29,32 @@
#include "libserial.h"
#include <chrono>
/* This is a serial passthrough class. Its amazingly simple to */
/* write now that the serial ports themselves were abstracted out */
static double tap_now_us() {
using namespace std::chrono;
return duration<double, std::micro>(
steady_clock::now().time_since_epoch()).count();
}
/* One log line per byte on the wire. Host-relative microseconds tell the
* real-time story (the RIO's ACK deadline is wall clock); PIC_FullIndex()
* (emulated ms) ties each byte to game-side timing. */
void CDirectSerial::tapLine(char dir, unsigned val, unsigned err) {
if (!tap_fp) return;
double us = tap_now_us() - tap_t0_us;
if (err)
fprintf(tap_fp, "%12.1f %11.3f %c %02X err=%02X\n",
us, PIC_FullIndex(), dir, val, err);
else
fprintf(tap_fp, "%12.1f %11.3f %c %02X\n",
us, PIC_FullIndex(), dir, val);
fflush(tap_fp);
}
CDirectSerial::CDirectSerial (Bitu id, CommandLine* cmd)
:CSerial (id, cmd) {
InstallationSuccessful = false;
@@ -86,6 +109,22 @@ CDirectSerial::CDirectSerial (Bitu id, CommandLine* cmd)
(unsigned)rx_burst);
}
// RIO_TAP=<path> (host env, like the VPX_* vars): append one line per
// TX/RX byte. Appends across runs; each open writes a header line.
const char *tp = getenv("RIO_TAP");
if (tp && tp[0]) {
tap_fp = fopen(tp, "a");
if (tap_fp) {
tap_t0_us = tap_now_us();
fprintf(tap_fp, "# COM%d tap open (host-us rel | emu-ms | dir | byte)\n",
(int)COMNUMBER);
fflush(tap_fp);
LOG_MSG("Serial%d: RIO_TAP logging to '%s'", (int)COMNUMBER, tp);
} else {
LOG_MSG("Serial%d: RIO_TAP cannot open '%s'", (int)COMNUMBER, tp);
}
}
CSerial::Init_Registers();
InstallationSuccessful = true;
rx_state = D_RX_IDLE;
@@ -94,6 +133,7 @@ CDirectSerial::CDirectSerial (Bitu id, CommandLine* cmd)
CDirectSerial::~CDirectSerial () {
if(comport) SERIAL_close(comport);
if(tap_fp) fclose(tap_fp);
// We do not use own events so we don't have to clear them.
}
@@ -293,6 +333,7 @@ void CDirectSerial::handleUpperEvent(uint16_t type) {
bool CDirectSerial::doReceive() {
int value = SERIAL_getextchar(comport);
if(value) {
tapLine('R', (unsigned)(value & 0xff), (unsigned)((value >> 8) & 0xff));
receiveByteEx((uint8_t)(value&0xff),(uint8_t)((value&0xff00)>>8));
return true;
}
@@ -326,6 +367,12 @@ void CDirectSerial::updatePortConfig (uint16_t divider, uint8_t lcr) {
else stopbits = SERIAL_2STOP;
} else stopbits = SERIAL_1STOP;
if (tap_fp) {
fprintf(tap_fp, "# %12.1f %11.3f config %u baud %d%c%d\n",
tap_now_us() - tap_t0_us, PIC_FullIndex(),
(unsigned)baudrate, (int)bytelength, (char)parity, (int)stopbits);
fflush(tap_fp);
}
if(!SERIAL_setCommParameters(comport, (int)baudrate, (char)parity, (char)stopbits, (char)bytelength)) {
#if SERIAL_DEBUG
log_ser(dbg_aux,"Serial port settings not supported by host." );
@@ -346,6 +393,7 @@ void CDirectSerial::updateMSR () {
}
void CDirectSerial::transmitByte (uint8_t val, bool first) {
tapLine('T', val, 0);
if(!SERIAL_sendchar(comport, (char)val))
LOG_MSG("Serial%d: COM port error: write failed!", (int)COMNUMBER);
if(first) setEvent(SERIAL_THR_EVENT, bytetime/8);
@@ -355,20 +403,41 @@ void CDirectSerial::transmitByte (uint8_t val, bool first) {
// setBreak(val) switches break on or off
void CDirectSerial::setBreak (bool value) {
if (tap_fp) {
fprintf(tap_fp, "# %12.1f %11.3f break=%d\n",
tap_now_us() - tap_t0_us, PIC_FullIndex(), value ? 1 : 0);
fflush(tap_fp);
}
SERIAL_setBREAK(comport,value);
}
// updateModemControlLines(mcr) sets DTR and RTS.
// updateModemControlLines(mcr) sets DTR and RTS.
void CDirectSerial::setRTSDTR(bool rts, bool dtr) {
if (tap_fp) {
fprintf(tap_fp, "# %12.1f %11.3f rts=%d dtr=%d\n",
tap_now_us() - tap_t0_us, PIC_FullIndex(),
rts ? 1 : 0, dtr ? 1 : 0);
fflush(tap_fp);
}
SERIAL_setRTS(comport,rts);
SERIAL_setDTR(comport,dtr);
}
void CDirectSerial::setRTS(bool val) {
if (tap_fp) {
fprintf(tap_fp, "# %12.1f %11.3f rts=%d\n",
tap_now_us() - tap_t0_us, PIC_FullIndex(), val ? 1 : 0);
fflush(tap_fp);
}
SERIAL_setRTS(comport,val);
}
void CDirectSerial::setDTR(bool val) {
if (tap_fp) {
fprintf(tap_fp, "# %12.1f %11.3f dtr=%d\n",
tap_now_us() - tap_t0_us, PIC_FullIndex(), val ? 1 : 0);
fflush(tap_fp);
}
SERIAL_setDTR(comport,val);
}
@@ -68,6 +68,12 @@ private:
// sitting in the host buffer. n>1 delivers buffered bytes n times
// faster, cutting multi-byte reply latency (RIO analog packets).
float rx_burst_div = 1.0f;
// RIO_TAP=<path> (host env): wire-level byte log ("<host-us> <emu-ms>
// T|R <hex>") to verify whether RIO requests reach the wire and when
// replies come back. NULL when disabled.
FILE *tap_fp = nullptr;
double tap_t0_us = 0.0;
void tapLine(char dir, unsigned val, unsigned err);
bool doReceive();
#if SERIAL_DEBUG