Files
riojoy/docs/PLAN.md
T
CydandClaude Fable 5 bdd30678e3 pod: fully explicit activation (--profile) + RIOJoy.Tray.Ready signal
No detection anywhere in the pod chain (Cyd: foreground detection is too
slow - the pad appears after the game already enumerated controllers).
--profile <name> activates immediately at startup, never runs the
auto-switch watcher, and on success signals the named manual-reset event
RIOJoy.Tray.Ready (process-lifetime, never stale) so a launcher waits on
the signal instead of counting winmm devices. Legible failures for the
launcher: exit 4 unknown profile, exit 5 activation failed with the reason
on stderr - both verified live against the built exe. Editor close
re-activates the explicit profile. build-pod start scripts now pass
--profile <Name> --exit-with <exe>; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:01:44 -05:00

37 KiB
Raw Blame History

RIOJoy — modernization plan

Modernize the cockpit RIO interface app for Windows 10/11, removing the vJoy dependency and replacing it with a custom virtual HID device, rewritten in C#/.NET as a background tray app with per-game profiles.

The legacy app is preserved under legacy/ as the behavioral reference. The RIO wire format and input map are documented in PROTOCOL.md.


Purpose

The cockpits run two native games (Firestorm, Red Planet) that talk to the RIO hardware directly and never use this app. RIOJoy exists to broaden which other games can run in the cockpits: arbitrary games don't know about the cockpit's extra hardware (5 analog axes, 96 lighted buttons, the plasma/VFD display, the labeled wallpaper), so RIOJoy bridges the RIO to whatever input those games do understand — joystick, keyboard, and mouse — and drives the cockpit's outputs on their behalf.


Target architecture

   ┌────────────────── C# / .NET Framework 4.8 tray app (x64) ─────────────────┐
   │  Serial (RIO protocol)   →  Input mapper (profile)   →  Output router      │
   │   COM port, 9600 8N1         72 inputs + keypads          ├─ Keyboard/Mouse: SendInput (P/Invoke)
   │   packet parse + ACK/NAK     decode iRIO bitfield         ├─ Joystick: HID report → DeviceIoControl ↓
   │   analog poll + recovery     axis calibration            └─ Lamps: LampRequest back over serial
   │  Plasma/VFD on 2nd COM   ·  Profiles + auto-switch + tray UI + logging      │
   └───────────────────────────────────────────────┬───────────────────────────┘
                                                     │ IOCTL (input report bytes)
   ┌─────────────────────────────────────────────────▼──────────────────────────┐
   │  RioGamepad.sys  — KMDF + VHF (vhf.sys) virtual HID device                   │
   │  Report descriptor: X,Y,Z,Rx,Ry,Rz (16-bit) · 1 hat · 96 buttons            │
   │  Control device + custom IOCTL  →  VhfReadReportSubmit()  →  Windows sees    │
   │                                                              a HID gamepad   │
   └─────────────────────────────────────────────────────────────────────────────┘

Decisions (confirmed)

  • Virtual joystick: custom VHF/UMDF HID driver (full fidelity: 6 axes, 96 buttons, 1 hat — exactly the legacy vJoy layout). Not ViGEm (can't hold 96 buttons).
  • Stack: C# / .NET Framework 4.8, x64 — in-box on Windows 10/11, so deployed builds are framework-dependent with no runtime to install. (Modern C# language features that net48 lacks are supplied by the PolySharp source generator + a few NuGet shims; see src/RioJoy.Core/Compat/.) Driver is C (WDK), separate toolchain.
  • Form: background tray app (NotifyIcon); start/stop managed by the TeslaConsole launcher (no logon auto-start).
  • Targets: Windows 10/11 x64 only. The legacy x86 / WinXP targets are dropped.

What ports over vs. what's new

Concern Legacy Modern
Serial + RIO protocol overlapped I/O + watch thread SerialPort + async loop; faithful port of the packet state machine
Input decode iRIO[] bitfield + Press_V2 same semantics, ported to C#
Keyboard/mouse SendInput scancode SendInput via P/Invoke (≈verbatim)
Joystick vJoy SetAxis/SetBtn/SetDiscPov HID report → IOCTL → RioGamepad.sys
Lamps LampRequest over serial same
Axis calibration UpdateJoystick/Throttle/Padal ported math
Plasma display CPlasma (COM2) ported; content per-profile
Config SimpleIni, single file, hard-coded COM1 profile library (JSON), configurable ports; importer for legacy RIO.ini

Profiles (the core abstraction)

A profile fully describes how the cockpit behaves for one non-native game. Everything is per-profile, not global:

  • Button/keypad mapping (the decoded iRIO table for this game)
  • Axis calibration, curves, and invert flags
  • Lamp behavior
  • Plasma/VFD content (or "off")
  • Cockpit overlay labels + the generated wallpaper (Phase 7)
  • Display/resolution targets

Profiles never describe the native games — those are the "hands-off" case.

Serial-port yield & the auto-switch state machine

Because the native games own the RIO's COM port directly, RIOJoy must yield it. A process/window watcher drives three states:

Detected running app RIOJoy behavior
Native game (Firestorm, Red Planet) Release COM port, go fully dormant — no serial, no HID, no overlay
Supported non-native game Acquire port, load that game's profile, drive HID / keyboard / mouse / lamps / plasma / wallpaper
Nothing / desktop Idle — port released or a configurable neutral default

Config therefore holds: the per-game profile library, a list of native games to yield to, and the executable→profile match rules. Manual override from the tray menu is always available.


Phases

Phase 0 — Repo & scaffold (this commit)

  • git init, remote https://gitea.mysticmachines.com/VWE/RIOJoy.git.
  • Legacy C++ moved to legacy/; cockpit art to docs/reference/.
  • Solution RioJoy.sln with src/RioJoy.Core (lib) + src/RioJoy.Tray (tray app); driver/ placeholder for the WDK project.
  • This plan + PROTOCOL.md.

Phase 1 — Virtual HID driver — test-signed, installed, verified

Implemented in driver/RioGamepad/; builds to RioGamepad.sys against the EWDK (KMDF 1.15 + VHF, x64, warnings-as-errors).

  • KMDF + VHF virtual HID gamepad: report descriptor = 6×16-bit axes, 1 hat, 96 buttons (ReportDescriptor.h).
  • Device interface + custom IOCTL_RIO_SUBMIT_REPORTVhfReadReportSubmit; the driver is a thin relay, with the 25-byte report layout pinned in Public.h.
  • C# side of the contract: RioJoy.Core.Hid.RioHidReport packs axes/hat/buttons into that exact report (unit-tested). Replaces the throwaway test harness idea.
  • Test-signed + pnputil-installed on the cabinet; INF declares vhf as a lower filter (LowerFilters AddReg) — without it VhfCreate fails and the device shows Code 31. The EWDK's in-build catalog/sign tasks are bypassed; the .cat is made with inf2cat/signtool at install time (driver/*.ps1, driver/README.md).
  • End-to-end verified: the real HidFeederJoystickSink opens the device and submits reports; axes (min/mid/max), buttons, and the POV hat all read back correctly through winmm joyGetPosEx / joy.cpl. The controller's joy.cpl name is set via the DirectInput OEMName registry value at install (VHF can't supply a HID product string).
  • Remaining: none for the driver itself; redistribution off owned cabinets would need attestation signing (Phase 6).

Phase 2 — Serial + RIO protocol core (RioJoy.Core) — code-complete

Implemented in src/RioJoy.Core/Protocol + Serial, covered by tests/RioJoy.Core.Tests (xUnit, 54 tests):

  • Packet parser/builder: command/length table, 7-bit checksum, control chars, framing resync on a high-bit byte mid-packet (PacketParser, PacketBuilder).
  • Typed RIO→PC decodes: AnalogReport (14-bit sign-extend), VersionInfo, CheckStatus; lamp-state composition (RioLampState).
  • RioSerialLink: async receive loop with ACK/NAK policy (legacy force-accept vs. opt-in VerifyInboundChecksum), analog poll timer + >5 s reset-recovery.
  • IRioTransport abstraction with a SerialPort-backed implementation (9600 8N1, DTR reset pulse); clean COM-port acquire/release = create/dispose the transport (foundation for serial yield). The read loop is tested against an in-memory fake transport.
  • Remaining: verify against real hardware (version reply, check reply, analog stream) — needs a cabinet; can't be done off-device.

Phase 3 — Input mapping + output routing — code-complete

Implemented in src/RioJoy.Core/Mapping + Output, covered by the Mapping tests (xUnit, 84 tests total):

  • RioMapEntry decodes the 16-bit iRIO word (flags + value) and resolves the routing Kind by the legacy precedence (joy+hat+mouse ⇒ RIO command; none ⇒ keyboard; else joy → hat → mouse).
  • RioAddress (button + keypad→address offsets) and RioInputMap (112-entry per-profile table) replace the hard-coded iRIO[].
  • InputRouter ports Press_V2/Release_V2: modifier ordering, scancode keys, joystick buttons, POV hat, mouse move/click, RIO-command dispatch, and lamp feedback (bright on press / dim on release; RIO commands carry none).
  • Output is split behind sink interfaces (IInputSink, IJoystickSink, ILampSink, IRioCommandSink) so routing is pure and unit-tested; SendInputSink is the real SendInput keyboard/mouse adapter.
  • The joystick sink's real adapter — the HID feeder → RioGamepad driver via DeviceIoControl (Output/HidFeederJoystickSink) — is implemented, wired in (RioCoordinator selects it when the driver is present, else NullJoystickSink), and verified end-to-end against the installed driver (Phase 1).
  • Remaining: the legacy default map / RIO.ini becomes an importable profile (Phase 5/7).

Phase 4 — Axis calibration + plasma display — code-complete

Implemented in src/RioJoy.Core/Calibration + Plasma (105 xUnit tests total):

  • AxisCalibrator ports UpdateThrottle/UpdatePadal/UpdateJoystick: throttle deadzone + ratchet field, pedal deadzones, X/Y auto-ranging from observed min/max, rudder mixing (enableZR), and all per-axis invert flags. Stateful (start positions, last outputs) like the legacy globals, with the RIOcmd axis resets. Outputs clamp to the documented 0..32766 range.
  • IJoystickSink gains SetAxis(JoyAxis, value) so calibrated axes reach the HID feeder; AxisOutputs carries the six values.
  • PlasmaCommands ports the CPlasma ESC command set (clear/cursor/font/attr/box draw+fill/text) + GetFontSize + the PlasmaPosText auto-fit/centering; PlasmaDisplay writes them over the secondary COM transport.
  • Remaining: hardware verification of axis feel + plasma output. Runtime plasma wiring (secondary port open, greeting, teardown) landed in Phase 9; the legacy game-specific PlasmaScoreDraw layout is superseded by the Phase 9 feedback endpoint (external clients draw score/status content — docs/FEEDBACK.md).

Phase 5 — Tray app + profiles — code-complete

Core logic in src/RioJoy.Core/Profiles + RioRuntime; UI/OS in src/RioJoy.Tray (241 xUnit tests total across the suite):

  • RioProfile + AppConfig model; ConfigStore JSON persistence (round-trip tested); RioIniImporter ports the legacy RIO.ini (buttons/inverts/greeting).
  • AutoSwitchResolver + AutoSwitchWatcher: the three-state decision (Yield native / Activate profile / Idle) from the foreground executable, native always winning; raises only on change. Pure + tested.
  • RioRuntime assembles a profile's live pipeline: serial button/keypad packets → InputRouter; analog replies → AxisCalibrator → the six joystick axes; RIO commands → calibration resets + serial requests + lamp re-init. End-to-end tested over the fake transport.
  • Tray: NotifyIcon menu mirroring the legacy console menu (axis resets, version/status, diagnostic toggles, quit) + profile selection (auto vs. manual); RioCoordinator owns the serial acquire/release tied to the watcher (native-game COM-port yield); OS adapters (ForegroundProcessProvider). The app's start/stop lifecycle is owned by the TeslaConsole launcher (no logon auto-start).
  • Joystick output now uses the real HidFeederJoystickSink when the driver is present (verified end-to-end); NullJoystickSink remains only as the no-driver fallback.
  • Per-profile ViGEm axis routing — code-complete . RioProfile.AxisRouting (Output/AxisRoutingConfig: one route per calibrated axis = pad target — four thumbs, two triggers, or None — + output mode; shared types with no ViGEm references, so net40 keeps compiling) re-routes the six axes on the Xbox 360 pad. Modes: Centered (legacy bipolar) and UnipolarPositive (calibrated 0 → thumb center — for the ratcheted unipolar throttle). Null/ absent = the historical fixed routing, so existing profiles are untouched. Resolution + conversion is the pure unit-tested AxisRouter; ViGEmJoystickSink.SetRouting applies it thread-safely and neutralizes all thumbs/triggers each profile switch (no stale axes); wired in RioCoordinator.Activate beside the calibrator config. First consumer: profiles/descent-d1x.json (throttle→RightThumbY unipolar, rudder→ RightThumbX, triggers untouched — DXX fires on the LT/RT axis-buttons). The HID feeder (native 6-axis report) intentionally ignores routing. Remaining: on-cabinet throttle/rudder feel check (first Descent flight).
  • Remaining: full on-cabinet verification of the auto-switch + acquire/release lifecycle against real RIO hardware.

Phase 6 — Packaging / signing / deploy — done

  • Driver test-signing + pnputil install scripted (driver/sign.ps1, driver/install.ps1, driver/uninstall.ps1, driver/README.md); proven on the cabinet.
  • Deployment package (deploy/): build-package.ps1 produces dist/RIOJoy-<version>.zip (framework-dependent net48 app + postinstall.bat / install-rio.ps1 / pre-uninstall.bat / uninstall-rio.ps1, all idempotent) with the cabinet doc README-DEPLOY.txt. Deployed builds use the signed ViGEmBus virtual controller (Xbox 360 layout, 11 buttons) so no test signing / Secure Boot change / reboot is needed; the custom RioGamepad driver remains the full-fidelity option for owned cabinets. App lifecycle is owned by the TeslaConsole launcher (no auto-start). Verify remaining: first real deploy on a cabinet via TeslaConsole.

Phase 7 — Profile/mapping editor + cockpit overlay generator — in progress

Replaces the legacy Google-Sheet → .data → GIMP → Script-Fu pipeline (see docs/reference/customBackground/).

  • Overlay generator — done , verified on real assets. RioJoy.Core/Overlay is the pure, unit-tested layout engine: FontFitter is a faithful port of the calc-fontsize auto-fit search (validated against a brute-force oracle), OverlayLayoutEngine ports create-data-layer's fit + horizontal/vertical justification, and OverlayTemplate/OverlayRegion (a regions.json via OverlayTemplateStore) hold the cell geometry/color. Label text is per-profile (RioProfile.OverlayLabels); GoobieDataImporter reads the legacy .data sheet into label rows. The rasterizer lives in src/RioJoy.Overlay (SkiaSharp): SkiaTextMeasurer (shared with the engine so measured layout == drawn output) + SkiaOverlayRenderer (draw labels → PNG). The full chain is exercised end-to-end on the real cockpit art (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests. RioJoy.Tray/WallpaperApplier applies the result via SystemParametersInfo.
  • Region authoring — done . tools/XcfRegionExtract parses the GIMP source (riojoy.xcf) and writes the 119-cell docs/reference/customBackground/regions.json (per-layer offsets/size/font/color, BaseImagePath → exported riojoy.png), anchored by CockpitRegionsTests. The in-app box editor lives in the wallpaper maker ("Edit cell boxes"): the selected cell grows resize handles — drag to move/resize (plain clicks still cycle stacked cells), or type exact X/Y/W/H — with the move/resize math in RioJoy.Core/Overlay/OverlayRegionEdit (unit-tested, min-size clamped). "Save template" persists the shared geometry back to the regions.json; "Reload template" discards unsaved box edits.
  • Runtime wiring — done (opt-in). RioJoy.Overlay/ProfileWallpaperGenerator renders a profile's labels onto the template base image; RioCoordinator generates + applies the wallpaper on profile activation when AppConfig.OverlayTemplatePath is set (best-effort, off by default, never breaks activation). The live SystemParametersInfo apply changes a user setting, so it is gated behind config and not exercised by tests. Restore-on-dormant — done : RioCoordinator captures the user's own wallpaper (WallpaperApplier.GetCurrent) the first time it overrides it, and puts it back (WallpaperApplier.Restore) on every GoDormant and on Dispose — so idle/native-game/exit return the desktop to what the user had. Capture is once-per-override (switching between cockpit profiles never records a cockpit wallpaper as the "previous"); only engages when OverlayTemplatePath is set. Verified 2026-07-19 on Win11 by a capture→apply→ restore round-trip against the compiled WallpaperApplier — including the empty-wallpaper case (GetCurrent'', apply, Restore("") clears back to the original solid-color desktop). Known gap: a hard crash between apply and restore leaves the cockpit wallpaper (SPIF_UPDATEINIFILE persists it) — a future crash-recovery could persist the saved path to config.
  • Wallpaper maker — done . RioJoy.Tray/Editor/WallpaperMakerForm (tray → "Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline: it renders the profile's wallpaper live on the template base image, outlines all 119 cells, and lets the user click any cell — including the heading/banner cells the button editor can't reach — to edit its text with debounced re-render. Clicking a stacked cell cycles through the regions at that pixel (RioJoy.Core/Overlay/OverlayHitTester, unit-tested — stacking is legitimate: the image feeds six chroma-split displays). Imports a legacy .data sheet row (game picker for multi-row sheets), exports the PNG, and can apply the desktop wallpaper immediately (same per-profile path the runtime uses). Prompts for and remembers AppConfig.OverlayTemplatePath on first use.
  • Mapping editor — done (cockpit control-panel layout). RioJoy.Tray/Editor/ProfileEditorForm shows the cockpit as a clickable control panel matching the original Win32 RIO design (docs/Win32RIO/, by FASA/Michel Lowrance): five MFD clusters, four board columns (Throttle/Secondary/Screen/ Joystick-Hat), an encoder-gauge strip, and the two later-added 4×4 keypads (rendered without lamps). The layout (RioJoy.Core.Editing.CockpitPanel, which places every address 0x000x47 / 0x500x6F exactly once, unit-tested) deliberately follows the design, not the wallpaper positions (those are a VGA chroma-split display artifact). Lamp buttons shade Off/assigned; keypads are neutral. Clicking a button edits its label and action/modifiers/lamp; the iRIO word goes through ButtonBindingRioMapEntry.Create — no hex. The value field is a context-sensitive picker (keyboard key by name via KeyCatalog, joystick Button N, hat direction, mouse/RIO-command enum); modifiers enable only for keyboard. Opened from the tray ("Edit profile…"); Save persists the profile. The encoder gauges are live: RioRuntime.AxesUpdated streams an AxisReadout (the six virtual-joystick outputs plus the calibrated pre-mix pedal positions) into the strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects from its center tick, and the X/Y box tracks the stick as a dot (pure fraction math in RioJoy.Core.Editing.AxisGauges, unit-tested). L/R read the pedals from before the ZR mix, since the mix pins Rx/Ry to center. Still to refine: showing each button's assigned key as a caption, grouping a button's two bank addresses, and clone-from-existing.
  • The unified profile JSON supersedes both RIO.ini and the Google Sheet, with importers for each.
  • To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs. live overlay (e.g. lit-button highlighting mirroring lamp state).

Phase 8 — Windows XP compatibility (dual-target) — in progress

(8A/8C/8D done ; remaining: 8B driver + 8E XP-VM/cabinet verification) Bring RIOJoy back to the original XP-era cabinets (x86, XP SP3) without regressing Windows 10/11. Strategy: one codebase, two flavors, one universal deployment archive. The mapping editor ships everywhere (decided); only wallpaper generation (SkiaSharp) stays modern-only — XP consumes pre-rendered wallpapers.

Windows 10/11 (unchanged) Windows XP SP3
TFM / arch net48, x64 net40, x86 (.NET 4.0 is XP's ceiling; 4.5+ needs Vista)
Virtual joystick ViGEm → RioGamepad → none (unchanged) RioGamepadXP.sys — our own thin WDM HID minidriver (same feeder contract) — full 6-axis/96-button fidelity
Overlay render SkiaSharp (generate + apply) consume pre-rendered wallpaper only (PNG→BMP — XP's SystemParametersInfo takes BMP only)
Editor full (incl. wallpaper maker) mapping editor included (decided; pure WinForms/GDI+); wallpaper maker gated (Skia)
JSON System.Text.Json → Newtonsoft 13 Newtonsoft 13 (STJ needs net461+; one serializer for both flavors)
Install scripts PowerShell .bat only (XP has no in-box PowerShell)
  • 8A — Core retarget — done (net48;net40 multi-target): de-Span the protocol/serial layer (≈20 uses / 11 files → byte[]/ArraySegment; System.Memory doesn't go below net45, and at 9600 baud Span buys nothing); swap System.Text.Json → Newtonsoft in ConfigStore/OverlayTemplateStore (both TFMs, so the config format can't drift); async on net40 via Microsoft.Bcl.Async + a Compat/TaskCompat shim (6 call sites: Task.Run/Delay/WhenAny/WhenAll → TaskEx; XP prereq: KB2468871, bundle it); shim the one HashCode.Combine; #if-gate the net48-only sinks (ViGEmJoystickSink, HidFeederJoystickSink, Hid/). Risk fallback: if Bcl.Async misbehaves on real XP, the receive loop reverts to a dedicated thread (the legacy CommWatchProc shape) for net40.
  • 8B — RioGamepadXP.sys — built (static; XP bring-up pending in 8E). Source in driver/RioGamepadXP/: a WDM HID minidriver (HidRegisterMinidriver, polled mode so there's no pending- IRP/cancel machinery) presenting the 6-axis/hat/96-button joystick, plus a named sideband control device (\\.\RioGamepadXP) that takes IOCTL_RIO_SUBMIT_REPORT — the identical Public.h contract as the modern driver (same 25-byte report, VID/PID). A dispatch wrapper routes the control device's CREATE/CLOSE/DEVICE_CONTROL to us and forwards the HID FDO's to hidclass. Builds with WDK 7.1.0 (build.cmdsetenv … fre x86 WXP no_oacr) to RioGamepadXP.sys (x86, subsystem 5.01). XP enforces no kernel signing, so install is unsigned; the device is root-enumerated so it needs devcon (devcon install RioGamepadXP.inf root\RioGamepadXP, also built from the WDK) rather than InstallHinfSection. The net40 feeder opens the driver by name (#if NET40 branch in HidFeederJoystickSink); XP can't register a device interface on a bare control device (no PDO), the one contract nuance. Bundled into the package's vendor\xp\. Precedent: the original FASA tasgame.sys was itself an XP HID minidriver (docs/Win32RIO/, analyzed); ours stays thin with serial in user mode. No third-party virtual joystick (vJoy/PPJoy unmaintained). Acquisition order: net48 ViGEm → RioGamepad → Null (unchanged); net40 RioGamepadXP → Null. 8E: the driver compiles clean but is unverified at runtime — needs an XP target to confirm joy.cpl enumeration + report flow. Staging: the app is useful before the driver — milestone 1 ships keyboard/mouse + lamps + plasma (joystick = Null sink) if the driver isn't present.
  • 8C — Tray on net40/x86 — done : multi-target RioJoy.Tray (net40 drops the ViGEm + RioJoy.Overlay references); gate WallpaperMakerForm + overlay generation; WallpaperApplier converts PNG→BMP via GDI+ before SystemParametersInfo (harmless on 10/11, required on XP); profile editor stays (Segoe UI falls back to Tahoma). XP cabinets consume wallpapers pre-rendered on a modern machine (RioProfile.WallpaperPath travels with the config).
  • 8D — Packaging — done (one universal archive, two install entry points). build-package.ps1 produces a single RIOJoy-<ver>.zip (~75 MB with the offline XP redistributables) that deploys on both XP and 10/11; the RioGamepadXP driver files bundle automatically once 8B lands (warn/skip until then). The shared OS-detecting install logic lives in RIOJoy\install-core.bat (pure cmd on the XP path); shortcuts via make-shortcut.vbs (works on XP and 10/11 alike):
    • Layout: RIOJoy\app\ (net48 x64) + RIOJoy\app-xp\ (net40 x86) + RIOJoy\vendor\ (ViGEmBus installer for 10/11; RioGamepadXP.sys + INF for XP; .NET 4.0 Full + KB2468871 redistributables so an offline XP cabinet needs nothing else — adds ~70 MB, XP can't download anymore) + VERSION.txt + README-DEPLOY (payload-internal detail doc).
    • README.txt (zip root): written for a person installing on a standalone computer — which Windows versions are supported, what the two .bat entry points are and which one to run (install.bat for a standalone machine; postinstall.bat is the cabinet launcher's hook), that all prerequisites are bundled for offline install, and where the app lands. Plain ASCII so XP-era Notepad renders it cleanly.
    • postinstall.bat (zip root, unattended): the TeslaConsole/launcher entry point, as today — detects the OS (ver → 5.1 = XP, 10.x = modern), installs the matching prereqs (ViGEmBus silently via the existing PowerShell on 10/11; .NET 4.0 + KB2468871 + driver INF on XP), and wires the matching app flavor. No prompts, idempotent. Its final step deletes install.bat and the root README.txt — on cabinet deploys the zip extracts into C:\games, and only launcher-managed files may stay at that root (del tolerating already-missing files, so re-runs stay idempotent).
    • install.bat (zip root, freestanding computers): same OS detection and prereq install, plus what a machine without the launcher needs — Start Menu/desktop shortcut to the right flavor's exe (still no logon auto-start; starting RIOJoy stays deliberate). Pure cmd.exe on the XP path; may call PowerShell only on the 10/11 path. Leaves the README in place (it's the standalone machine's documentation).
    • pre-uninstall.bat / uninstall mirror both scenarios. Release the one zip to Gitea per the established process.
  • 8E — Verification: full suite stays net48-hosted (xUnit needs net452+; shared sources are what's tested) + a tiny net40 console self-test for the shims, run on XP. Ladder: net40/x86 binary boots on Win10 → XP VM with vRIO over a virtual COM pair (app milestone; the driver needs real/virtualized XP too — vhidmini-class drivers run fine in a VM) → real cabinet (joy.cpl shows 6 axes + 96 buttons, SendInput into a game, lamps, plasma, auto-switch yield, BMP wallpaper).
  • All open decisions resolved:
    1. The Xbox 360 pad (ViGEm) remains the preferred controller on Windows 10/11 whenever ViGEmBus is present.
    2. No third-party virtual joystick drivers (vJoy/PPJoy are unmaintained) — XP gets our own RioGamepadXP.sys.
    3. The mapping editor ships in all instances, XP included.
    4. Both install scenarios in one archive: postinstall.bat (TeslaConsole/launcher, unattended) and install.bat (freestanding computers, adds shortcuts); the single dist zip carries everything needed for both XP and 10/11, including offline redistributables.

Phase 9 — Game feedback (game → cockpit) — code-complete

Inbound feedback endpoint + plasma runtime wiring + rumble→lamp mapping, in src/RioJoy.Core/Feedback (442 xUnit tests total across the suite); protocol spec + client snippets in docs/FEEDBACK.md. Delivers the §Profiles promises "Lamp behavior" and "Plasma/VFD content (or 'off')".

  • Endpoint: FeedbackPipeServer serves \\.\pipe\riojoy-feedback (read-only — no replies ever, which sidesteps the 0-buffer pipe write deadlock class; ≤4 concurrent clients; reconnect forever; vRIO's VRioPipeService server pattern incl. the poke-connect stop) and FeedbackUdpListener binds loopback-only UDP (off by default, AppConfig.Feedback.UdpPort — the transport sim export scripts speak natively). One shared text line protocol: FeedbackLineParser + FeedbackLineBuffer (Latin-1, LF/CRLF, forgiving — malformed lines drop and log, never the connection), including plasma row <y> <hex32> bitmap streaming (PlasmaCommands.GraphicsWrite ports the display's ESC P graphics command per vRIO's recovered PlasmaProtocol; the router queues rows strictly FIFO while texts coalesce and clear flushes, bounded at 128). FeedbackService façades the lot; it lives in RioCoordinator for the app lifetime, so clients keep their connection across profile switches and dormancy — only command application is gated.
  • Rate governor: CoalescingLampScheduler — per-address desired/last-sent shadow state, at most one changed lamp per 25 ms tick, round-robin. All feedback lamp traffic (pipe/UDP and rumble) posts here; nothing feedback-side calls ILampSink directly, because every lamp command crosses the link's stop-and-wait command gate (~150 ms worst case) shared with the ~55 ms analog poll.
  • Routing/precedence: FeedbackRouter — per-profile gating (RioProfile.Feedback, null = feedback off; AllowLampCommands/ AllowPlasmaText), profile-owned lamps (HasLamp) protected from press/release fights (dropped, logged once per address per attach), plasma writes single-flight with a latest-pending-wins slot.
  • Plasma wired at last (closes the Phase 4 wiring): RioCoordinator. Activate opens PlasmaComPort ?? DefaultPlasmaComPort via the transport factory (pipe: endpoints work for benchless testing; "off"/empty skips; failure becomes a status suffix and never breaks activation), shows PlasmaGreeting auto-centered, blanks + releases the port on teardown (the native games open this port too). PlasmaDisplay gained its missing write lock — PosTextAsync is five transport writes, and concurrent callers used to interleave ESC fragments (PlasmaDisplayTests pins both the sequence and the no-interleave guarantee).
  • Rumble → lamps (net48 only): ViGEmJoystickSink.RumbleChanged (plain byte delegate over ViGEm's FeedbackReceived; fires on a ViGEm-owned thread) → RumbleLampAdapter: off below Threshold, then slow/med/fast thirds at full brightness per motor, posting only state changes so XInput's identical-value spam costs nothing — the board sustains the blink from the state byte. Works with unmodified games that set XInput vibration.
  • Config: FeedbackEndpointConfig (app-wide) + ProfileFeedbackConfig / RumbleLampConfig (per-profile); nullable sections = off/defaults, keeping pre-Phase-9 JSON byte-compatible (round-trip, unset-stays-null, and shipped-profile cases in ConfigStoreTests).
  • Remaining: on-cabinet verification (real lamps + plasma glass, link feel under game load); timed flash-then-restore effects (the scheduler's shadow state is the designed hook); editor UI for the per-profile feedback settings (JSON-only today); shipped client examples (SimHub plugin / DCS export script) beyond the FEEDBACK.md snippets.

Phase 10 — Pod-bundled deployment — code-complete

Deployment topology decision (2026-07-31): RIO hardware exists only on pods (the cockpit cabinets) and dev boxes — no freestanding end-user PCs. Production model is therefore one RIOJoy copy bundled inside each podized game's folder, started by the game's launch script and exiting with the game; no resident RIOJoy runs on a pod, and the native games simply don't bundle one (making the COM-port yield machinery vestigial in production). The resident tray + auto-switch reclassifies as the development harness. 455 xUnit tests total across the suite.

  • Portable config: ConfigLocator.Resolve — a config.json beside the exe wins over %APPDATA%\RIOJoy\config.json; TrayApplicationContext. ConfigPath resolves through it, so --import-profile targets the same store. A pod bundle needs no import step: its config is the profile.
  • --profile <name> — explicit immediate activation, no detection: in pod mode the auto-switch watcher never runs. The rationale is enumeration timing: games enumerate controllers at startup, and foreground detection activates ~1 s after the window appears — too late, so the pod launch script activates the profile before starting the game and the ViGEm pad already exists when the game looks. On success RIOJoy signals the named event RIOJoy.Tray.Ready (process-lifetime — never stale) so a pod launcher waits on the signal instead of counting input devices. Exit code 4 for an unknown profile name (validated up front so a pod-script typo is scriptable, not a silently idle tray) and 5 for failed activation (reason on stderr) — the launcher can always tell "failed with reason" from "hung". Closing the editor re-activates the explicit profile.
  • --exit-with <exe|pid> (CompanionTarget.Parse — pid, or a name normalized like auto-switch triggers): the tray polls the companion on its existing 1 s timer and quits through the normal teardown (ports released, wallpaper restored, plasma blanked) once the game has run and then gone. CompanionExit holds the pure decision — launch order isn't guaranteed, so a never-seen companion only triggers exit after a 60 s startup grace (also covers "game failed to launch"). Clock-free and unit-tested (tests/.../Hosting/CompanionExitTests).
  • Instance handoff: with --exit-with, a starting instance waits up to 15 s for the predecessor's single-instance mutex (game A's copy tearing down while game B's starts) instead of the historical silent exit-0; plain launches keep the instant-exit behavior. Abandoned mutex (predecessor crash) counts as acquired.
  • deploy/build-pod.ps1: emits the self-contained per-game drop-in — nothing is ever installed on a pod by hand. Inner layout mirrors the universal package (app/app-xp, vendor, install-core.bat, install-rio.ps1 reused verbatim — no forked install logic), plus the portable config.json beside the exe (the game's profile document verbatim), start-riojoy.bat (--exit-with prefilled from the profile's first trigger), and install-riojoy.bat: called from the game's postinstall.bat, self-elevating, idempotent (installs only what's absent — ViGEmBus on net48; .NET 4.0 + KB2468871 + RioGamepadXP via devcon on net40 — and never removes anything). There is deliberately no uninstall step: drivers are abandoned in place on game removal, since nothing can know whether another podized game still uses them and idle drivers are harmless. Zipped as RIOJoy-pod-<name>-<stamp>.zip (~8.4 MB net48 incl. ViGEmBus). Verified by building the Descent bundle and round-tripping its emitted config through ConfigStore.Load. Gotcha for future edits: PS 5.1 reads BOM-less scripts as ANSI, where a UTF-8 em dash decodes into a smart quote that terminates strings — keep deploy scripts pure ASCII.
  • Remaining: on-pod verification of the full launch/handoff cycle (launcher → game A → quit → game B); podize a first real game with the bundle; revisit the universal zip's install.bat framing (dev-setup only) once pod deploys are routine.

Open items / risks

  • Driver signing is the main friction point. Test-signing is fine for owned cabinets; redistribution needs attestation signing (EV cert + Partner Center).
  • Input injection vs. session: SendInput targets the interactive session — fine for a tray app, which is why a Windows service was rejected.
  • Legacy quirks to decide on (see PROTOCOL.md ⚠️ notes): disabled inbound checksum verification; odd mouse-move deltas.