feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash
Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate- governs the 9600-baud link; plasma finally wired into activation (greeting, teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
# Game feedback endpoint (game → RIOJoy → cockpit)
|
||||
|
||||
Phase 9 lets external programs drive the cockpit's **output** hardware through
|
||||
the running RIOJoy tray app: the 96 lamps (with board-side flash) and the
|
||||
plasma/VFD text display. Anything that can write a line of text to a named pipe
|
||||
or a UDP socket can use it — a DCS `Export.lua`, a SimHub plugin, a game mod, a
|
||||
PowerShell one-liner. XInput **rumble** is mapped separately (no client needed;
|
||||
see [Rumble → lamps](#rumble--lamps)).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Transport | Address | Default |
|
||||
|---|---|---|
|
||||
| Named pipe | `\\.\pipe\riojoy-feedback` | **on** |
|
||||
| UDP (loopback only) | `udp://127.0.0.1:<port>` | off (`UdpPort` unset) |
|
||||
|
||||
Both feed the same line protocol; the pipe accepts up to 4 concurrent clients.
|
||||
Configured app-wide in `%APPDATA%\RIOJoy\config.json`:
|
||||
|
||||
```json
|
||||
{ "Feedback": { "PipeEnabled": true, "PipeName": "riojoy-feedback", "UdpPort": 19910 } }
|
||||
```
|
||||
|
||||
Omitting the `Feedback` section entirely = pipe on under the default name, UDP
|
||||
off. Endpoint config is read once at the first profile activation; changes take
|
||||
effect on app restart.
|
||||
|
||||
The endpoint is **app-lifetime**: clients keep their connection across profile
|
||||
switches and dormancy. Whether commands *apply* is per-profile (see
|
||||
[Gating](#gating)).
|
||||
|
||||
## Line protocol
|
||||
|
||||
One command per line. LF or CRLF line endings; on UDP, one datagram carries one
|
||||
or more complete lines and the end of the datagram terminates the last line
|
||||
(no trailing LF needed, no fragments across datagrams). Keywords are
|
||||
case-insensitive. Lines over 256 bytes and UDP datagrams over 4 KB are dropped.
|
||||
|
||||
```
|
||||
# comment (also ;)
|
||||
lamp <addr> <state> set one lamp
|
||||
lamp-all <state> set every lamp
|
||||
plasma text [x y] <text> write text to the plasma display
|
||||
plasma clear clear the plasma display
|
||||
```
|
||||
|
||||
- **`<addr>`** — RIO lamp address, decimal or `0x` hex. Valid: `0x00–0x47`
|
||||
(the 72 buttons), `0x50–0x5F` (keypad 0), `0x60–0x6F` (keypad 1). See
|
||||
[PROTOCOL.md §5](PROTOCOL.md).
|
||||
- **`<state>`** — either words: `[solid|slow|med|fast] off|dim|bright` (flash
|
||||
defaults to `solid`), or a raw state byte `0x00–0x3F` (the `LampRequest`
|
||||
state, [PROTOCOL.md §3](PROTOCOL.md)). `lamp 0x12 fast bright` = flash-fast
|
||||
at full brightness. **The board sustains the blink** — one command starts a
|
||||
flash, another (`solid dim`, `off`, …) ends it.
|
||||
- **`plasma text`** — the rest of the line is the text, or quote it
|
||||
(`"VIPER 1-1"`; quotes stripped, no escapes). Two leading *numeric* tokens
|
||||
are a cursor position `x y`; omitted (or `0 0`) auto-fits and centers
|
||||
(`PlasmaPosText`). To display something that starts with two numbers, quote
|
||||
it. Encoding is **Latin-1** (one byte = one char, the plasma's wire
|
||||
encoding) — do not send UTF-8 for accented characters.
|
||||
|
||||
Malformed lines are dropped and counted (first few are logged); they **never**
|
||||
cost a client its connection. The endpoint sends no replies.
|
||||
|
||||
## Gating
|
||||
|
||||
| RIOJoy state | Listeners | Commands |
|
||||
|---|---|---|
|
||||
| Profile active, profile has a `Feedback` section | up | applied |
|
||||
| Profile active, no `Feedback` section | up | dropped |
|
||||
| Dormant / native game owns the ports | up | dropped |
|
||||
| Editor session | up | dropped |
|
||||
|
||||
Per-profile, in the profile's JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Feedback": {
|
||||
"AllowLampCommands": true,
|
||||
"AllowPlasmaText": true,
|
||||
"Rumble": { "LargeMotorLamps": [18, 19], "SmallMotorLamps": [96], "Threshold": 24 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A profile without `"Feedback"` never applies inbound commands. (Editor UI for
|
||||
these settings is a Phase 9 remaining item — edit the JSON for now.)
|
||||
|
||||
**Lamp ownership:** a `lamp` write to an address the profile maps as a *lighted
|
||||
button* (`iRIO` bit `0x8000`, `HasLamp`) is dropped — the input router owns
|
||||
those lamps (bright on press / dim on release) and feedback must not fight it.
|
||||
Such drops are logged once per address. `lamp-all` silently skips owned lamps.
|
||||
|
||||
**Rate:** lamp commands share the 9600-baud RIO link with the ~55 ms analog
|
||||
poll, so RIOJoy coalesces per-lamp state (latest wins) and sends at most one
|
||||
*changed* lamp per 25 ms. Spam freely — identical states cost nothing — but a
|
||||
`lamp-all` sweep takes ~3 s to fully land. Plasma writes are single-flight,
|
||||
latest-pending-wins: flood-updating a score means only the newest pending text
|
||||
is written.
|
||||
|
||||
## Rumble → lamps
|
||||
|
||||
With a `Rumble` config (above) and the ViGEm pad active, XInput vibration set
|
||||
by the game flashes the configured lamps — works with **unmodified games**:
|
||||
below `Threshold` (0–255) the lamps are off; the rest of the range maps to
|
||||
slow / med / fast flash at full brightness, per motor. Constant rumble costs
|
||||
one lamp command (the board blinks on its own). net48 flavor only (the XP
|
||||
flavor has no ViGEm).
|
||||
|
||||
## Client snippets
|
||||
|
||||
DCS-style `Export.lua` (a pipe opens as a file on Windows):
|
||||
|
||||
```lua
|
||||
local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w")
|
||||
-- in your export tick:
|
||||
if masterCaution then rio:write("lamp 0x12 fast bright\n")
|
||||
else rio:write("lamp 0x12 off\n") end
|
||||
rio:write('plasma text "' .. callsign .. '"\n')
|
||||
rio:flush()
|
||||
```
|
||||
|
||||
PowerShell, pipe (hand-testing on the cabinet):
|
||||
|
||||
```powershell
|
||||
$p = New-Object IO.Pipes.NamedPipeClientStream '.', 'riojoy-feedback', ([IO.Pipes.PipeDirection]::Out)
|
||||
$p.Connect(2000)
|
||||
$w = New-Object IO.StreamWriter $p, ([Text.Encoding]::GetEncoding(28591))
|
||||
$w.WriteLine('lamp 0x12 fast bright'); $w.WriteLine('plasma text "VIPER 1-1"'); $w.Flush()
|
||||
```
|
||||
|
||||
PowerShell, UDP (with `"UdpPort": 19910` configured):
|
||||
|
||||
```powershell
|
||||
$u = New-Object Net.Sockets.UdpClient
|
||||
$b = [Text.Encoding]::GetEncoding(28591).GetBytes("lamp 0x12 fast bright`nplasma text 42 kills")
|
||||
$u.Send($b, $b.Length, '127.0.0.1', 19910) | Out-Null
|
||||
```
|
||||
|
||||
## Implementation map
|
||||
|
||||
`src/RioJoy.Core/Feedback/`: `FeedbackLineParser` (grammar → `FeedbackCommand`),
|
||||
`FeedbackLineBuffer` (bytes → lines), `FeedbackPipeServer` / `FeedbackUdpListener`
|
||||
(transports), `CoalescingLampScheduler` (the rate governor — all feedback lamp
|
||||
traffic goes through it, never straight to `ILampSink`), `FeedbackRouter`
|
||||
(gating + ownership + plasma single-flight), `RumbleLampAdapter`, and
|
||||
`FeedbackService` (the façade `RioCoordinator` owns). Tests mirror the layout in
|
||||
`tests/RioJoy.Core.Tests/Feedback/`.
|
||||
+57
-2
@@ -183,8 +183,11 @@ Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total):
|
||||
- `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; the
|
||||
game-specific `PlasmaScoreDraw` layout is profile content (Phase 5/7).
|
||||
- ⏳ **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`](FEEDBACK.md)).
|
||||
|
||||
### Phase 5 — Tray app + profiles — code-complete ✅
|
||||
Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray`
|
||||
@@ -431,6 +434,58 @@ XP consumes pre-rendered wallpapers.
|
||||
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` (425 xUnit tests total across the suite); protocol
|
||||
spec + client snippets in [`docs/FEEDBACK.md`](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). `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.
|
||||
|
||||
---
|
||||
|
||||
## Open items / risks
|
||||
|
||||
Reference in New Issue
Block a user