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>
14 KiB
Input integration guide (cockpit → RIOJoy → game)
How a game receives the cockpit's inputs — the 72 lighted buttons, two 16-key keypads, and 5 analog axes — and how to build the profile that maps them. This is the mirror of OUTPUT-INTEGRATION.md (game → cockpit); the wire protocol lives in PROTOCOL.md, the profile/auto-switch model in PLAN.md.
What a game sees
RIOJoy translates cockpit events into ordinary Windows input, per profile, through three surfaces (all can be active at once — each button picks its route):
| Surface | What the game sees | When |
|---|---|---|
| Virtual Xbox 360 pad (ViGEm) | a normal XInput controller: 11 buttons, D-pad, 2 sticks, 2 triggers | default on Windows 10/11 when ViGEmBus is installed |
Keyboard / mouse (SendInput) |
scancode keystrokes with modifiers; relative mouse moves + clicks | any button routed to a key/mouse action |
| RioGamepad HID | a native 6-axis, 96-button, 1-hat joystick | fallback when ViGEm is absent; the XP flavor |
The sink is chosen at activation: ViGEm → RioGamepad feeder → none (keyboard and mouse always work). Most games — XInput and DirectInput alike — see the Xbox 360 pad as a standard controller; the practical limit is its 11 mappable buttons, so keyboard routing carries everything beyond that.
The input inventory
- 72 lighted buttons, RIO addresses
0x00–0x47, grouped into five MFD clusters and four columns — the physical map is in OUTPUT-INTEGRATION.md. - Two 4×4 keypads: internal
0x50–0x5F, external0x60–0x6F(key label → address = base + hex digit; no lamps). - 5 analog inputs — joystick X/Y, throttle, left pedal, right pedal —
calibrated into 6 virtual axes (X, Y, Z, Rx, Ry, Rz), each
0..32766with center16383.
Per-button routing
Every mapped address carries one action (the iRIO word, PROTOCOL.md §5).
The editor exposes these as the Action kinds:
| Kind | What happens on press/release |
|---|---|
| Keyboard | key down/up by scancode (so DOS-era and raw-input games see it), with optional Shift/Ctrl/Alt held around it and the extended-key flag for nav keys |
| Joystick | virtual pad button 1–11 (or 1–96 on the RioGamepad HID) |
| Hat | the POV hat / D-pad direction (up/right/down/left; release = centered) |
| Mouse | relative move in clean 50-px steps (up/right/down/left) or left/right click — the legacy build's mixed-up move deltas are fixed in the port |
| RIO command | internal: axis recalibrations/resets, version/check request, diagnostic toggles — useful on a spare cockpit button so recalibration never needs the desktop |
| Lit flag | lamp follows the button (dim idle, bright pressed). Also marks the lamp as profile-owned, which shields it from the feedback endpoint (see OUTPUT-INTEGRATION.md) |
The Xbox 360 button map
RIO joystick buttons are assigned in this fixed order — pick low numbers for the game's most important actions:
| RIO joy button | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Pad button | A | B | X | Y | LB | RB | Back | Start | L3 | R3 | Guide |
The hat maps to the D-pad. Buttons past 11 are dropped by the pad — route those to the keyboard instead. (On the RioGamepad HID all 96 buttons exist natively and no mapping table applies.)
Axes
Calibration (per profile)
Calibration holds per-axis invert flags and EnableZR:
- Joystick X/Y auto-range from observed travel with a small deadzone.
- Throttle (Z) is the ratcheted lever; calibrated so the detent rest
position reads 0, full forward
32766— i.e. it is naturally unipolar. - Pedals feed Rx/Ry directly, or — with
EnableZR— mix into a single rudder axis:Rz = 16383 − left/2 + right/2(Rx/Ry then idle).
Routing onto the pad (AxisRouting, JSON-only)
Each of the six axes picks a Target and Mode
(src/RioJoy.Core/Output/AxisRoutingConfig.cs; null section = the legacy
default routing):
| Axis | Default target | Conversion |
|---|---|---|
| X | LeftThumbX | Centered |
| Y | LeftThumbY | Centered |
| Z | LeftTrigger | trigger byte value×255/32766 |
| Rx | RightThumbX | Centered |
| Ry | RightThumbY | Centered |
| Rz | RightTrigger | trigger byte |
Modes for thumb targets: Centered ((value−16383)×2 → stick range) for
axes that rest mid-travel, UnipolarPositive (rest 0 = stick center,
32766 = stick max — only the upper half is used) for the ratcheted throttle.
Target: "None" suppresses an axis entirely.
The triggers-are-buttons trap: many games hard-bind the pad triggers to
fire/actions. If the throttle rides LeftTrigger (the default), advancing the
throttle fires. The shipped Descent profile
(profiles/descent-d1x.json) is the worked
example: throttle → RightThumbY UnipolarPositive, rudder mix →
RightThumbX, pedals None, keeping both triggers free for the game.
Choosing a strategy per game
- Modern XInput game — pad buttons + axes for the flight controls, keyboard routing for the long tail (MFD pages, systems). Check the game's own binding UI to see the pad.
- DOS / emulated game (DOSBox, source ports) — mostly keyboard routing (it arrives as scancodes, which DOSBox maps cleanly); axes via the pad if the emulator supports a controller, else map coarse throttle steps to keys.
- Legacy DirectInput sim — the x360 pad appears as a DirectInput device too; if the game needs more than 11 buttons on the stick itself, prefer keyboard routing or run the RioGamepad HID (96 native buttons).
- Anything with a clickable cockpit — mouse routing gives you cursor nudges and clicks from cockpit buttons.
Building the profile
- Create/edit from the tray: Edit profile. The editor shows the cockpit panel in its functional groups; click a button, set its label, action, modifiers, and Lit, then Apply. Save writes the config.
- Live check: with the RIO (or vRIO) connected, physical presses light the panel and the axis gauges move — before any game is involved. The "Send button output to the PC" toggle turns real keystroke injection on when you want to test into an editor/notepad.
- Triggers: comma-separated executable names that auto-activate the
profile when their window is foreground (
d1x-rebirth, descent). Matching is basename, case-insensitive,.exeoptional. First matching profile wins; DOSBox-hosted games all share the DOSBox exe name (rename per game or switch manually); native games (Firestorm, Red Planet) go inNativeGameExecutablesinstead — RIOJoy releases the ports for them. - RIO port: leave
(app default), or a COM name, orpipe:vriofor the emulator. - JSON-only settings (edit
%APPDATA%\RIOJoy\config.json):AxisRouting,Calibrationfine points,PlasmaComPort/PlasmaGreeting, and theFeedbacksection (see FEEDBACK.md). - Legacy import: the
Import .initray menu converts an originalRIO.ini(buttons, inverts, greeting). For programmatic install-time handoff, see the next section.
Shipping a profile with your game
Two models, chosen per deployment:
- Pod bundle (production — cockpit cabinets): the game's folder carries its own RIOJoy copy + profile; nothing is registered anywhere. See Pod-bundled deployment below.
- Import into a resident RIOJoy (dev boxes): hand a profile document to the shared tray install, as follows.
A game (or its installer/launcher) hands its profile to RIOJoy as a single-profile JSON document plus one command:
RioJoy.Tray.exe --import-profile <your-game-riojoy-profile.json>
The document is one RioProfile object — the shipped
profiles/descent-d1x.json is the reference
example. It must carry a "Name" (imports without one are rejected), and it
bundles everything in one payload: Buttons, MatchExecutables (the
triggers), Calibration, AxisRouting, PlasmaGreeting, Feedback,
overlay labels. Author it in the profile editor, then lift the profile object
out of %APPDATA%\RIOJoy\config.json into your distribution.
The import merges into the user's %APPDATA%\RIOJoy\config.json — created
with defaults if absent, all other content preserved. A profile with the
same name (case-insensitive) is replaced in place, so re-running the
import on a game update is idempotent and never disturbs other games'
profiles.
Contract for installers:
- Check the exit code, not stdout (the tray is a GUI-subsystem exe;
console output only appears when redirected):
0imported,1failed,2usage,3RIOJoy is running. - The tray must not be running during import — a running tray holds the config in memory and its own saves would silently discard the merge, so the import refuses (exit 3) instead. Sequence: quit/skip the tray → import → (re)launch. On launcher-managed cabinets (TeslaConsole owns the RIOJoy lifecycle) a game's install step can import safely before the next boot.
- Per-user, per-session: the config lives under the user's
%APPDATA%and the running-instance check is per-session — run the import in the user's session, not as an elevated SYSTEM step.
Nothing else needs registering: once the tray starts with the merged config,
the profile's MatchExecutables auto-activates it whenever the game's window
is foreground (tray in Auto mode).
For games you control end-to-end, keep the profile document in the game's
repo as the source of truth — dxx-rebirth does this, and a RIOJoy test
(ShippedDescentProfile_MatchesDxxRebirthReferenceCopy) asserts the two
checkouts stay byte-identical so drift is caught in CI.
Pod-bundled deployment (production)
On the pods (the cockpit cabinets) no resident RIOJoy runs at all. Each podized game's install carries its own copy, built by:
deploy\build-pod.ps1 -ProfileJson <your-game-profile.json>
That emits a self-contained drop-in (~8 MB on net48): riojoy\ — the app,
a portable config.json beside the exe holding just this game's profile
(it wins over the per-user %APPDATA% store), and all RIOJoy prerequisites
(ViGEmBus; on the XP flavor .NET 4.0 + the RioGamepadXP driver) — plus two
entry points the game's package wires up:
install-riojoy.bat— call from the game'spostinstall.bat. Self-elevating and idempotent: safe on every install, reinstall, and update; it installs only what's absent and never removes anything. Put nothing in the game's pre-uninstall — drivers stay in place by design, since another podized game may share them and idle drivers are harmless.start-riojoy.bat— call from the game's launch script before the game:
start "" "...\riojoy\app\RioJoy.Tray.exe" --profile "<Name>" --exit-with <game exe>
In pod mode everything is explicit — there is no detection on either
side. --profile activates the named profile immediately at startup, so
the virtual controller and the ports exist before the game launches and
enumerates input devices (foreground detection activates ~1 s after the
window appears — too late for startup enumeration, and the auto-switch
watcher never runs in this mode). On success RIOJoy signals the named event
RIOJoy.Tray.Ready — a launcher waits on that (with a timeout) instead
of guessing from device enumeration, and the failure modes stay legible:
| Launcher observes | Meaning |
|---|---|
RIOJoy.Tray.Ready signaled |
profile active; pad + ports exist — start the game |
| RIOJoy exited, code 4 | profile name not in the config (script typo) |
| RIOJoy exited, code 5 | activation failed — reason on stderr (port busy, bad endpoint) |
| no signal, still running | genuinely stuck — timeout and report |
The event is process-lifetime (it can never go stale); either side may create
it first — same name, manual-reset, both converge on one object. Batch-only
integrations without a launcher can simply order the script: start
RIOJoy, then the game — but a real launcher should wait on the event.
--exit-with handles the other end: once the game process has run and then
exited, RIOJoy tears itself down completely (ports released, wallpaper
restored, plasma blanked) and quits. If the game never appears within 60 s it
also quits, so a failed launch can't strand it. Back-to-back launches hand
over cleanly: a starting pod instance waits up to 15 s for the previous
game's copy to release the single-instance lock.
Properties that matter on a cabinet: each game pins the RIOJoy build it was verified with (updating RIOJoy for a new game can't regress an old one); native games simply don't bundle RIOJoy, so the COM ports are free for them by construction; and every bundle is fully self-sufficient — drivers install through the game's own postinstall, so a fresh pod needs no separate RIOJoy provisioning pass. The bundled exe is still the full tray app: run it with no arguments on the pod and you have the profile editor.
Testing without hardware or game
- vRIO (
pipe:vrio): click buttons on the emulator's panel and watch them arrive — the editor lights up, the pad reacts. - joy.cpl (Game Controllers): shows the virtual pad's axes/buttons moving.
- Keyboard routes: open Notepad, enable the editor's output toggle, press cockpit buttons.
Checklist for a new game
- Find the game's real executable name (foreground window process) → Triggers.
- Decide the axis story first: does the game hard-use triggers? If yes, route
the throttle to a thumb axis
UnipolarPositive(copy the Descent pattern). - Map the few primary actions to pad buttons 1–11, everything else to keyboard; mark cockpit-lit buttons Lit.
- Set
EnableZRif the game wants one rudder axis rather than two pedals. - Live-check in the editor, then in-game; bind a spare cockpit button to RIO command → recalibrate for the cabinet.
- Add the
Feedbacksection if the game will drive lamps/plasma back (OUTPUT-INTEGRATION.md).