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>
280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
# 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](OUTPUT-INTEGRATION.md)
|
||
(game → cockpit); the wire protocol lives in [PROTOCOL.md](PROTOCOL.md), the
|
||
profile/auto-switch model in [PLAN.md](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](OUTPUT-INTEGRATION.md#address-map-functional-groups).
|
||
- **Two 4×4 keypads**: internal `0x50–0x5F`, external `0x60–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..32766`
|
||
with center `16383`.
|
||
|
||
## 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`](../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
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **Triggers**: comma-separated executable names that auto-activate the
|
||
profile when their window is foreground (`d1x-rebirth, descent`). Matching
|
||
is basename, case-insensitive, `.exe` optional. 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 in
|
||
`NativeGameExecutables` instead — RIOJoy releases the ports for them.
|
||
4. **RIO port**: leave `(app default)`, or a COM name, or `pipe:vrio` for the
|
||
emulator.
|
||
5. **JSON-only settings** (edit `%APPDATA%\RIOJoy\config.json`): `AxisRouting`,
|
||
`Calibration` fine points, `PlasmaComPort`/`PlasmaGreeting`, and the
|
||
`Feedback` section (see FEEDBACK.md).
|
||
6. **Legacy import**: the `Import .ini` tray menu converts an original
|
||
`RIO.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](#pod-bundled-deployment-production) 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`](../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): `0` imported, `1` failed,
|
||
`2` usage, `3` **RIOJoy 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's `postinstall.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
|
||
|
||
1. Find the game's real executable name (foreground window process) → Triggers.
|
||
2. 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).
|
||
3. Map the few primary actions to pad buttons 1–11, everything else to
|
||
keyboard; mark cockpit-lit buttons **Lit**.
|
||
4. Set `EnableZR` if the game wants one rudder axis rather than two pedals.
|
||
5. Live-check in the editor, then in-game; bind a spare cockpit button to
|
||
*RIO command → recalibrate* for the cabinet.
|
||
6. Add the `Feedback` section if the game will drive lamps/plasma back
|
||
(OUTPUT-INTEGRATION.md).
|