From 73b597df35acab6b898f0c46f6377e94f631831d Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 24 Jul 2026 08:48:57 -0500 Subject: [PATCH] emulator: in-fork virtual RIO cockpit board (serial1=rio) Emulate the cockpit RIO board natively inside the DOSBox-X fork, driven by an SDL game controller + host keyboard -- no external vRIO process and no named pipe. The game<->board round trip runs in-process at the emulated UART's own cadence, so the serial round-trip dropouts (livelock, TXMAXIDLE, rxburst, 15s retry) can't occur. - serialrio.{cpp,h}: RIO 9600-8N1 device state machine + protocol codec + input mapping, transcribed from the vRIO app (VRioDevice + Protocol + InputRouter/BindingProfileFormat), minus transport/pacer/UI/locks. B0 = gamepad (5 axes + joystick column). B1 = keyboard field: MFD banks on the letter rows, F-keys = Secondary/Screen, numpad = flight controls, LShift/LCtrl throttle slew; PAUSE/ScrollLock toggle panel<->DOS; vRIO- grammar bindings file via bindings:/VWE_RIO_BINDINGS. - vpx-device/README.md: device notes + apply steps (the DOSBox-X src tree is git-ignored, so this dir is the tracked source of truth; the three sdlmain keyboard-hook edits are documented there). - net_{loop,rp}_rio.conf + deploy templates; render-bridge/ gauge_arena_rio_sound.conf (standalone -egg trim); pod-launch --rio (mutually exclusive with --pipe). Additive: real pods keep directserial realport:COM1; vRIO-over-pipe keeps namedpipe pipe:vrio. Validated live 2026-07-22 (pad + keyboard field + console networking) on the dist install. Co-Authored-By: Claude Opus 4.8 --- emulator/deploy/net_loop_rio.conf.tmpl | 68 ++ emulator/deploy/net_rp_rio.conf.tmpl | 69 ++ emulator/net_loop_rio.conf | 65 ++ emulator/net_rp_rio.conf | 68 ++ emulator/pod-launch/Options.cs | 13 +- emulator/pod-launch/README.md | 1 + .../render-bridge/gauge_arena_rio_sound.conf | 80 ++ emulator/vpx-device/README.md | 66 ++ emulator/vpx-device/serialrio.cpp | 1032 +++++++++++++++++ emulator/vpx-device/serialrio.h | 177 +++ 10 files changed, 1638 insertions(+), 1 deletion(-) create mode 100644 emulator/deploy/net_loop_rio.conf.tmpl create mode 100644 emulator/deploy/net_rp_rio.conf.tmpl create mode 100644 emulator/net_loop_rio.conf create mode 100644 emulator/net_rp_rio.conf create mode 100644 emulator/render-bridge/gauge_arena_rio_sound.conf create mode 100644 emulator/vpx-device/serialrio.cpp create mode 100644 emulator/vpx-device/serialrio.h diff --git a/emulator/deploy/net_loop_rio.conf.tmpl b/emulator/deploy/net_loop_rio.conf.tmpl new file mode 100644 index 00000000..0986d9da --- /dev/null +++ b/emulator/deploy/net_loop_rio.conf.tmpl @@ -0,0 +1,68 @@ +# DEPLOY TEMPLATE -- rendered to net_loop_rio.conf by deploy/configure.ps1 at install. +# IN-FORK RIO TRIM: the RIO cockpit board is emulated NATIVELY inside DOSBox-X +# (serial1=rio) and driven by an SDL game controller -- no vRIO process, no +# pipe (launch: pod-launch --rio). Self-contained standalone/dev trim; +# arcade cockpits with the REAL RIO/plasma on COM ports use the non-suffixed +# confs, the vRIO-app dev rig uses the _pipe confs. +# Tokens (filled by postinstall): @@ROOT@@ = package dir (C:\games\), +# @@REALNIC@@ = this pod's NIC GUID fragment, @@MACADDR@@ = stable derived MAC. +# BattleTech, GO.BAT-style netnub loop (pod stays connected + picks up missions). +[sdl] +output=opengl +priority=highest,highest +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[ne2000] +ne2000=true +nicbase=340 +# IRQ 10, NOT 3: COM2 (plasma) owns IRQ 3 in-game. +nicirq=10 +macaddr=@@MACADDR@@ +backend=pcap +[ethernet, pcap] +realnic=@@REALNIC@@ +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[joystick] +# the in-fork RIO owns the game controller (SDL); keep the DOS gameport + +# mapper auto-binding off so nothing else grabs the pad. +joysticktype=none +[serial] +# in-fork virtual RIO board on COM1 (pad: and inverty are optional args) +serial1=rio +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "@@ROOT@@\ALPHA_1" +mount d "@@ROOT@@\net-boot" +d: +echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \rel410\bt +set VIDEOFORMAT=svga +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +echo === entering GO.BAT-style netnub loop (waits for console missions) === +call d:\loop.bat diff --git a/emulator/deploy/net_rp_rio.conf.tmpl b/emulator/deploy/net_rp_rio.conf.tmpl new file mode 100644 index 00000000..ada8775d --- /dev/null +++ b/emulator/deploy/net_rp_rio.conf.tmpl @@ -0,0 +1,69 @@ +# DEPLOY TEMPLATE -- rendered to net_rp_rio.conf by deploy/configure.ps1 at install. +# IN-FORK RIO TRIM: the RIO cockpit board is emulated NATIVELY inside DOSBox-X +# (serial1=rio) and driven by an SDL game controller -- no vRIO process, no +# pipe (launch: pod-launch --rio). Self-contained standalone/dev trim; +# arcade cockpits with the REAL RIO/plasma on COM ports use the non-suffixed +# confs, the vRIO-app dev rig uses the _pipe confs. +# Tokens (filled by postinstall): @@ROOT@@ = package dir (C:\games\), +# @@REALNIC@@ = this pod's NIC GUID fragment, @@MACADDR@@ = stable derived MAC. +# Red Planet 4.10, networked pod boot (console pushes the mission egg over TCP). +# Loops netnub (loop_rp.bat) so the pod stays connected + picks up missions; +# runs until the supervisor (pod-launch) kills DOSBox. No single-shot in deploy. +[sdl] +output=opengl +priority=highest,highest +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[ne2000] +ne2000=true +nicbase=340 +nicirq=10 +macaddr=@@MACADDR@@ +backend=pcap +[ethernet, pcap] +realnic=@@REALNIC@@ +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[joystick] +# the in-fork RIO owns the game controller (SDL); keep the DOS gameport + +# mapper auto-binding off so nothing else grabs the pad. +joysticktype=none +[serial] +# in-fork virtual RIO board on COM1 (pad: and inverty are optional args) +serial1=rio +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "@@ROOT@@\ALPHA_1" +mount d "@@ROOT@@\net-boot" +d: +echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \rel410\rp +set VIDEOFORMAT=svga +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +echo === entering netnub loop (waits for console missions; supervisor kills to stop) === +call d:\loop_rp.bat diff --git a/emulator/net_loop_rio.conf b/emulator/net_loop_rio.conf new file mode 100644 index 00000000..71ab4167 --- /dev/null +++ b/emulator/net_loop_rio.conf @@ -0,0 +1,65 @@ +# IN-FORK vRIO variant of net_loop_pipe.conf: the RIO cockpit board is emulated +# natively inside DOSBox-X (serial1=rio) and driven by an SDL game controller -- +# no external vRIO process and no named pipe. Self-contained flyable cockpit. +# Everything else is identical to net_loop_pipe.conf (see it for the network / +# GO.BAT-loop rationale). The plasma readout stays on the vPLASMA pipe (optional; +# an unconnected pipe is just discarded). Real pods keep net_loop.conf +# (directserial realport:COM1); this file is dev / standalone only. +[sdl] +output=opengl +priority=highest,highest +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[ne2000] +ne2000=true +nicbase=340 +# IRQ 10, NOT 3: COM2 (plasma) owns IRQ 3 in-game; see net_full.conf. +nicirq=10 +backend=pcap +[ethernet, pcap] +realnic=F70BADD7A +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[joystick] +# the in-fork RIO owns the game controller (SDL); keep the DOS gameport + +# mapper auto-binding off so nothing else grabs the pad. +joysticktype=none +[serial] +# in-fork virtual RIO board on COM1 (pad: and inverty are optional args) +serial1=rio +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\net-boot" +d: +echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \rel410\bt +set VIDEOFORMAT=svga +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +echo === entering GO.BAT-style netnub loop (waits for console missions) === +call d:\loop.bat diff --git a/emulator/net_rp_rio.conf b/emulator/net_rp_rio.conf new file mode 100644 index 00000000..4f380301 --- /dev/null +++ b/emulator/net_rp_rio.conf @@ -0,0 +1,68 @@ +# IN-FORK vRIO variant of net_rp_pipe.conf: the RIO cockpit board is emulated +# natively inside DOSBox-X (serial1=rio) and driven by an SDL game controller -- +# no external vRIO process and no named pipe. Self-contained flyable cockpit. +# Everything else is identical to net_rp_pipe.conf (see it for the RP boot +# rationale). Plasma stays on the vPLASMA pipe (optional). Real pods keep +# net_rp.conf (directserial realport:COM1); this file is dev / standalone only. +[sdl] +output=opengl +priority=highest,highest +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[ne2000] +ne2000=true +nicbase=340 +nicirq=10 +backend=pcap +[ethernet, pcap] +realnic=F70BADD7A +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[joystick] +# the in-fork RIO owns the game controller (SDL); keep the DOS gameport + +# mapper auto-binding off so nothing else grabs the pad. +joysticktype=none +[serial] +# in-fork virtual RIO board on COM1 (pad: and inverty are optional args) +serial1=rio +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\net-boot" +d: +echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \rel410\rp +set VIDEOFORMAT=svga +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +echo === launching Red Planet via NetNub (RIO + sound; waits for console) === +32rtm.exe -x +netnub -p -f rpl4opt > nn.log +32rtm.exe -u +echo === RP-NET-RUN-DONE === +pause + diff --git a/emulator/pod-launch/Options.cs b/emulator/pod-launch/Options.cs index 0b336ca7..683c5289 100644 --- a/emulator/pod-launch/Options.cs +++ b/emulator/pod-launch/Options.cs @@ -27,6 +27,7 @@ namespace VwePod public bool NoBridge; public bool NoSound; public bool Pipe; + public bool Rio; public bool Mipmap; public bool NoFocus; public bool DryRun; @@ -78,6 +79,7 @@ namespace VwePod case "--no-bridge": o.NoBridge = true; break; case "--no-sound": o.NoSound = true; break; case "--pipe": o.Pipe = true; break; + case "--rio": o.Rio = true; break; case "--mipmap": o.Mipmap = true; break; case "--no-focus": o.NoFocus = true; break; case "--dry-run": o.DryRun = true; break; @@ -105,9 +107,14 @@ namespace VwePod ?? throw new ArgumentException("dosbox-x.exe not found under root; pass --dosbox"); o.DosBoxDir = Path.GetDirectoryName(o.DosBox); + if (o.Pipe && o.Rio) + throw new ArgumentException("--pipe and --rio are mutually exclusive (both select a COM1 backend)"); + // --pipe: the _pipe conf variant (serials as named pipes to // vRIO/vPLASMA -- the dev-rig trim; real cockpits keep COM ports). - string confBase = o.Mode.ConfBase + (o.Pipe ? "_pipe" : ""); + // --rio: the _rio conf variant (RIO board emulated IN-FORK, + // driven by an SDL game controller -- no vRIO process, no pipe). + string confBase = o.Mode.ConfBase + (o.Rio ? "_rio" : o.Pipe ? "_pipe" : ""); o.Conf = confArg ?? FirstExisting(Path.Combine(o.Root, confBase + ".conf")); if (o.Conf == null) { @@ -190,6 +197,10 @@ namespace VwePod --pipe use the _pipe conf: serial1/2 as named pipes to vRIO (pipe:vrio) + vPLASMA (pipe:vplasma). Dev-rig default trim; arcade cockpits keep real COM ports + --rio use the _rio conf: the RIO cockpit board is + emulated IN-FORK (serial1=rio) and driven by an SDL + game controller -- no vRIO process, no pipe. Self- + contained; dev/standalone only. Excludes --pipe --mipmap VRVIEW_MIPMAP=1 (RP floor shimmer fix; non-authentic) --no-focus skip the window layout/focus pass --dry-run resolve paths + print the launch plan, then exit"); diff --git a/emulator/pod-launch/README.md b/emulator/pod-launch/README.md index a7e40cb3..f4bf4360 100644 --- a/emulator/pod-launch/README.md +++ b/emulator/pod-launch/README.md @@ -71,6 +71,7 @@ see `../CAMERA-REVIEW-NOTES.md`. Default mode: `bt`. | `--no-bridge` | pod only, no GL render window | | `--no-focus` | skip the renderer-topmost / DOSBox-focus pass | | `--pipe` | use the `_pipe` conf: serials as named pipes to vRIO (`pipe:vrio`) + vPLASMA (`pipe:vplasma`), no com0com. **Dev-rig policy (2026-07-17): always `--pipe` on the dev machine** — vRIO dist ≥ `20260716-68e3d1f` speaks the pipe; the plain confs (real COM ports) are for cockpits with the physical RIO/plasma | +| `--rio` | use the `_rio` conf: the RIO cockpit board is emulated **in-fork** (`serial1=rio`) and driven by an **SDL game controller + the host keyboard** inside DOSBox-X — no vRIO process, no pipe, no serial round-trip. Self-contained cockpit: pad = stick/throttle/pedals + trigger/hat/torso; keyboard = the 72-button MFD/Secondary/Screen field + internal keypad (vRIO's default layout; **PAUSE toggles** keyboard between panel and DOS; Ctrl/Alt chords always pass through; remap via a vRIO-grammar bindings file, conf `bindings:` or env `VWE_RIO_BINDINGS`). See the fork's `serialrio.cpp`. Dev/standalone only; mutually exclusive with `--pipe`. Real pods still use the plain confs (physical RIO on COM1) | ### Window placement (per-rig; normally fixed, overridable per-launch) diff --git a/emulator/render-bridge/gauge_arena_rio_sound.conf b/emulator/render-bridge/gauge_arena_rio_sound.conf new file mode 100644 index 00000000..807847c7 --- /dev/null +++ b/emulator/render-bridge/gauge_arena_rio_sound.conf @@ -0,0 +1,80 @@ +# IN-FORK RIO variant of gauge_arena_pipe_sound.conf: bare -egg arena1 run +# (no netnub/console) with the RIO board emulated NATIVELY inside DOSBox-X +# (serial1=rio) -- SDL game controller + host keyboard field (PAUSE toggles +# panel/DOS). No vRIO process, no pipe. Everything else identical to the +# pipe trim (see its comments for the rationale of each section). +[sdl] +output=opengl +# higher,higher not highest: HIGH_PRIORITY_CLASS starved the host desktop; +# with the retry patches a rare dropout self-recovers (see gauge_rio.conf). +priority=higher,higher +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +# match the EMU8000s' native rate (no resample) and buffer ~60ms so brief +# emulation-thread stalls (RIO retry recovery) don't audibly chop +rate=44100 +blocksize=1024 +prebuffer=60 +[ne2000] +# bare -egg run WITH the packet stack resident (as production AUTOEXEC did); +# slirp = user-mode NAT, no LAN peers -- fine for a solo run. +ne2000=true +nicbase=340 +nicirq=3 +backend=slirp +[joystick] +# the in-fork RIO owns the game controller (SDL); keep the DOS gameport + +# mapper auto-binding off so nothing else grabs the pad. +joysticktype=none +[serial] +# in-fork virtual RIO board on COM1 (pad: / inverty / bindings: +# optional); plasma display stays on the vPLASMA pipe (optional). +serial1=rio +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\net-boot" +d: +echo === loading NE2000 packet-driver stack (as production AUTOEXEC did) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \REL410\BT +set VIDEOFORMAT=svga +rem production pod card init (PARAMETR.BAT:181-186): DIAGNOSE + AWEUTIL per +rem card -- AWEUTIL /S does the EMU8000 bring-up and DRAM detect the HMI SOS +rem driver relies on; skipping it left the cards uninitialized (silent). +rem aweutil /s SKIPPED for now: it verifies the AWE32 GM ROM, which the +rem emulated cards lack (hangs in a retry loop) -- restore once the ROM is +rem dumped from a real card. diagnose /s kept (passes, sets mixer config). +set BLASTER=A220 I5 D1 H5 P330 T6 +c:\sb16\diagnose /s +set BLASTER=A240 I7 D3 H6 P300 T6 +c:\sb16\diagnose /s +set BLASTER=A220 I5 D1 H5 P330 T6 +set TEMP=c:\ +rem arena1 city mission (TESTARN.EGG: map=arena1, time=day), RIO attached, +rem bare -egg launch (no netnub) = the real-world test-egg setup. +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +32rtm.exe -x +rem stdout -> log (this optimized build still emits DEBUG_STREAM lines). +btl4opt.exe -egg testarn.egg > c:\weaponlog.txt +32rtm.exe -u +echo ALPHA1-RUN-DONE +pause + diff --git a/emulator/vpx-device/README.md b/emulator/vpx-device/README.md index 4fe69202..b7a09c1e 100644 --- a/emulator/vpx-device/README.md +++ b/emulator/vpx-device/README.md @@ -24,6 +24,42 @@ version control because the DOSBox-X source tree itself bits`); the full wire contract is in the header comment (pinned with the vRIO session). Supports the directserial `rxpollus`/`rxburst`/`rxdelay` low-latency knobs. Smoke-tested end-to-end 2026-07-12. +- **`serialrio.cpp` / `serialrio.h`** — `serial=rio` backend (2026-07-21, + keyboard field 2026-07-22): the cockpit RIO board emulated **in-fork**. It + speaks the device side of the RIO 9600-8N1 protocol straight to the game's + UART (no external process, no pipe) and sources pilot input from an **SDL + game controller + the host keyboard** inside the emulator. Because the + game↔board round trip is in-process at the emulated UART's own cadence — + like the real ISA UART on a pod — the serial round-trip dropouts (livelock, + `TXMAXIDLE`, `rxburst`, 15s retry) can't occur. The protocol state machine + + input mapping are transcribed from the validated vRIO app (`C:\VWE\vrio` + VRio.Core: `VRioDevice`, the `Protocol` codec, `Input/InputRouter` + + `BindingProfileFormat`); the transport, wire pacer, panel UI and thread + locks are dropped (single emulator thread). RX byte pacing reuses + directserial's state machine (same `rxpollus`/`rxburst` knobs), draining a + queue the board itself fills. + **Keyboard field (B1):** sdlmain offers every SDL key to `RIO_HostKeyEvent()` + first; while capture is ON (default) bound keys press RIO addresses or drive + axes and are swallowed; unbound keys and Ctrl/Alt/Gui chords pass through + (host hotkeys keep working; modifier keys BOUND as inputs are exempt from + the chord rule). Default field: number/QWERTY rows = upper MFD bank, + home/bottom rows = lower bank, F1–F12 = Secondary/Screen, arrows+Space = + hat+main, and keyboard FLIGHT controls (operator 2026-07-22): numpad + 8/2/4/6 = stick, 7/9 = pedals, LShift/LCtrl = throttle slew ±0.7/s, + numpad-5 = throttle zero; the internal keypad is unbound (mission-review + plumbing only). **PAUSE or SCROLL LOCK toggles capture** panel↔DOS + (toggling off releases everything; needed at the netnub loop / DOS + prompts); `togglekey:` adds a third toggle key for keyboards + lacking both. Focus loss releases held keys (`RIO_HostFocusLost` in + `GFX_LosingFocus`). A bindings file in vRIO's grammar REPLACES the default + profile (conf `bindings:`, else env `VWE_RIO_BINDINGS`; key-axis + modes `deflect|slew|rate|set`, sign may be a separate token; vRIO's .NET + key names are aliased so a vRIO `bindings.txt` ports over). + Conf: `serial1=rio [pad:] [inverty] [bindings:] + [togglekey:] [rxpollus:] [rxburst:]`. **Additive** — real pods keep `directserial realport:COM1`; + vRIO-over-pipe keeps `namedpipe pipe:vrio`. Confs: `net_loop_rio.conf` / + `net_rp_rio.conf`; `pod-launch --rio`. B0 (pad) LIVE-CONFIRMED 2026-07-22 + from the repacked dist; keyboard-field live test pending. ## Applying to a DOSBox-X source checkout @@ -66,6 +102,36 @@ Tested against DOSBox-X `v2026.06.02`, MSYS2 mingw64. copy the pattern. - `src/src/dosbox.cpp`: add `"namedpipe"` to the `serials[]` allowed-values list (without this the config parser silently falls back to `dummy`). +3c. The in-fork RIO serial backend (needs SDL2 — the whole-program link already + pulls SDL2 in, and the vendored SDL2 include dir is on the global compile + flags, so no per-subdir cflag edits): + ``` + cp emulator/vpx-device/serialrio.cpp emulator/src/src/hardware/serialport/ + cp emulator/vpx-device/serialrio.h emulator/src/src/hardware/serialport/ + ``` + plus the same four stock edits as the namedpipe backend: + - `src/src/hardware/serialport/Makefile.am`: append + `serialrio.cpp serialrio.h` to `libserial_a_SOURCES` (same generated- + Makefile caveat as step 2). + - `src/include/serialport.h`: add `SERIAL_TYPE_RIO` to `SerialTypesE` + (UNguarded — SDL is cross-platform, unlike the WIN32-only namedpipe; + placed right after `SERIAL_TYPE_MOUSE`). + - `src/src/hardware/serialport/serialport.cpp`: `#include "serialrio.h"`; + add the `type=="rio"` case to BOTH dispatch switches and `"rio"` to the + `serialTypes[]` string table (copy the `namedpipe` pattern; keep it + unguarded). + - `src/src/dosbox.cpp`: add `"rio"` to the `serials[]` allowed-values list. + - `src/src/gui/sdlmain.cpp` (the B1 keyboard field, three small edits): + (a) declare the hooks above `GFX_LosingFocus`: + `bool RIO_HostKeyEvent(int sdl_scancode, bool pressed, bool repeat, + unsigned int sdl_mods); void RIO_HostFocusLost(void);` + (b) call `RIO_HostFocusLost();` inside `GFX_LosingFocus` right after + `MAPPER_LosingFocus();` + (c) in GFX_Events' SDL2 `case SDL_KEYDOWN: case SDL_KEYUP:` block, right + after the SCREEN_GAMELINK early-out, offer the event to + `RIO_HostKeyEvent((int)event.key.keysym.scancode, event.type == + SDL_KEYDOWN, event.key.repeat != 0, event.key.keysym.mod)` and `break;` + when it returns true (consumed keys must never reach the mapper). 4. Build: ``` cd emulator/src diff --git a/emulator/vpx-device/serialrio.cpp b/emulator/vpx-device/serialrio.cpp new file mode 100644 index 00000000..1c1b9b6e --- /dev/null +++ b/emulator/vpx-device/serialrio.cpp @@ -0,0 +1,1032 @@ +/* + * VWE fork: in-fork virtual RIO cockpit board (serial1=rio). See serialrio.h + * for the rationale. The protocol state machine + input mapping are transcribed + * from the validated vRIO app (C:\VWE\vrio VRio.Core); the transport, wire pacer, + * panel UI and thread locks are dropped -- everything here runs on the single + * emulator thread (CSerial callbacks + the SDL reads + the host keyboard hook), + * so no synchronization is needed. RX byte pacing reuses directserial's state + * machine (via the same rxpollus/rxburst knobs), except doReceive() drains a + * queue the board itself fills rather than a pipe. + */ + +#include "dosbox.h" + +#include "logging.h" +#include "pic.h" +#include "setup.h" +#include "serialport.h" +#include "serialrio.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool getBituSubstring(const char* name, Bitu* data, CommandLine* cmd); + +enum { P_RX_IDLE = 0, P_RX_WAIT, P_RX_BLOCKED, P_RX_FASTWAIT }; + +// ---- RIO wire constants (mirror vRIO VRio.Core/Protocol) -------------------- +enum { + CMD_CHECK_REQ = 0x80, CMD_VER_REQ = 0x81, CMD_ANALOG_REQ = 0x82, + CMD_RESET_REQ = 0x83, CMD_LAMP_REQ = 0x84, CMD_CHECK_REPLY = 0x85, + CMD_VER_REPLY = 0x86, CMD_ANALOG_REPLY = 0x87, CMD_BTN_PRESS = 0x88, + CMD_BTN_RELEASE = 0x89, CMD_KEY_PRESS = 0x8A, CMD_KEY_RELEASE = 0x8B, + CMD_TESTMODE = 0x8C +}; +enum { CTL_ACK = 0xFC, CTL_NAK = 0xFD, CTL_RESTART = 0xFE, CTL_IDLE = 0xFF }; + +// The I/O boards a healthy cockpit reports (RioAddressSpace.Boards); the game's +// CheckRequest expects one BoardOk CheckReply per entry, framed by test mode. +static const uint8_t kBoards[] = { + 0x00, 0x08, 0x10, 0x11, 0x18, 0x19, 0x1A, 0x20, 0x28, 0x30, 0x38 +}; + +static int rio_payload_len(uint8_t cmd) { + static const int t[] = { 0, 0, 0, 1, 2, 2, 2, 10, 1, 1, 2, 2, 1 }; + if (cmd < 0x80 || cmd > 0x8C) return -1; + return t[cmd - 0x80]; +} + +static uint8_t rio_cs(const uint8_t* b, int n) { + int s = 0; + for (int i = 0; i < n; i++) s += (b[i] & 0x7F); + return (uint8_t)(s & 0x7F); +} + +// 14-bit signed axis value -> two 7-bit wire bytes (AnalogCodec.Split). +static void rio_split(int16_t v, uint8_t& lo, uint8_t& hi) { + int raw = v & 0x3FFF; + lo = (uint8_t)(raw & 0x7F); + hi = (uint8_t)((raw >> 7) & 0x7F); +} + +// ---- input mapping helpers (transcribed from vRIO InputRouter) --------------- +// axis indices match RioAxis: Throttle 0, LeftPedal 1, RightPedal 2, JoyY 3, JoyX 4 +enum { PS_LX = 0, PS_LY, PS_RX, PS_RY, PS_LT, PS_RT }; + +static float rio_clampf(float x) { return x < -1.0f ? -1.0f : (x > 1.0f ? 1.0f : x); } + +// deadzone (rescaled so travel stays continuous) + inversion (InputRouter.Shape) +static float rio_shape(float v, float dz, bool invert) { + if (dz > 0.0f) { + float m = std::fabs(v); + v = m <= dz ? 0.0f : (v < 0 ? -1.0f : 1.0f) * (m - dz) / (1.0f - dz); + } + return invert ? -v : v; +} + +// stick axes are bipolar (-1..1), throttle/pedals unipolar (0..1) (ClampNorm) +static float rio_clampNorm(int axis, float v) { + float lo = (axis == 3 || axis == 4) ? -1.0f : 0.0f; + return v < lo ? lo : (v > 1.0f ? 1.0f : v); +} + +// raw value at full travel from rest; sign is the wire direction (RioAxisRange) +static int rio_fullTravel(int axis) { + if (axis == 0) return -800; // throttle (forward = negative) + if (axis == 1 || axis == 2) return 500; // pedals (spring) + return 80; // joystick extent +} + +static std::string rio_lower(const std::string& s) { + std::string r = s; + for (size_t i = 0; i < r.size(); i++) r[i] = (char)tolower((unsigned char)r[i]); + return r; +} + +// key name -> SDL scancode. vRIO bindings files use .NET Keys names (D1, +// NumPad0, OemMinus, ...) -- alias those first so a vRIO bindings.txt ports +// over unchanged; everything else goes through SDL_GetScancodeFromName +// ("A", "1", "F1", "Up", "Space", "Keypad 5", ... -- case-insensitive). +static int rio_key_scancode(const std::string& name) { + static const struct { const char* net; const char* sdl; } kAliases[] = { + { "d0", "0" }, { "d1", "1" }, { "d2", "2" }, { "d3", "3" }, { "d4", "4" }, + { "d5", "5" }, { "d6", "6" }, { "d7", "7" }, { "d8", "8" }, { "d9", "9" }, + { "numpad0", "Keypad 0" }, { "numpad1", "Keypad 1" }, { "numpad2", "Keypad 2" }, + { "numpad3", "Keypad 3" }, { "numpad4", "Keypad 4" }, { "numpad5", "Keypad 5" }, + { "numpad6", "Keypad 6" }, { "numpad7", "Keypad 7" }, { "numpad8", "Keypad 8" }, + { "numpad9", "Keypad 9" }, + { "oemminus", "-" }, { "oemplus", "=" }, { "oemcomma", "," }, { "oemperiod", "." }, + { "oemopenbrackets", "[" }, { "oemclosebrackets", "]" }, + { "divide", "Keypad /" }, { "multiply", "Keypad *" }, { "subtract", "Keypad -" }, + { "add", "Keypad +" }, { "decimal", "Keypad ." }, { "enter", "Return" }, + { "lshift", "Left Shift" }, { "rshift", "Right Shift" }, + { "lctrl", "Left Ctrl" }, { "rctrl", "Right Ctrl" }, + { "lalt", "Left Alt" }, { "ralt", "Right Alt" }, + { "scrolllock", "ScrollLock" }, { "pause", "Pause" }, + }; + std::string low = rio_lower(name); + for (size_t i = 0; i < sizeof(kAliases) / sizeof(kAliases[0]); i++) + if (low == kAliases[i].net) + return (int)SDL_GetScancodeFromName(kAliases[i].sdl); + return (int)SDL_GetScancodeFromName(name.c_str()); +} + +static bool rio_pad_button_name(const std::string& s, uint16_t& bit) { + static const struct { const char* n; uint16_t b; } t[] = { + { "dpadup", 0x0001 }, { "dpaddown", 0x0002 }, { "dpadleft", 0x0004 }, + { "dpadright", 0x0008 }, { "start", 0x0010 }, { "back", 0x0020 }, + { "leftthumb", 0x0040 }, { "rightthumb", 0x0080 }, + { "leftshoulder", 0x0100 }, { "rightshoulder", 0x0200 }, + { "a", 0x1000 }, { "b", 0x2000 }, { "x", 0x4000 }, { "y", 0x8000 }, + }; + std::string low = rio_lower(s); + for (size_t i = 0; i < sizeof(t) / sizeof(t[0]); i++) + if (low == t[i].n) { bit = t[i].b; return true; } + return false; +} + +static bool rio_pad_axis_name(const std::string& s, int& src) { + static const struct { const char* n; int v; } t[] = { + { "leftstickx", PS_LX }, { "leftsticky", PS_LY }, + { "rightstickx", PS_RX }, { "rightsticky", PS_RY }, + { "lefttrigger", PS_LT }, { "righttrigger", PS_RT }, + }; + std::string low = rio_lower(s); + for (size_t i = 0; i < sizeof(t) / sizeof(t[0]); i++) + if (low == t[i].n) { src = t[i].v; return true; } + return false; +} + +static bool rio_axis_name(const std::string& s, int& axis) { + static const struct { const char* n; int v; } t[] = { + { "throttle", 0 }, { "leftpedal", 1 }, { "rightpedal", 2 }, + { "joysticky", 3 }, { "joystickx", 4 }, + }; + std::string low = rio_lower(s); + for (size_t i = 0; i < sizeof(t) / sizeof(t[0]); i++) + if (low == t[i].n) { axis = t[i].v; return true; } + return false; +} + +static bool rio_parse_addr(const std::string& s, int& addr) { + char* end = nullptr; + long v = strtol(s.c_str(), &end, 0); // 0x-prefixed hex or decimal + if (end == s.c_str() || *end != '\0') return false; + addr = (int)v; + return (addr >= 0 && addr < 72) || + (addr >= 0x50 && addr <= 0x5F) || (addr >= 0x60 && addr <= 0x6F); +} + +static bool rio_parse_float(const std::string& s, float& f) { + char* end = nullptr; + f = strtof(s.c_str(), &end); + return end != s.c_str() && *end == '\0'; +} + +// the KMOD bit a modifier key itself contributes (0 for ordinary keys) -- +// used so a BOUND modifier (e.g. LShift = throttle slew) can be routed and +// doesn't chord-block itself or other bound keys while held. +static unsigned int rio_own_mod_bit(int sc) { + switch (sc) { + case SDL_SCANCODE_LCTRL: return KMOD_LCTRL; + case SDL_SCANCODE_RCTRL: return KMOD_RCTRL; + case SDL_SCANCODE_LSHIFT: return KMOD_LSHIFT; + case SDL_SCANCODE_RSHIFT: return KMOD_RSHIFT; + case SDL_SCANCODE_LALT: return KMOD_LALT; + case SDL_SCANCODE_RALT: return KMOD_RALT; + case SDL_SCANCODE_LGUI: return KMOD_LGUI; + case SDL_SCANCODE_RGUI: return KMOD_RGUI; + } + return 0; +} + +// ---- host keyboard hook entry points ---------------------------------------- +// One RIO port owns the host keyboard (first constructed wins). +static CSerialRio* rio_instance = nullptr; + +bool RIO_HostKeyEvent(int sdl_scancode, bool pressed, bool repeat, unsigned int sdl_mods) { + return rio_instance ? rio_instance->hostKeyEvent(sdl_scancode, pressed, repeat, sdl_mods) : false; +} + +void RIO_HostFocusLost(void) { + if (rio_instance) rio_instance->hostFocusLost(); +} + +// ============================================================================ + +CSerialRio::CSerialRio(Bitu id, CommandLine* cmd) : CSerial(id, cmd) { + InstallationSuccessful = false; + + Bitu tmp = 0; + if (getBituSubstring("pad:", &tmp, cmd)) pad_index = (int)tmp; + invertY = cmd->FindExist("inverty", false); + + std::string bindPath, tmpstr; + if (cmd->FindStringBegin("bindings:", tmpstr, false)) bindPath = tmpstr; + else if (const char* e = getenv("VWE_RIO_BINDINGS")) bindPath = e; + + Bitu rx_poll_us = 0; + if (getBituSubstring("rxpollus:", &rx_poll_us, cmd)) { + if (rx_poll_us < 50) rx_poll_us = 50; + if (rx_poll_us > 1000) rx_poll_us = 1000; + rx_poll_ms = (float)rx_poll_us / 1000.0f; + } + Bitu rx_burst = 0; + if (getBituSubstring("rxburst:", &rx_burst, cmd)) { + if (rx_burst < 1) rx_burst = 1; + if (rx_burst > 64) rx_burst = 64; + rx_burst_div = (float)rx_burst; + } + if (getBituSubstring("rxdelay:", &rx_retry_max, cmd)) { + if (!(rx_retry_max <= 10000)) rx_retry_max = 0; + } + + logv = (getenv("VWE_RIO_LOG") != nullptr); + + // capture toggle keys: Pause and ScrollLock by default (not every keyboard + // has both), plus an optional custom one via togglekey:. + toggleKeys[0] = SDL_SCANCODE_PAUSE; + toggleKeys[1] = SDL_SCANCODE_SCROLLLOCK; + toggleKeys[2] = 0; + if (cmd->FindStringBegin("togglekey:", tmpstr, false)) { + int sc = rio_key_scancode(tmpstr); + if (sc != SDL_SCANCODE_UNKNOWN) { + toggleKeys[2] = sc; + LOG_MSG("Serial%d: RIO capture toggle also on '%s'", (int)COMNUMBER, tmpstr.c_str()); + } else { + LOG_MSG("Serial%d: togglekey:'%s' not a recognized key name -- ignored", + (int)COMNUMBER, tmpstr.c_str()); + } + } + + loadDefaultProfile(); + if (!bindPath.empty()) { + if (loadBindingsFile(bindPath)) + LOG_MSG("Serial%d: RIO bindings file %s REPLACES the default profile", + (int)COMNUMBER, bindPath.c_str()); + else + LOG_MSG("Serial%d: RIO bindings file %s unreadable -- keeping the default profile", + (int)COMNUMBER, bindPath.c_str()); + } + computeBoundModMask(); + + LOG_MSG("Serial%d: virtual RIO board (pad %d, inverty %d, %u key + %u pad bindings, " + "keyboard CAPTURED for the panel -- PAUSE toggles panel/DOS)", + (int)COMNUMBER, pad_index, invertY ? 1 : 0, + (unsigned)(keyButtons.size() + keyAxes.size()), + (unsigned)(padButtons.size() + padAxes.size())); + + CSerial::Init_Registers(); + // the board is always present -- assert the modem-in lines the game reads + setRI(false); + setCD(true); + setDSR(true); + setCTS(true); + + if (rio_instance == nullptr) rio_instance = this; + else LOG_MSG("Serial%d: another RIO port already owns the host keyboard hook", (int)COMNUMBER); + + InstallationSuccessful = true; + rx_state = P_RX_IDLE; + setEvent(SERIAL_POLLING_EVENT, rx_poll_ms); +} + +CSerialRio::~CSerialRio() { + if (rio_instance == this) rio_instance = nullptr; + if (pad) SDL_GameControllerClose((SDL_GameController*)pad); + pad = nullptr; + // events are cleared by the framework on port destruction +} + +void CSerialRio::logf(const char* fmt, ...) { + if (!logv) return; + va_list ap; va_start(ap, fmt); + fprintf(stderr, "[rio] "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, "\n"); + va_end(ap); +} + +// ---- binding profile --------------------------------------------------------- + +// The vRIO default profile (BindingProfileFormat.DefaultText), transcribed 1:1 +// with SDL scancodes standing in for the .NET Keys names. +void CSerialRio::loadDefaultProfile() { + keyButtons.clear(); keyAxes.clear(); padButtons.clear(); padAxes.clear(); + + static const RioKeyButtonBind kKeys[] = { + // upper MFD bank: number row = top MFD row, QWERTY row under it + { SDL_SCANCODE_1, 0x2F, false }, { SDL_SCANCODE_2, 0x2E, false }, + { SDL_SCANCODE_3, 0x2D, false }, { SDL_SCANCODE_4, 0x2C, false }, + { SDL_SCANCODE_5, 0x27, false }, { SDL_SCANCODE_6, 0x26, false }, + { SDL_SCANCODE_7, 0x25, false }, { SDL_SCANCODE_8, 0x24, false }, + { SDL_SCANCODE_9, 0x37, false }, { SDL_SCANCODE_0, 0x36, false }, + { SDL_SCANCODE_MINUS, 0x35, false }, { SDL_SCANCODE_EQUALS, 0x34, false }, + { SDL_SCANCODE_Q, 0x2B, false }, { SDL_SCANCODE_W, 0x2A, false }, + { SDL_SCANCODE_E, 0x29, false }, { SDL_SCANCODE_R, 0x28, false }, + { SDL_SCANCODE_T, 0x23, false }, { SDL_SCANCODE_Y, 0x22, false }, + { SDL_SCANCODE_U, 0x21, false }, { SDL_SCANCODE_I, 0x20, false }, + { SDL_SCANCODE_O, 0x33, false }, { SDL_SCANCODE_P, 0x32, false }, + { SDL_SCANCODE_LEFTBRACKET, 0x31, false }, { SDL_SCANCODE_RIGHTBRACKET, 0x30, false }, + // lower MFD bank: home row + the row below, 4-key blocks split by a gap + { SDL_SCANCODE_A, 0x0F, false }, { SDL_SCANCODE_S, 0x0E, false }, + { SDL_SCANCODE_D, 0x0D, false }, { SDL_SCANCODE_F, 0x0C, false }, + { SDL_SCANCODE_H, 0x07, false }, { SDL_SCANCODE_J, 0x06, false }, + { SDL_SCANCODE_K, 0x05, false }, { SDL_SCANCODE_L, 0x04, false }, + { SDL_SCANCODE_Z, 0x0B, false }, { SDL_SCANCODE_X, 0x0A, false }, + { SDL_SCANCODE_C, 0x09, false }, { SDL_SCANCODE_V, 0x08, false }, + { SDL_SCANCODE_N, 0x03, false }, { SDL_SCANCODE_M, 0x02, false }, + { SDL_SCANCODE_COMMA, 0x01, false }, { SDL_SCANCODE_PERIOD, 0x00, false }, + // Secondary / Screen columns on the function keys, top to bottom + { SDL_SCANCODE_F1, 0x10, false }, { SDL_SCANCODE_F2, 0x11, false }, + { SDL_SCANCODE_F3, 0x12, false }, { SDL_SCANCODE_F4, 0x13, false }, + { SDL_SCANCODE_F5, 0x14, false }, { SDL_SCANCODE_F6, 0x15, false }, + { SDL_SCANCODE_F7, 0x18, false }, { SDL_SCANCODE_F8, 0x19, false }, + { SDL_SCANCODE_F9, 0x1A, false }, { SDL_SCANCODE_F10, 0x1B, false }, + { SDL_SCANCODE_F11, 0x1C, false }, { SDL_SCANCODE_F12, 0x1D, false }, + // joystick column: hat on the arrows, Main on Space + { SDL_SCANCODE_SPACE, 0x40, false }, // Main (fire) + { SDL_SCANCODE_UP, 0x42, false }, // Hat Up + { SDL_SCANCODE_DOWN, 0x41, false }, // Hat Back + { SDL_SCANCODE_LEFT, 0x44, false }, // Hat Left + { SDL_SCANCODE_RIGHT, 0x43, false }, // Hat Right + }; + for (size_t i = 0; i < sizeof(kKeys) / sizeof(kKeys[0]); i++) + keyButtons.push_back(kKeys[i]); + + // keyboard flight controls (operator 2026-07-22): numpad = stick/pedals, + // LShift/LCtrl = throttle slew, numpad-5 = throttle zero. The internal + // keypad is deliberately UNBOUND (dead plumbing in normal missions -- + // mission review only; re-add via a bindings file if ever needed). + static const RioKeyAxisBind kKeyAxes[] = { + { SDL_SCANCODE_KP_8, 3, RIO_KAX_DEFLECT, 2.5f }, // JoystickY full up + { SDL_SCANCODE_KP_2, 3, RIO_KAX_DEFLECT, -2.5f }, // JoystickY full down + // wire X runs NEGATIVE to the right (same reason pad LeftStickX has + // invert), so left = +, right = - + { SDL_SCANCODE_KP_4, 4, RIO_KAX_DEFLECT, 2.5f }, // JoystickX full left + { SDL_SCANCODE_KP_6, 4, RIO_KAX_DEFLECT, -2.5f }, // JoystickX full right + { SDL_SCANCODE_KP_7, 1, RIO_KAX_DEFLECT, 2.5f }, // LeftPedal full press + { SDL_SCANCODE_KP_9, 2, RIO_KAX_DEFLECT, 2.5f }, // RightPedal full press + { SDL_SCANCODE_LSHIFT, 0, RIO_KAX_RATE, 0.7f }, // Throttle slew + + { SDL_SCANCODE_LCTRL, 0, RIO_KAX_RATE, -0.7f }, // Throttle slew - + { SDL_SCANCODE_KP_5, 0, RIO_KAX_SET, 0.0f }, // Throttle zero + }; + for (size_t i = 0; i < sizeof(kKeyAxes) / sizeof(kKeyAxes[0]); i++) + keyAxes.push_back(kKeyAxes[i]); + + static const RioPadAxisBind kPadAxes[] = { + { PS_LX, 4, true, 0.20f, 0.00f }, // LeftStickX -> JoystickX (invert) + { PS_LY, 3, false, 0.20f, 0.00f }, // LeftStickY -> JoystickY + { PS_RY, 0, false, 0.20f, 0.75f }, // RightStickY -> Throttle (rate) + { PS_LT, 1, false, 0.12f, 0.00f }, // LeftTrigger -> LeftPedal + { PS_RT, 2, false, 0.12f, 0.00f }, // RightTrigger-> RightPedal + }; + for (size_t i = 0; i < sizeof(kPadAxes) / sizeof(kPadAxes[0]); i++) + padAxes.push_back(kPadAxes[i]); + + static const RioPadButtonBind kPadBtns[] = { + { 0x1000, 0x40, false }, // A -> Main / trigger (fire) + { 0x2000, 0x46, false }, // B -> Thumb-low (torso center) + { 0x4000, 0x45, false }, // X -> Pinky (look down) + { 0x8000, 0x47, false }, // Y -> Thumb-high (look up) + { 0x0001, 0x42, false }, // DPad Up -> Hat Up + { 0x0002, 0x41, false }, // DPad Down -> Hat Back + { 0x0004, 0x44, false }, // DPad Left -> Hat Left (torso twist L) + { 0x0008, 0x43, false }, // DPad Right -> Hat Right (torso twist R) + { 0x0100, 0x3D, false }, // Left Shoulder-> Panic + { 0x0200, 0x3F, false }, // Right Should.-> Throttle button + }; + for (size_t i = 0; i < sizeof(kPadBtns) / sizeof(kPadBtns[0]); i++) + padButtons.push_back(kPadBtns[i]); +} + +// Bindings file, vRIO grammar (BindingProfileFormat.Parse): one binding per +// line, '#' comments, case-insensitive keywords; bad lines are reported with +// their line number and skipped. A readable file REPLACES the whole profile. +bool CSerialRio::loadBindingsFile(const std::string& path) { + std::ifstream f(path.c_str()); + if (!f) return false; + + std::vector kb; + std::vector ka; + std::vector pb; + std::vector pa; + + std::string line; + int lineno = 0, bad = 0; + while (std::getline(f, line)) { + lineno++; + size_t hash = line.find('#'); + if (hash != std::string::npos) line.erase(hash); + std::istringstream ss(line); + std::vector tok; + std::string t; + while (ss >> t) tok.push_back(t); + if (tok.empty()) continue; + + const char* err = nullptr; + if (tok.size() < 4) { + err = "expected ' ...'"; + } else { + std::string source = rio_lower(tok[0]); + std::string target = rio_lower(tok[2]); + bool toggle = false; + if (tok.size() >= 5 && rio_lower(tok[4]) == "toggle") toggle = true; + + if (source == "key" && target == "button") { + int sc = rio_key_scancode(tok[1]); + int addr = 0; + if (sc == SDL_SCANCODE_UNKNOWN) err = "unknown key name"; + else if (!rio_parse_addr(tok[3], addr)) err = "not a RIO input address (0x00-0x47, 0x50-0x6F)"; + else if (tok.size() > 5 || (tok.size() == 5 && !toggle)) err = "only 'toggle' may follow the address"; + else kb.push_back(RioKeyButtonBind{ sc, addr, toggle }); + } else if (source == "key" && target == "axis") { + int sc = rio_key_scancode(tok[1]); + int axis = 0; float val = 0; + if (sc == SDL_SCANCODE_UNKNOWN) err = "unknown key name"; + else if (!rio_axis_name(tok[3], axis)) err = "unknown RIO axis (Throttle/LeftPedal/RightPedal/JoystickY/JoystickX)"; + else if (tok.size() < 6) err = "key axis bindings need 'deflect|slew|rate|set '"; + else { + std::string mode = rio_lower(tok[4]); + int kax = mode == "deflect" ? RIO_KAX_DEFLECT + : (mode == "rate" || mode == "slew") ? RIO_KAX_RATE + : mode == "set" ? RIO_KAX_SET : -1; + // value may carry its sign as a separate token: "+ 2.5" + std::string vs = tok[5]; + if ((vs == "+" || vs == "-") && tok.size() > 6) vs += tok[6]; + if (kax < 0) err = "unknown key-axis mode (deflect/slew/rate/set)"; + else if (!rio_parse_float(vs, val)) err = "not a number"; + else ka.push_back(RioKeyAxisBind{ sc, axis, kax, val }); + } + } else if (source == "pad" && target == "button") { + uint16_t bit = 0; int addr = 0; + if (!rio_pad_button_name(tok[1], bit)) err = "unknown pad button"; + else if (!rio_parse_addr(tok[3], addr)) err = "not a RIO input address (0x00-0x47, 0x50-0x6F)"; + else if (tok.size() > 5 || (tok.size() == 5 && !toggle)) err = "only 'toggle' may follow the address"; + else pb.push_back(RioPadButtonBind{ bit, addr, toggle }); + } else if (source == "padaxis" && target == "axis") { + int src = 0, axis = 0; + bool invert = false; float dz = 0, rate = 0; + if (!rio_pad_axis_name(tok[1], src)) err = "unknown pad axis"; + else if (!rio_axis_name(tok[3], axis)) err = "unknown RIO axis (Throttle/LeftPedal/RightPedal/JoystickY/JoystickX)"; + else { + for (size_t i = 4; i < tok.size() && !err; i++) { + std::string opt = rio_lower(tok[i]); + if (opt == "invert") invert = true; + else if (opt == "deadzone") { + if (i + 1 >= tok.size() || !rio_parse_float(tok[++i], dz)) err = "'deadzone' needs a number"; + } else if (opt == "rate") { + if (i + 1 >= tok.size() || !rio_parse_float(tok[++i], rate)) err = "'rate' needs a number"; + } else err = "unknown padaxis option"; + } + if (!err) pa.push_back(RioPadAxisBind{ src, axis, invert, dz, rate }); + } + } else { + err = "unknown binding form (key/pad/padaxis ... button/axis)"; + } + } + if (err) { + bad++; + LOG_MSG("Serial%d: RIO bindings line %d: %s", (int)COMNUMBER, lineno, err); + } + } + + keyButtons.swap(kb); + keyAxes.swap(ka); + padButtons.swap(pb); + padAxes.swap(pa); + LOG_MSG("Serial%d: RIO bindings: %u key-button, %u key-axis, %u pad-button, %u pad-axis (%d bad line(s))", + (int)COMNUMBER, (unsigned)keyButtons.size(), (unsigned)keyAxes.size(), + (unsigned)padButtons.size(), (unsigned)padAxes.size(), bad); + return true; +} + +// modifier bits bound as cockpit inputs are exempt from chord passthrough +// (holding a bound LShift/LCtrl must not push other bound keys to DOS). +void CSerialRio::computeBoundModMask() { + boundModMask = 0; + for (size_t i = 0; i < keyButtons.size(); i++) + boundModMask |= rio_own_mod_bit(keyButtons[i].scancode); + for (size_t i = 0; i < keyAxes.size(); i++) + boundModMask |= rio_own_mod_bit(keyAxes[i].scancode); +} + +// ---- host keyboard routing ---------------------------------------------------- +bool CSerialRio::hostKeyEvent(int sc, bool pressed, bool repeat, unsigned int mods) { + // capture toggle keys (Pause / ScrollLock / togglekey:): always ours + // while the RIO is active. + for (int i = 0; i < 3; i++) { + if (toggleKeys[i] == 0 || sc != toggleKeys[i]) continue; + if (pressed && !repeat) { + captureKeys = !captureKeys; + if (!captureKeys) releaseAllKeys(); + LOG_MSG("Serial%d: RIO keyboard %s", (int)COMNUMBER, + captureKeys ? "CAPTURED -> cockpit panel" : "RELEASED -> DOS"); + logf("keyboard %s", captureKeys ? "captured" : "released"); + } + return true; + } + + if (pressed) { + if (heldKeys.count(sc)) return true; // repeats / dup downs of routed keys + if (repeat) return false; // repeat of a key we didn't route + if (!captureKeys) return false; + // host chords (Alt+Enter etc.) pass through -- but modifier bits that + // are BOUND as inputs (LShift/LCtrl throttle slew) don't count, and a + // modifier key never blocks on its own bit. + unsigned int chord = (mods & ~rio_own_mod_bit(sc)) & + ((KMOD_CTRL | KMOD_ALT | KMOD_GUI) & ~boundModMask); + if (chord) return false; + + bool bound = false; + for (size_t i = 0; i < keyButtons.size(); i++) { + if (keyButtons[i].scancode != sc) continue; + bound = true; + if (keyButtons[i].toggle) toggleAddress(keyButtons[i].addr); + else incHold(keyButtons[i].addr); + } + for (size_t i = 0; i < keyAxes.size(); i++) { + if (keyAxes[i].scancode != sc) continue; + bound = true; + if (keyAxes[i].mode == RIO_KAX_SET) // snap the position now; + rate_i[keyAxes[i].axis] = // deflect/rate act in pollInput + rio_clampNorm(keyAxes[i].axis, keyAxes[i].value); + } + if (!bound) return false; + heldKeys.insert(sc); + return true; + } + + // release: ours iff we routed the press, regardless of capture/mods now + if (!heldKeys.erase(sc)) return false; + for (size_t i = 0; i < keyButtons.size(); i++) + if (keyButtons[i].scancode == sc && !keyButtons[i].toggle) + decHold(keyButtons[i].addr); + return true; +} + +// focus loss releases every held key so no panel button sticks; toggle latches +// survive (vRIO ReleaseAllKeys semantics). +void CSerialRio::hostFocusLost() { + releaseAllKeys(); +} + +void CSerialRio::releaseAllKeys() { + std::set keys = heldKeys; + heldKeys.clear(); + for (std::set::const_iterator it = keys.begin(); it != keys.end(); ++it) + for (size_t i = 0; i < keyButtons.size(); i++) + if (keyButtons[i].scancode == *it && !keyButtons[i].toggle) + decHold(keyButtons[i].addr); +} + +void CSerialRio::toggleAddress(int addr) { + if (toggled.erase(addr)) decHold(addr); + else { toggled.insert(addr); incHold(addr); } +} + +// ---- board -> game queue helpers ------------------------------------------- +void CSerialRio::emit(const uint8_t* p, int n) { + for (int i = 0; i < n; i++) rxq.push_back(p[i]); +} +void CSerialRio::emitControl(uint8_t c) { rxq.push_back(c); } + +void CSerialRio::sendEvent(const uint8_t* p, int n) { + lastEventPacket.assign(p, p + n); // remembered so a NAK can re-send it + resends = 0; + emit(p, n); +} + +void CSerialRio::pressAddress(int addr) { + if (addr >= 0 && addr < 72) { // lamp-capable button + uint8_t p[3] = { CMD_BTN_PRESS, (uint8_t)addr, 0 }; + p[2] = rio_cs(p, 2); sendEvent(p, 3); + } else if ((addr >= 0x50 && addr <= 0x5F) || (addr >= 0x60 && addr <= 0x6F)) { + uint8_t padb = addr >= 0x60 ? 1 : 0; + uint8_t idx = (uint8_t)(addr - (padb ? 0x60 : 0x50)); + uint8_t p[4] = { CMD_KEY_PRESS, padb, idx, 0 }; + p[3] = rio_cs(p, 3); sendEvent(p, 4); + } + logf("press 0x%02X", addr); +} + +void CSerialRio::releaseAddress(int addr) { + if (addr >= 0 && addr < 72) { + uint8_t p[3] = { CMD_BTN_RELEASE, (uint8_t)addr, 0 }; + p[2] = rio_cs(p, 2); sendEvent(p, 3); + } else if ((addr >= 0x50 && addr <= 0x5F) || (addr >= 0x60 && addr <= 0x6F)) { + uint8_t padb = addr >= 0x60 ? 1 : 0; + uint8_t idx = (uint8_t)(addr - (padb ? 0x60 : 0x50)); + uint8_t p[4] = { CMD_KEY_RELEASE, padb, idx, 0 }; + p[3] = rio_cs(p, 3); sendEvent(p, 4); + } + logf("release 0x%02X", addr); +} + +void CSerialRio::setAxis(int axis, int value) { + if (value < -8192) value = -8192; + if (value > 8191) value = 8191; + axes[axis] = (int16_t)value; +} + +// ---- game -> board protocol ------------------------------------------------- +void CSerialRio::feedByte(uint8_t ch) { + if (parse_remaining != 0) { + if (ch & 0x80) { // high bit mid-packet = framing error + parse_remaining = 0; parse_count = 0; + logf("framing error 0x%02X mid-packet", ch); + return; + } + parse_buf[parse_count++] = ch; + parse_remaining--; + if (parse_remaining == 0) { + int bodyLen = parse_count - 1; // command + payload (exclude checksum) + uint8_t got = parse_buf[bodyLen]; + bool valid = rio_cs(parse_buf, bodyLen) == got; + onPacket(parse_buf, bodyLen, valid); + parse_remaining = 0; parse_count = 0; + } + return; + } + if (ch >= 0x80 && ch <= 0x8C) { // a command starts a packet + parse_count = 0; + parse_buf[parse_count++] = ch; + parse_remaining = rio_payload_len(ch) + 1; // + checksum byte + return; + } + onControl(ch); // ACK/NAK/RESTART/IDLE or garbage +} + +void CSerialRio::onPacket(const uint8_t* body, int bodyLen, bool checksumValid) { + if (!checksumValid) { + emitControl(CTL_NAK); + logf("RX cmd 0x%02X bad checksum -> NAK", body[0]); + return; + } + emitControl(CTL_ACK); + + uint8_t cmd = body[0]; + const uint8_t* pl = body + 1; + int plen = bodyLen - 1; + + switch (cmd) { + case CMD_CHECK_REQ: { + // init handshake: TestMode ENTER, a BoardOk per board, TestMode EXIT + uint8_t tm1[3] = { CMD_TESTMODE, 1, 0 }; tm1[2] = rio_cs(tm1, 2); emit(tm1, 3); + for (size_t i = 0; i < sizeof(kBoards); i++) { + uint8_t cr[4] = { CMD_CHECK_REPLY, 0 /*BoardOk*/, kBoards[i], 0 }; + cr[3] = rio_cs(cr, 3); emit(cr, 4); + } + uint8_t tm0[3] = { CMD_TESTMODE, 0, 0 }; tm0[2] = rio_cs(tm0, 2); emit(tm0, 3); + logf("RX CheckRequest -> test mode, %d boards OK, exit", (int)sizeof(kBoards)); + break; + } + case CMD_VER_REQ: { + uint8_t vr[4] = { CMD_VER_REPLY, verMajor, verMinor, 0 }; + vr[3] = rio_cs(vr, 3); emit(vr, 4); + logf("RX VersionRequest -> %u.%u", verMajor, verMinor); + break; + } + case CMD_ANALOG_REQ: { + if (analogMuted) { logf("RX AnalogRequest dropped (wedged)"); break; } + analogRequests++; + int16_t wy = invertY ? axes[3] + : (int16_t)std::min(8191, -(int)axes[3]); + uint8_t p[12]; p[0] = CMD_ANALOG_REPLY; + rio_split(axes[0], p[1], p[2]); // throttle + rio_split(axes[1], p[3], p[4]); // left pedal + rio_split(axes[2], p[5], p[6]); // right pedal + rio_split(wy, p[7], p[8]); // joystick Y (wire direction) + rio_split(axes[4], p[9], p[10]); // joystick X + p[11] = rio_cs(p, 11); emit(p, 12); + if ((analogRequests % 200) == 0) logf("analog polls served: %ld", analogRequests); + break; + } + case CMD_RESET_REQ: { + uint8_t target = plen >= 1 ? pl[0] : 0; + applyReset(target); + analogMuted = false; + logf("RX ResetRequest %u", target); + break; + } + case CMD_LAMP_REQ: { + if (plen >= 2) { + int lamp = pl[0]; uint8_t st = pl[1]; + if (lamp >= 0 && lamp < 72) { + lamps[lamp] = st; // stored for a future VDB lamp head + logf("RX Lamp 0x%02X = 0x%02X", lamp, st); + } + } + break; + } + default: + logf("RX unexpected cmd 0x%02X", cmd); + break; + } +} + +void CSerialRio::onControl(uint8_t b) { + switch (b) { + case CTL_ACK: + lastEventPacket.clear(); + resends = 0; + break; + case CTL_NAK: + if (lastEventPacket.empty()) { + logf("RX NAK (nothing pending)"); + } else if (resends < 4) { // v4.2 retry budget + resends++; + emit(lastEventPacket.data(), (int)lastEventPacket.size()); + logf("RX NAK -> resend (%d)", resends); + } else { + lastEventPacket.clear(); + resends = 0; + emitControl(CTL_RESTART); // give up (EmulateReplyWedge OFF) + logf("RX NAK -> retries exhausted, RESTART"); + } + break; + case CTL_RESTART: + logf("RX RESTART"); + break; + case CTL_IDLE: + break; // keep-alive noise + default: + logf("RX stray 0x%02X", b); + break; + } +} + +void CSerialRio::applyReset(uint8_t target) { + int idx = target == 1 ? 0 : target == 2 ? 1 : target == 3 ? 2 + : target == 4 ? 3 : target == 5 ? 4 : -1; + if (idx < 0) { + for (int i = 0; i < 5; i++) { axes[i] = 0; rate_i[i] = 0; last_sent_valid[i] = false; } + } else { + axes[idx] = 0; rate_i[idx] = 0; last_sent_valid[idx] = false; + } +} + +// ---- gamepad + axis input ---------------------------------------------------- +void CSerialRio::openPad() { + SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER); + int n = SDL_NumJoysticks(); + for (int i = 0; i < n; i++) { + if (!SDL_IsGameController(i)) continue; + if (pad_index >= 0 && i != pad_index) continue; + SDL_GameController* gc = SDL_GameControllerOpen(i); + if (gc) { + pad = gc; + logf("opened gamepad %d: %s", i, SDL_GameControllerName(gc)); + return; + } + } +} + +void CSerialRio::incHold(int addr) { + int c = holdCounts[addr]; + holdCounts[addr] = c + 1; + if (c == 0) pressAddress(addr); +} + +void CSerialRio::decHold(int addr) { + std::map::iterator it = holdCounts.find(addr); + if (it == holdCounts.end()) return; + if (it->second <= 1) { holdCounts.erase(it); releaseAddress(addr); } + else it->second--; +} + +void CSerialRio::pollInput() { + double now = PIC_FullIndex(); + double dt = last_tick_ms > 0.0 ? (now - last_tick_ms) / 1000.0 : 0.0; + last_tick_ms = now; + if (dt < 0.0) dt = 0.0; + if (dt > 0.1) dt = 0.1; + + // gamepad snapshot; everything rests at zero when absent/detached, so a + // detach releases whatever the pad held (button edges below see btns=0) + float src[6] = { 0, 0, 0, 0, 0, 0 }; + uint16_t btns = 0; + if (!pad && now >= next_pad_open_ms) { next_pad_open_ms = now + 1000.0; openPad(); } + if (pad) { + SDL_GameController* gc = (SDL_GameController*)pad; + if (!SDL_GameControllerGetAttached(gc)) { + SDL_GameControllerClose(gc); pad = nullptr; + logf("gamepad detached"); + } else { + SDL_GameControllerUpdate(); + src[PS_LX] = rio_clampf( SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_LEFTX) / 32767.0f); + src[PS_LY] = rio_clampf(-SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_LEFTY) / 32767.0f); + src[PS_RX] = rio_clampf( SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_RIGHTX) / 32767.0f); + src[PS_RY] = rio_clampf(-SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_RIGHTY) / 32767.0f); + src[PS_LT] = rio_clampf( SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_TRIGGERLEFT) / 32767.0f); + src[PS_RT] = rio_clampf( SDL_GameControllerGetAxis(gc, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) / 32767.0f); + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_A)) btns |= 0x1000; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_B)) btns |= 0x2000; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_X)) btns |= 0x4000; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_Y)) btns |= 0x8000; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_DPAD_UP)) btns |= 0x0001; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) btns |= 0x0002; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) btns |= 0x0004; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) btns |= 0x0008; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_START)) btns |= 0x0010; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_BACK)) btns |= 0x0020; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_LEFTSTICK)) btns |= 0x0040; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_RIGHTSTICK)) btns |= 0x0080; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) btns |= 0x0100; + if (SDL_GameControllerGetButton(gc, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) btns |= 0x0200; + } + } + + // pass 1: advance rate integrators (throttle-style axes; pad + held rate keys) + for (size_t i = 0; i < padAxes.size(); i++) { + const RioPadAxisBind& b = padAxes[i]; + if (b.rate > 0.0f) + rate_i[b.axis] = rio_clampNorm(b.axis, + rate_i[b.axis] + rio_shape(src[b.src], b.dz, b.invert) * b.rate * (float)dt); + } + for (size_t i = 0; i < keyAxes.size(); i++) { + const RioKeyAxisBind& b = keyAxes[i]; + if (b.mode == RIO_KAX_RATE && heldKeys.count(b.scancode)) + rate_i[b.axis] = rio_clampNorm(b.axis, rate_i[b.axis] + b.value * (float)dt); + } + // pass 2: compose each axis (rate + held deflect keys + direct pad) and + // push it if it moved + for (int a = 0; a < 5; a++) { + float total = rate_i[a]; + for (size_t i = 0; i < keyAxes.size(); i++) { + const RioKeyAxisBind& b = keyAxes[i]; + if (b.mode == RIO_KAX_DEFLECT && b.axis == a && heldKeys.count(b.scancode)) + total += b.value; + } + for (size_t i = 0; i < padAxes.size(); i++) { + const RioPadAxisBind& b = padAxes[i]; + if (b.axis == a && b.rate <= 0.0f) + total += rio_shape(src[b.src], b.dz, b.invert); + } + total = rio_clampNorm(a, total); + int raw = (int)lround(total * rio_fullTravel(a)); + if (!last_sent_valid[a] || raw != last_sent[a]) { + setAxis(a, raw); + last_sent[a] = raw; + last_sent_valid[a] = true; + } + } + // pad buttons: edge-detect and route through hold counts + uint16_t changed = btns ^ prevPadButtons; + if (changed) { + for (size_t i = 0; i < padButtons.size(); i++) { + const RioPadButtonBind& b = padButtons[i]; + if (!(changed & b.bit)) continue; + bool down = (btns & b.bit) != 0; + if (b.toggle) { if (down) toggleAddress(b.addr); } + else if (down) incHold(b.addr); + else decHold(b.addr); + } + prevPadButtons = btns; + } +} + +// ---- UART plumbing (mirrors serialnamedpipe / directserial) ----------------- +bool CSerialRio::doReceive() { + if (rxq.empty()) return false; + uint8_t b = rxq.front(); + rxq.pop_front(); + receiveByteEx(b, 0); + return true; +} + +void CSerialRio::handleUpperEvent(uint16_t type) { + switch (type) { + case SERIAL_POLLING_EVENT: { + setEvent(SERIAL_POLLING_EVENT, rx_poll_ms); + pollInput(); + // directserial's RX state machine, delivering queued board->game bytes + switch (rx_state) { + case P_RX_IDLE: + if (CanReceiveByte()) { + if (doReceive()) { + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + } else { + rx_state = P_RX_BLOCKED; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + break; + case P_RX_BLOCKED: + if (!CanReceiveByte()) { + rx_retry++; + if (rx_retry >= rx_retry_max) { + rx_retry = 0; + removeEvent(SERIAL_RX_EVENT); + if (doReceive()) { + while (doReceive()); + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } else { + rx_state = P_RX_IDLE; + } + } + } else { + removeEvent(SERIAL_RX_EVENT); + rx_retry = 0; + if (doReceive()) { + rx_state = P_RX_FASTWAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + } else { + rx_state = P_RX_IDLE; + } + } + break; + case P_RX_WAIT: + case P_RX_FASTWAIT: + break; + } + break; + } + case SERIAL_RX_EVENT: { + switch (rx_state) { + case P_RX_IDLE: + LOG_MSG("internal error in serialrio"); + break; + case P_RX_BLOCKED: + case P_RX_WAIT: + case P_RX_FASTWAIT: + if (CanReceiveByte()) { + rx_retry = 0; + if (doReceive()) { + if (rx_state == P_RX_WAIT) + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + else { + rx_state = P_RX_FASTWAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + } + } else { + rx_state = P_RX_IDLE; + } + } else { + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + rx_state = P_RX_BLOCKED; + } + break; + } + break; + } + case SERIAL_TX_EVENT: { + if (rx_state == P_RX_IDLE && CanReceiveByte()) { + if (doReceive()) { + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + } + ByteTransmitted(); + break; + } + case SERIAL_THR_EVENT: { + ByteTransmitting(); + setEvent(SERIAL_TX_EVENT, bytetime * 1.1f); + break; + } + } +} + +void CSerialRio::updatePortConfig(uint16_t divider, uint8_t lcr) { + (void)divider; (void)lcr; // the board is fixed 9600 8N1; base tracks bytetime +} + +void CSerialRio::updateMSR() { + // modem-in lines are static (board present); nothing to poll +} + +void CSerialRio::transmitByte(uint8_t val, bool first) { + if (first) setEvent(SERIAL_THR_EVENT, bytetime / 10); + else setEvent(SERIAL_TX_EVENT, bytetime); + feedByte(val); // hand the game's byte to the board protocol +} + +void CSerialRio::setBreak(bool value) { + (void)value; // RIO doesn't use break +} + +void CSerialRio::setRTSDTR(bool rts, bool dtr) { + (void)rts; + setDTR(dtr); +} + +void CSerialRio::setRTS(bool val) { + (void)val; // board ignores host RTS +} + +void CSerialRio::setDTR(bool val) { + if (val == dtr_state) return; + bool rising = val && !dtr_state; + dtr_state = val; + if (rising) { + // the game pulses DTR to hard-reset the board; clear comms state so + // the following CheckRequest handshake starts clean. + parse_remaining = 0; parse_count = 0; + lastEventPacket.clear(); resends = 0; + analogMuted = false; + rxq.clear(); + logf("DTR reset"); + } +} diff --git a/emulator/vpx-device/serialrio.h b/emulator/vpx-device/serialrio.h new file mode 100644 index 00000000..aa1ce71a --- /dev/null +++ b/emulator/vpx-device/serialrio.h @@ -0,0 +1,177 @@ +/* + * VWE fork: in-fork virtual RIO cockpit board (serial1=rio). + * + * A native DOSBox-X serial backend that *is* the cockpit RIO board: it speaks + * the device side of the RIO 9600-8N1 protocol to the game's UART, and sources + * its pilot input from an SDL game controller + the host keyboard inside the + * emulator. There is no external process and no pipe, so the game<->board + * round trip happens entirely in-process at the emulated UART's own byte + * cadence -- the same timing a real ISA UART sees on a physical pod, which + * removes the whole class of serial / pipe round-trip-latency dropouts + * (livelock, TXMAXIDLE, rxburst, 15s retry). + * + * The protocol state machine + input mapping are transcribed from the + * validated vRIO app (C:\VWE\vrio VRio.Core: VRioDevice, the Protocol codec, + * Input/InputRouter + BindingProfileFormat), minus its transport, wire pacer, + * panel UI and thread locks -- CSerial callbacks, the SDL reads and the host + * keyboard hook all run on the single emulator thread. + * + * Keyboard field (B1): sdlmain's SDL2 event loop offers every key to + * RIO_HostKeyEvent() first. While capture is ON (default; PAUSE or + * SCROLL LOCK toggles it -- plus an optional `togglekey:`) a key bound + * in the profile presses its RIO address (button 0x00-0x47 / keypad + * 0x50-0x6F) or drives an axis, and is swallowed before the DOS keyboard + * sees it; unbound keys and Ctrl/Alt/Gui chords pass through (bound modifier + * keys are exempt from the chord rule), so DOSBox-X host hotkeys keep + * working. Toggling capture OFF releases everything held and gives the whole + * keyboard back to DOS (needed at the netnub loop / DOS prompts). + * + * Bindings: the built-in default profile = vRIO's pad + button field + * (number/QWERTY rows = upper MFD bank, home/bottom rows = lower bank, + * F1-F12 = Secondary/Screen columns, arrows+Space = hat+main) plus keyboard + * FLIGHT controls (operator 2026-07-22): numpad 8/2/4/6 = stick, 7/9 = + * pedals, LShift/LCtrl = throttle slew, numpad-5 = throttle zero; the + * internal keypad is unbound (mission-review-only plumbing). A bindings file + * in vRIO's grammar (`key button [toggle]` / `key axis + * deflect|slew|rate|set ` / `pad