Files
CydandClaude Opus 4.8 aadda7f9a5 Cockpit camera complete + namedpipe transport + 832x512 native res
Render bridge (live_bridge.py, vrview_gl.py):
- Hat glances render (left/right frame the canopy, hat-down = clean rear).
  Root bug was a stale _ckpt['fix'] key -> KeyError every glance frame ->
  render aborted (screen froze on hold, snapped back on release). The glance
  itself is the authentic eye-DCS action-0x1f reflush fp_cam already applies.
- Torso twist turret-true (root-axis yaw, zero parallax); lasers follow torso.
- Rear glance drops the canopy shell for a clean view (original-hardware
  behavior); mission-fade shroud 9fd hidden.
- Wireframe debug mode (VRVIEW_WIREFRAME / 'w' key), scene-pass scoped.
- Renderer output = 832x512, the dPL3 board's native framebuffer res.

DOSBox-X fork: namedpipe serial backend (serialnamedpipe.cpp/.h) for
vRIO/vPLASMA, replacing com0com; overlapped non-blocking I/O; typed frames
(0x00 data / 0x01 DTR+RTS). Tracked copies + apply steps in vpx-device.

Docs: COCKPIT-CAGE-NOTES (full glance/twist/rear forensics), XP-PORT-PLAN
(back-burnered), RIO-NOTES (namedpipe + keypad), pipe/egg conf variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:08:15 -05:00

22 KiB
Raw Permalink Blame History

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.

Named-pipe transport for vRIO/vPLASMA (2026-07-12, replaces com0com)

The fork now has a namedpipe serial backend so the VIRTUAL peripherals need no com0com pair (no kernel driver, no signing pain, sub-ms latency):

serial1=namedpipe pipe:vrio    rxpollus:100 rxburst:16   # vRIO
serial2=namedpipe pipe:vplasma                           # plasma readout

DOSBox is the pipe client (500ms background retry; unconnected pipe = unplugged cable: modem-in lines low, TX discarded). vRIO/vPLASMA are the servers on \\.\pipe\vrio / \\.\pipe\vplasma. One duplex byte-mode pipe, typed frames both directions: 0x00 <len:u8> <bytes> = serial data, 0x01 <lines:u8> = sender's own DTR(bit0)/RTS(bit1), receiver applies the null-modem cross (their DTR -> our DSR+CD, their RTS -> our CTS); each side sends one 0x01 on connect (vRIO sends 0x03 = board present); unknown type = drop the connection. Contract pinned with the vRIO session 2026-07-12; source of truth = the serialnamedpipe.h header comment (tracked copy in emulator/vpx-device/, apply steps in its README). Smoke-tested end-to-end (connect, line handshake, DTR/RTS edges on DOS COM open, data both ways, clean disconnect). Real pods keep directserial realport:COM1 — the real board is untouched by this. vRIO side pending: NamedPipeLink + framer (other session builds it; DTR edge feeds the existing HostHandshake event).

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 / 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:<us> (501000, 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> (164): 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 <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). 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 0420 (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), 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.

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 + L4RIO.HPP:138-152 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 <status> <unit> <ck> 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.

RIO button unit map (L4CTRL.HPP enum; wire unit byte == buttonGroup index)

Press = 88 <unit> <ck>, release = 89 <unit> <ck> (ck = sum&0x7F). The game uses the unit byte DIRECTLY as its button index (L4CTRL.CPP RIO::ButtonPressedEvent).

unit button
0x00-0x07 AuxLowerRight 8..1 (panel)
0x08-0x0F AuxLowerLeft 8..1 (panel)
0x10-0x15 Secondary 1..6
0x18-0x1D Secondary 7..12
0x20-0x27 AuxUpperCenter 8..1
0x28-0x2F AuxUpperLeft 8..1
0x30-0x37 AuxUpperRight 8..1
0x39-0x3B IcomHeadPluggedIn / IcomSensor / IcomMikePluggedIn
0x3C Door
0x3D Panic
0x3F Throttle1
0x40 JoystickTrigger (= FIRE)
0x41/0x42 JoystickHatDown / HatUp
0x43/0x44 JoystickHatRight / HatLeft (= TORSO TWIST R/L)
0x45 JoystickPinky (= torso/look DOWN per TORSO.CTL)
0x46 JoystickThumbLow (= TORSO CENTER)
0x47 JoystickThumbHigh (= torso/look UP)

CORRECTION (user, operated the real pods): in the PRODUCTION cockpit torso twist is AXIS-driven; TORSO.CTL's button mappings (TorsoLeft=HatLeft etc., limits per TORSO.SUB: +-80deg horiz @20deg/s, +10/-30 vert @40deg/s) were the DEV-rig fallback for setups without the full cockpit. On the real pod the HAT gives momentary GLANCE views (left/right/rear). Keypad keys are a SEPARATE event type (8A KeyPressed: unit,key). Verified live 2026-07-06: vRIO hat glances DO work in-game (units 0x41-0x44 arriving), so the 88/89 button path is validated end-to-end -- weapons-not-firing is NOT a unit-code problem; suspect weapon-group arming/mission state (see MECHWEAP.CTL) instead.

RIO keypad usage in the games (2026-07-11)

Keypad = the separate 8A/8B key press/release events, body = (unit, key): unit selects the keyboard -- 0 = KeyboardPilot (cockpit internal keypad), 1 = KeyboardExternal (operator keypad), 2 = KeyboardPC -- and the engine converts key to ASCII (0-9 -> '0'-'9', >=10 -> 'A'...) before feeding keyboardGroup[unit] plus a typed-string matcher (L4CTRL.CPP:2329).

  • RP: the keypad is dead plumbing. Nothing subscribes to KeyboardPilot or KeyboardExternal; the only string registration in the shipped source -- stringManager.Add("A90", KeyboardPilot, ...), type A-9-0 to begin calibration -- is COMMENTED OUT (L4CTRL.CPP:850).
  • BT: pilot keypad works ONLY in mission review. Ground truth from the shipped BTL4OPT binary (C:\VWE\BT411 reconstruction, btl4mppr.cpp): MechRIOMapper subscribes KeyboardPilot -> Mech KeypressMessageID under mode mask 0x200000, and that mode bit is toggled by the Mech's mission-review flag -- so keypad presses reach the game only while reviewing (review navigation; the Mech-side key handler is not yet recovered, so per-key meanings are still open). In normal cockpit missions BOTH games ignore the keypad entirely.
  • KeyboardExternal (operator keypad) has no subscriber in either game.
  • Related panel-button findings from the same dig: BT's eight AuxUpperRight buttons (0x30-0x37) are the "hotbox" pilot-target-select group, each with a linked lamp, and the Panic button's (0x3D) lamp doubles as the review-mode/config lamp under 0x200000.
  • vRIO implication: keypad emulation is not needed for normal missions; it only matters if/when mission-review pods are emulated.