From 377047f37d08e704a8edb696d846ea37f257e0cf Mon Sep 17 00:00:00 2001 From: Cyd Date: Sat, 4 Jul 2026 00:22:14 -0500 Subject: [PATCH] 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= (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 --- emulator/RIO-NOTES.md | 131 ++++++++++++++++++ emulator/gauge_rio.conf | 7 +- emulator/gauge_rio_log.conf | 3 +- .../vpx-device/serialport/directserial.cpp | 71 +++++++++- emulator/vpx-device/serialport/directserial.h | 6 + 5 files changed, 215 insertions(+), 3 deletions(-) diff --git a/emulator/RIO-NOTES.md b/emulator/RIO-NOTES.md index e6ab596..909bd4a 100644 --- a/emulator/RIO-NOTES.md +++ b/emulator/RIO-NOTES.md @@ -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 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). + ## Crash-on-advance fixed: arena terrain shadows With the RIO in sync the sim advances and the game crashed dereferencing the diff --git a/emulator/gauge_rio.conf b/emulator/gauge_rio.conf index f8b9e6b..1cb8721 100644 --- a/emulator/gauge_rio.conf +++ b/emulator/gauge_rio.conf @@ -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" diff --git a/emulator/gauge_rio_log.conf b/emulator/gauge_rio_log.conf index 6064c33..fae52ad 100644 --- a/emulator/gauge_rio_log.conf +++ b/emulator/gauge_rio_log.conf @@ -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" diff --git a/emulator/vpx-device/serialport/directserial.cpp b/emulator/vpx-device/serialport/directserial.cpp index 65b750e..33c94d1 100644 --- a/emulator/vpx-device/serialport/directserial.cpp +++ b/emulator/vpx-device/serialport/directserial.cpp @@ -29,9 +29,32 @@ #include "libserial.h" +#include + /* 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( + 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= (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); } diff --git a/emulator/vpx-device/serialport/directserial.h b/emulator/vpx-device/serialport/directserial.h index 4c1645b..207f84a 100644 --- a/emulator/vpx-device/serialport/directserial.h +++ b/emulator/vpx-device/serialport/directserial.h @@ -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= (host env): wire-level byte log (" + // T|R ") 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