16 Commits
Author SHA1 Message Date
CydandClaude Opus 5 46e108de89 feedback: plasma box, explicit fonts, and per-position text coalescing
Three endpoint gaps, found building the Descent 3 score overlay - the boxed
place|score field the original games drew over the callsign:

plasma box <x> <y> <w> <h> draws an outlined box with a blanked interior, the
overlay chrome, as one ESC P graphics write. The wire addresses whole bytes
horizontally, so the write covers the byte-aligned span containing the box and
clears span pixels outside it; documented, with 8-px alignment the advice.
Boxes queue FIFO with rows.

plasma text gains an explicit font: a third numeric token after the position,
with text still following, so `plasma text 2 2 7` still displays "7". Auto-fit
picks the font by LENGTH - short text always rendered large, and a "1" that
must fit a 12-px box simply could not be sent before. ResolvePosText
generalizes the legacy Score-font special case: 0 = auto, nonzero honored.

Text coalescing is now per position. The global rule - any queued text
superseded every other queued text - meant the documented two-field layout
(callsign top, score bottom) could not survive its own send burst: the second
field silently ate the first whenever both were queued. A newer text now
replaces only a queued text at the same (x,y).

15 new tests; 472 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:45:59 -05:00
CydandClaude Opus 5 23453667d2 profiles: Descent 3 runs a blank greeting
Synced from the canonical copy in the game's repo: the pod display stays dark
from activation until the game sends the pilot's callsign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:23:39 -05:00
CydandClaude Opus 5 1854afaa73 plasma: hide the cursor before anything draws
The firmware parks a cursor artifact wherever text last ended, and nothing
ever turned it off - the native games did, once at startup (ESC G 0,
L4PLASMA.CPP), which is why the cabinet never showed it. Seen on the bench as
a stray block sitting in the Descent 3 nameplate.

ShowGreetingAsync now sends cursor-hidden as its first act on every plasma
open, before the clear and greeting, so every path that follows - greeting,
feedback text, bitmap rows - draws on a cursorless panel. PlasmaDisplay gains
the CursorAsync wrapper for the ESC G command the command builder already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:18:23 -05:00
CydandClaude Opus 5 63bdb2c1da pod bundle: explicit app-level config, and the registry profile catches up
The portable config a bundle shipped carried only Profiles, so every app-level
setting - ports, baud, poll rate - rode whatever that build of RIOJoy compiled
in. That matched today and would drift invisibly the day a default changes or
the FastRIO path moves AnalogPollMs. The generated config now writes them out,
so a bundle says what it runs with. Verified by building the Descent 3 bundle
and parsing the result.

profiles/descent3.json was a pre-cockpit draft: old button layout, no explicit
ports, no overlay labels. Replaced verbatim with the flown-and-confirmed Tesla
profile from the game's own repo (Descent3 venue/riojoy/tesla.riojoy.json,
which pack-dist feeds to build-pod and is the canonical copy) - explicit
COM1/COM2, the 30-mapping layout, afterburner on the thumb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:59:17 -05:00
CydandClaude Opus 5 870a8a257d calibrator: exact center in must be exact center out
The legacy joystick math left the output unchanged when the raw sample was
exactly 0 - a case a jittering pot never produces, so on real hardware the hold
was invisible. vRIO's pad deadzone produces it constantly: every release of the
bench gamepad shapes the residual to exactly 0.0 and holds it there, one sample
per 55 ms poll. The calibrator held the last in-motion output against that
stream indefinitely, so the virtual pad froze off-center at whatever the stick
commanded the instant before release.

Flown, that read as a Descent 3 pod drifting on yaw and pitch with the stick
centered, stopping only when the release was slow enough to land an
intermediate sample inside the +/-5 band. It survived a full day of suspects -
the game's deadzone, its axis map, its focus handling, com0com, the serial
protocol - because every layer below the calibrator was correct: the freeze was
visible in joy.cpl itself, and the game's own control trace showed heading
latched at -0.38 while the wire carried perfect zeros.

Same divergence from the legacy port, same reasoning, as the throttle detent
fix above it: zero means centered, not "no information". Both axes; regression
tests pin the fast-release case and center stability across repeated zero
polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:49:25 -05:00
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
CydandClaude Fable 5 5befd1d511 profiles: Descent 3 starting profile (derived from the D1X layout)
Same cockpit axis strategy (throttle RightThumbY unipolar, pedal-mix rudder
on RightThumbX, triggers left free for fire) and the D1X button/keypad map
as a starting point pending on-pod tuning in the editor. Triggers cover the
open-source port (Descent3) and the retail launcher (d3); plasma greeting
DESCENT 3. Pod bundle builds clean from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:21:38 -05:00
CydandClaude Fable 5 38cf37c5e6 pod: self-contained bundles - drivers install via the game postinstall
Per Cyd: no operator ever installs anything on a pod by hand. build-pod.ps1
now mirrors the universal inner layout (app/app-xp, vendor, install-core.bat
+ install-rio.ps1 reused verbatim) and bundles the flavor prerequisites
(ViGEmBus / XP .NET+KB+RioGamepadXP). New install-riojoy.bat entry point is
called from the game postinstall.bat: self-elevating, idempotent, add-only.
Deliberately no uninstall step - other podized games may share the drivers,
so they are abandoned in place. Also: deploy scripts must stay pure ASCII
(PS5.1 reads BOM-less UTF-8 as ANSI; an em dash decodes to a smart quote
that terminates strings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:15:23 -05:00
CydandClaude Fable 5 97caf124a6 pod: bundled per-game deployment (portable config, --exit-with, build-pod)
Phase 10: RIO hardware exists only on pods + dev boxes, so production is one
RIOJoy copy inside each podized game folder, no resident tray. ConfigLocator
makes a config.json beside the exe win over %APPDATA%; --exit-with <exe|pid>
(CompanionTarget/CompanionExit, 60s startup grace) tears down and quits when
the game exits; a starting --exit-with instance waits up to 15s for the
predecessor mutex instead of silently exiting. deploy/build-pod.ps1 emits
the ~4.5MB drop-in (app + portable config wrapping the profile + start
script, no drivers) - verified against the shipped Descent profile. 455
tests; PLAN.md Phase 10 + INPUT-INTEGRATION.md pod section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:01:52 -05:00
CydandClaude Fable 5 9a792193f9 docs: shipping-a-profile section in the input integration guide
Documents the install-time handoff contract that previously lived only in
Program.cs comments: single-profile document + --import-profile, exit codes
(0/1/2/3), the tray-must-not-be-running rule and installer sequencing,
per-user/per-session scope, idempotent replace-by-name, and the game-repo
reference-copy convention (dxx-rebirth pattern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:09:02 -05:00
CydandClaude Fable 5 80222c5ea7 docs: input integration guide (cockpit -> RIOJoy -> game)
Companion to OUTPUT-INTEGRATION.md: the three input surfaces (ViGEm x360
pad, SendInput scancode keyboard/mouse, RioGamepad HID), per-button routing
kinds incl. the fixed 11-button pad order, axis calibration + routing with
the triggers-are-buttons trap and the shipped Descent pattern, per-game
strategy, profile building/triggers workflow, benchless testing, checklist.
Cross-linked from README and the output guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:49:23 -05:00
CydandClaude Fable 5 44b636ddd3 plasma: ESC P bitmap rows through the feedback endpoint (plasma row)
PlasmaCommands.GraphicsWrite/GraphicsRow port the display firmware graphics
command (ESC P s y x w h, MSB-left); PlasmaDisplay.RowAsync writes a locked
whole-row update; the line protocol gains `plasma row <y> <hex32>`. The
router plasma slot becomes a bounded FIFO queue: rows stream in order (a
frame must not tear), texts still coalesce to the newest, clear flushes, cap
128 with counted drops. Docs updated; 442 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:45:38 -05:00
CydandClaude Fable 5 66c3cbdb57 docs: plasma bitmap mode (ESC P) + correct panel geometry to 128x32
The display is fully dot-addressable: document the ESC P graphics write
(row format, MSB-left, changed-row streaming as Red Planet does it, and the
~0.77s full-frame budget at 9600 baud) per vRIO PlasmaProtocol.cs, recovered
from the Tesla 4.10 sources + firmware dump. Fixes the guide''s 112x32 guess
(the legacy auto-center pivot x=56 sits left of the true 128px center) and
notes the firmware cursor ranges (x 0-127, y 0-31).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:34:25 -05:00
CydandClaude Fable 5 94d32a1f06 docs: plasma/output integration guide for game and sim integrators
Lamp address map by functional group (keypads have no lamps), 112x32 plasma
model with auto-fit font rules and update semantics, rate-budget guidance,
and recipes: rumble config, DCS Export.lua, SimHub, log tailing, vRIO/vPlasma
bench testing. Cross-linked from FEEDBACK.md (now the wire reference) and
README; corrects the lamp count there (72, per CockpitPanel, not 96).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:13:55 -05:00
CydandClaude Fable 5 ad7ac19ab2 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>
2026-07-31 19:31:03 -05:00
CydandClaude Fable 5 d13d434e88 editor: RIO port/pipe picker on the profile edit panel
The right-hand panel gains a "RIO port" row under Triggers: an editable
combo offering the app default (shown with its value), the machine's COM
ports, and pipe:vrio, with free text for anything else. Save stores the
endpoint in RioProfile.RioComPort (blank or the default entry = null =
follow DefaultRioComPort).

Because the editor session holds the endpoint it opened with, a saved
port change now re-arms the session live: the tray host unhooks the old
runtime's editor wiring, re-activates on the new endpoint, re-hooks, and
restores the output-gate state - so the live RIO commands and button
echo follow the new port/pipe without closing the editor.

Verified with the offline DrawToBitmap harness (layout, both default and
pipe:vrio states) plus a scripted save round-trip (default->null,
pipe:vrio->stored, blank->null, reopen shows stored COM7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:39:52 -05:00
48 changed files with 5211 additions and 78 deletions
+10 -2
View File
@@ -20,8 +20,11 @@ Red Planet — talk to the RIO directly and do not use this app.)
| [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy | | [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy |
| [`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/) | On-cabinet end-to-end check of the feeder → driver path | | [`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/) | On-cabinet end-to-end check of the feeder → driver path |
| [`tools/XcfRegionExtract`](tools/XcfRegionExtract/) | Extracts cockpit label regions from `riojoy.xcf``regions.json` | | [`tools/XcfRegionExtract`](tools/XcfRegionExtract/) | Extracts cockpit label regions from `riojoy.xcf``regions.json` |
| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) | | [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan |
| [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference | | [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
| [`docs/FEEDBACK.md`](docs/FEEDBACK.md) | Game→cockpit feedback endpoint (lamps + plasma over pipe/UDP, rumble) |
| [`docs/INPUT-INTEGRATION.md`](docs/INPUT-INTEGRATION.md) | Integrator's guide: cockpit→game — routing kinds, pad/axis mapping, profile building |
| [`docs/OUTPUT-INTEGRATION.md`](docs/OUTPUT-INTEGRATION.md) | Integrator's guide: game→cockpit — lamp address map, plasma display model, recipes |
| _RIO board hardware & firmware_ | Moved to the [TeslaRel410 `restoration/`](https://gitea.mysticmachines.com/VWE/TeslaRel410/src/branch/main/restoration) archive — board photos, schematics, GAL decode (`restoration/rio-hardware`) and the RIO 4.3 board firmware (`restoration/rio-firmware`) | | _RIO board hardware & firmware_ | Moved to the [TeslaRel410 `restoration/`](https://gitea.mysticmachines.com/VWE/TeslaRel410/src/branch/main/restoration) archive — board photos, schematics, GAL decode (`restoration/rio-hardware`) and the RIO 4.3 board firmware (`restoration/rio-firmware`) |
| [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline | | [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline |
| [`legacy/`](legacy/) | Original C++/vJoy implementation, kept as reference | | [`legacy/`](legacy/) | Original C++/vJoy implementation, kept as reference |
@@ -41,7 +44,12 @@ dotnet test RioJoy.sln
## Status ## Status
Phases 15 are implemented and tested (241 unit tests). The `RioGamepad` virtual Phases 15, 9 and 10 are implemented and tested (455 unit tests). Games (or sim
export scripts) can drive the cockpit lamps and plasma display back through the
running app — see [`docs/FEEDBACK.md`](docs/FEEDBACK.md). For cockpit cabinets,
RIOJoy deploys **bundled per game** (`deploy\build-pod.ps1`, portable config +
`--exit-with` self-teardown) rather than resident — see the pod section in
[`docs/INPUT-INTEGRATION.md`](docs/INPUT-INTEGRATION.md). The `RioGamepad` virtual
HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it
enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl` enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl`
`RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see `RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see
+257
View File
@@ -0,0 +1,257 @@
<#
.SYNOPSIS
Build a SELF-CONTAINED pod bundle of RIOJoy for one podized game
(PLAN.md Phase 10): a drop-in the game's install carries inside its own
directory - app + portable config (the game's profile) + start script +
ALL prerequisites (ViGEmBus / XP driver + .NET payload) with an
idempotent install entry point. The game's postinstall.bat calls
install-riojoy.bat (safe to re-run); the game's launch script calls
start-riojoy.bat; RIOJoy exits by itself when the game exits
(--exit-with). No operator ever touches the pod to install anything.
There is deliberately NO uninstall step: drivers are abandoned in place
on game removal, because nothing can know whether another podized game
still uses them - and idle drivers are harmless.
.PARAMETER ProfileJson
Path to the game's single-profile JSON document (must carry "Name";
same format as profiles\descent-d1x.json).
.PARAMETER GameExe
Game executable name for --exit-with. Default: the profile's first
MatchExecutables entry.
.PARAMETER Flavor
net48 (Windows 10/11 pods, default) or net40 (XP pods, x86). Selects
both the app build and which prerequisites are bundled.
.PARAMETER VigemInstaller
net48 only: path to the signed ViGEmBus installer. If omitted, looks in
deploy\vendor\ViGEmBus*.exe.
.PARAMETER OutDir
Where to write the zip (default: dist, relative to the repo root).
.PARAMETER Configuration
Build configuration (default: Release).
#>
param(
[Parameter(Mandatory = $true)]
[string]$ProfileJson,
[string]$GameExe,
[ValidateSet('net48', 'net40')]
[string]$Flavor = 'net48',
[string]$VigemInstaller,
[string]$OutDir = 'dist',
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent # deploy\ -> repo root
$staging = Join-Path ([IO.Path]::GetTempPath()) "riojoy-pod-$([Guid]::NewGuid().ToString('N'))"
Write-Host '== RIOJoy pod bundle (self-contained) ==' -ForegroundColor Cyan
# --- profile document -----------------------------------------------------
if (-not (Test-Path $ProfileJson)) { throw "Profile document not found: $ProfileJson" }
$profileText = Get-Content $ProfileJson -Raw
$profileDoc = $profileText | ConvertFrom-Json
if (-not $profileDoc.Name) { throw "Profile file '$ProfileJson' has no Name." }
if (-not $GameExe) {
$GameExe = @($profileDoc.MatchExecutables) | Select-Object -First 1
if (-not $GameExe) { throw "Profile has no MatchExecutables; pass -GameExe <name>." }
}
# --- version stamp --------------------------------------------------------
$sha = (& git -C $repo rev-parse --short HEAD 2>$null)
if (-not $sha) { $sha = 'nogit' }
$version = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd'), $sha
$safeName = $profileDoc.Name
foreach ($c in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($c, '_') }
$safeName = $safeName -replace ' ', '-'
try {
# The payload uses the SAME inner layout as the universal package
# (app / app-xp, vendor, install-core.bat, install-rio.ps1), so the
# idempotent install machinery is reused verbatim - no forked scripts.
$pkgDir = Join-Path $staging 'riojoy'
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
$appDirName = if ($Flavor -eq 'net48') { 'app' } else { 'app-xp' }
$appOut = Join-Path $pkgDir $appDirName
# 1. Publish the tray app (framework-dependent, like the universal package).
Write-Host "Publishing RioJoy.Tray ($Configuration, $Flavor)..."
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
-c $Configuration -f $Flavor -p:DebugType=none `
-o $appOut | Out-Null
if ($LASTEXITCODE -ne 0) { throw "dotnet publish ($Flavor) failed." }
if ($Flavor -eq 'net48') {
# Same SkiaSharp pruning as build-package.ps1.
foreach ($d in 'x64', 'x86', 'arm64') {
$nd = Join-Path $appOut $d
if (Test-Path $nd) { Remove-Item $nd -Recurse -Force }
}
Get-ChildItem $appOut -Filter '*.dylib' -ErrorAction SilentlyContinue | Remove-Item -Force
Get-ChildItem $appOut -Filter '*.so' -ErrorAction SilentlyContinue | Remove-Item -Force
}
# 2. Portable config beside the exe (ConfigLocator picks it over %APPDATA%):
# an AppConfig wrapping this game's profile, verbatim, plus the app-level
# settings written out explicitly. A config that carries only Profiles
# leaves every app-level value to whatever this build of RIOJoy compiles
# in - which happens to match today, and would drift invisibly the day a
# default changes or a pod needs tuning (the FastRIO path will move
# AnalogPollMs). A pod bundle should say what it runs with.
$configJson = @"
{
"DefaultRioComPort": "COM1",
"DefaultPlasmaComPort": "COM2",
"RioBaudRate": 9600,
"AnalogPollMs": 55,
"Profiles": [
$profileText
]
}
"@
Set-Content -Path (Join-Path $appOut 'config.json') -Value $configJson -Encoding utf8
Set-Content -Path (Join-Path $pkgDir 'VERSION.txt') -Value "RIOJoy pod bundle $version ($Flavor) - $($profileDoc.Name)" -Encoding utf8
# 3. Prerequisites + the shared idempotent install core, per flavor.
Copy-Item (Join-Path $PSScriptRoot 'install-core.bat') $pkgDir
$vendorOut = Join-Path $pkgDir 'vendor'
New-Item -ItemType Directory -Force -Path $vendorOut | Out-Null
if ($Flavor -eq 'net48') {
Copy-Item (Join-Path $PSScriptRoot 'install-rio.ps1') $pkgDir
if (-not $VigemInstaller) {
$VigemInstaller = Get-ChildItem -Path (Join-Path $PSScriptRoot 'vendor') -Filter 'ViGEmBus*.exe' `
-ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $VigemInstaller -or -not (Test-Path $VigemInstaller)) {
throw "ViGEmBus installer not found. Pass -VigemInstaller <path>, or drop ViGEmBus_*.exe into deploy\vendor\."
}
$sig = Get-AuthenticodeSignature $VigemInstaller
if ($sig.Status -ne 'Valid') { throw "ViGEmBus installer is not validly signed ($($sig.Status)): $VigemInstaller" }
Copy-Item $VigemInstaller $vendorOut
Write-Host "Bundled prerequisite: $(Split-Path $VigemInstaller -Leaf) (signature $($sig.Status))"
} else {
$vendorXpSrc = Join-Path $PSScriptRoot 'vendor\xp'
$vendorXpOut = Join-Path $vendorOut 'xp'
New-Item -ItemType Directory -Force -Path $vendorXpOut | Out-Null
$xpWanted = @(
'dotNetFx40_Full_x86_x64.exe', 'NDP40-KB2468871-v2-x86.exe',
'RioGamepadXP.inf', 'RioGamepadXP.sys', 'devcon.exe'
)
foreach ($name in $xpWanted) {
$src = Join-Path $vendorXpSrc $name
if (Test-Path $src) {
Copy-Item $src $vendorXpOut
Write-Host "Bundled XP prerequisite: $name"
} else {
Write-Warning "XP prerequisite missing from deploy\vendor\xp: $name - install will warn/skip."
}
}
}
# 4. install-riojoy.bat - the game's postinstall.bat calls this. Elevates
# (modern) like the universal postinstall.bat, runs the shared core,
# deletes nothing. Idempotent; there is no uninstall counterpart.
$installBat = @(
'@echo off'
'rem ==========================================================================='
"rem RIOJoy pod install for $($profileDoc.Name) - call from the game's"
'rem postinstall.bat. Idempotent: safe to run on every (re)install/update.'
'rem Installs only what is absent (drivers, runtime); never removes anything.'
'rem There is deliberately NO uninstall: other podized games may share the'
'rem drivers, and abandoned-in-place drivers are harmless.'
'rem ==========================================================================='
'setlocal'
'net session >nul 2>&1'
'if %errorlevel% neq 0 ('
' ver | findstr /C:"Version 5.1" >nul'
' if not errorlevel 1 ('
' echo install-riojoy.bat must run as an Administrator user.'
' exit /b 1'
' )'
' echo Requesting administrator privileges...'
" powershell -NoProfile -Command `"Start-Process -FilePath '%~f0' -Verb RunAs -Wait`""
' exit /b 0'
')'
'call "%~dp0riojoy\install-core.bat"'
'exit /b %errorlevel%'
) -join "`r`n"
Set-Content -Path (Join-Path $staging 'install-riojoy.bat') -Value $installBat -Encoding Ascii
# 5. Start script: the game's launch script calls this before the game.
# Everything explicit - no foreground detection: --profile activates
# immediately so the virtual pad exists before the game enumerates
# controllers, and --exit-with ends RIOJoy when the game exits.
$startBat = @(
'@echo off'
"rem RIOJoy pod companion for $($profileDoc.Name)."
'rem Call from the game''s launch script BEFORE starting the game:'
'rem the profile activates immediately (no foreground detection), so'
'rem the virtual controller exists before the game enumerates devices.'
'rem RIOJoy exits by itself when the game exits (--exit-with).'
"start `"`" `"%~dp0riojoy\$appDirName\RioJoy.Tray.exe`" --profile `"$($profileDoc.Name)`" --exit-with $GameExe"
) -join "`r`n"
Set-Content -Path (Join-Path $staging 'start-riojoy.bat') -Value $startBat -Encoding Ascii
# 6. Integrator note.
$readme = @(
"RIOJoy pod bundle - $($profileDoc.Name) ($version, $Flavor)"
''
'Self-contained: app, this game''s profile, and all RIOJoy prerequisites'
'(drivers/runtime). Nothing is installed on the pod by hand.'
''
'Integrate into the game''s package:'
' 1. Ship the riojoy\ folder, install-riojoy.bat and start-riojoy.bat'
' inside the game''s install directory.'
' 2. Call install-riojoy.bat from the game''s postinstall.bat.'
' Idempotent - safe on every install, reinstall and update; it only'
' adds what is missing and never removes anything.'
' 3. Call start-riojoy.bat from the game''s launch script before the'
' game. The profile activates immediately and explicitly - no'
' foreground detection - so the virtual controller exists before'
' the game enumerates devices; RIOJoy exits by itself when the'
' game exits. A launcher should wait (with a timeout) on the named'
' event "RIOJoy.Tray.Ready" before starting the game: signaled ='
' pad + ports ready; RIOJoy exit code 4/5 = config/activation'
' failure (reason on stderr); neither = stuck, report.'
' 4. Put NOTHING in the game''s pre-uninstall: drivers stay in place by'
' design (another podized game may use them; idle drivers are'
' harmless).'
''
"The game's profile lives in riojoy\$appDirName\config.json (portable"
'mode - the per-user %APPDATA% config is ignored while it exists). Edit'
"it there, or run riojoy\$appDirName\RioJoy.Tray.exe with no arguments"
'for the profile editor.'
) -join "`r`n"
Set-Content -Path (Join-Path $staging 'README-POD.txt') -Value $readme -Encoding Ascii
# 7. Zip with forward-slash entry names (same rationale as build-package.ps1).
$outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir }
New-Item -ItemType Directory -Force -Path $outDirFull | Out-Null
$zip = Join-Path $outDirFull "RIOJoy-pod-$safeName-$version.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Write-Host "Zipping -> $zip"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$base = (Resolve-Path $staging).Path.TrimEnd('\') + '\'
$fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::CreateNew)
try {
$archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in Get-ChildItem -Path $staging -Recurse -File) {
$entryName = $file.FullName.Substring($base.Length) -replace '\\', '/'
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$archive, $file.FullName, $entryName,
[System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
}
} finally { $archive.Dispose() }
} finally { $fs.Dispose() }
$size = '{0:N1} MB' -f ((Get-Item $zip).Length / 1MB)
Write-Host ''
Write-Host "Pod bundle built: $zip ($size)" -ForegroundColor Green
Write-Host "Integrate: extract into the game's folder; game postinstall.bat calls install-riojoy.bat; launch script calls start-riojoy.bat (game exe: $GameExe)."
}
finally {
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
}
+178
View File
@@ -0,0 +1,178 @@
# Game feedback endpoint (game → RIOJoy → cockpit)
Phase 9 lets external programs drive the cockpit's **output** hardware through
the running RIOJoy tray app: the 72 button 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)).
This page is the wire-protocol reference. For the integrator's view — which
lamp addresses map to which physical buttons, the plasma display's geometry
and fonts, and per-game recipes — see
[OUTPUT-INTEGRATION.md](OUTPUT-INTEGRATION.md).
## 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 [font]] <text> write text to the plasma display
plasma clear clear the plasma display
plasma row <y> <hex32> write one full 128-px bitmap row
plasma box <x> <y> <w> <h> outlined box with a blanked interior
```
- **`<addr>`** — RIO lamp address, decimal or `0x` hex. Valid: `0x000x47`
(the 72 buttons), `0x500x5F` (keypad 0), `0x600x6F` (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 `0x000x3F` (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. A **third** numeric token after the position — with text still following
— is an explicit font: `0` auto (by length: ≤9 chars large, else small),
`2` small 5×7, `5` large 10×14. Short positioned text otherwise always
renders large, which cannot fit inside a `plasma box`. Encoding is
**Latin-1** (one byte = one char, the plasma's wire encoding) — do not send
UTF-8 for accented characters. Text coalesces **per position**: a newer
queued text replaces an older one at the same `x y` only, so multi-field
layouts (callsign + score) can update one field without losing the others.
- **`plasma row`** — one full bitmap row: `<y>` 031 (decimal or `0x` hex),
then exactly **32 hex digits** = 16 bytes = 128 pixels, **MSB = leftmost**.
Rows are written strictly in arrival order (unlike `plasma text`, which
coalesces — a bitmap frame is many rows and must not tear);
`plasma clear` discards any queued rows/text. Up to 128 commands queue;
beyond that incoming rows are dropped and counted — pace full-frame pushes
(a 32-row frame is ~0.77 s of wire time at 9600 baud; stream changed rows,
as the native games did). See
[OUTPUT-INTEGRATION.md](OUTPUT-INTEGRATION.md#bitmap-graphics-esc-p).
- **`plasma box`** — an outlined 1-px box with its interior blanked, in pixel
coordinates (`x` 0127, `y` 031, must fit the panel). This is the overlay
chrome the original games drew for their rank|score field over the callsign
bitmap. The wire's graphics command spans whole bytes horizontally, so the
write covers the byte-aligned span containing the box; pixels inside the
span but outside the box are cleared — place boxes on 8-px boundaries when
that matters. Boxes queue FIFO with rows.
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
over a bounded queue: flooded `text` updates coalesce to the newest value,
`clear` flushes everything queued before it, and bitmap `row`s stream in
order.
## 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` (0255) 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/`.
+279
View File
@@ -0,0 +1,279 @@
# 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 `0x000x47`, 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 `0x500x5F`, external `0x600x6F` (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 111 (or 196 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`** (`(value16383)×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 111, 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).
+277
View File
@@ -0,0 +1,277 @@
# Plasma & output integration guide
How to make a game, sim, or companion tool drive the cockpit's **outputs**
through RIOJoy: the lighted buttons (lamps) and the plasma/VFD text display.
This is the integrator's view — what the hardware can show, which addresses
mean what, and how to feed them. The exact wire grammar lives in
[FEEDBACK.md](FEEDBACK.md); the RIO serial protocol in
[PROTOCOL.md](PROTOCOL.md); the mirror direction (cockpit inputs → game) in
[INPUT-INTEGRATION.md](INPUT-INTEGRATION.md).
## The four output channels
| Channel | Trigger | Effort | Good for |
|---|---|---|---|
| **Automatic lamp feedback** | button press/release | none — built in | lighting the button the player just pressed |
| **Feedback endpoint** (`lamp` / `plasma` lines) | your code writes a text line to a pipe or UDP | small script | warning lights, status flashes, callsigns, scores |
| **Rumble → lamps** | game sets XInput vibration | config only, no code | damage/fire effects from **unmodified** games |
| **Plasma greeting** | profile activation | config only | a static per-game banner |
All four apply only while a profile is **active**; the profile opts into the
endpoint and rumble channels with its `"Feedback"` JSON section (see
[Gating in FEEDBACK.md](FEEDBACK.md#gating)). When RIOJoy is dormant or a
native game owns the ports, endpoint clients stay connected but commands drop.
## The lamp model
### What a lamp is
Each of the **72 cockpit buttons** (RIO addresses `0x000x47`) has a built-in
lamp. A lamp is set with a single state byte combining a **flash mode**
(solid / slow / med / fast) and a **brightness** (off / dim / bright). The
**board runs the blink itself** — one command starts a sustained flash, another
ends it. There is no per-frame cost to a flashing lamp.
The two 4×4 keypads (`0x500x5F` internal, `0x600x6F` external) are valid
protocol addresses but have **no physical lamps** — writes to them are
accepted and do nothing visible.
### Address map (functional groups)
From the panel model (`RioJoy.Core.Editing.CockpitPanel`, mirrored in the
profile editor):
| Group | Addresses | Layout |
|---|---|---|
| Lower Right MFD | `0x000x07` | 4×2; top row `07 06 05 04`, bottom `03 02 01 00` |
| Lower Left MFD | `0x080x0F` | 4×2; top `0F 0E 0D 0C`, bottom `0B 0A 09 08` |
| Secondary column | `0x100x17` | vertical 8 |
| Screen column | `0x180x1F` | vertical 8 |
| Upper Middle MFD | `0x200x27` | 4×2; top `27 26 25 24`, bottom `23 22 21 20` |
| Upper Left MFD | `0x280x2F` | 4×2; top `2F 2E 2D 2C`, bottom `2B 2A 29 28` |
| Upper Right MFD | `0x300x37` | 4×2; top `37 36 35 34`, bottom `33 32 31 30` |
| Throttle column | `0x380x3F` | vertical 8 (`3D` Panic, `3F` Throttle) |
| Joystick cluster | `0x400x47` | `40` Main, `4144` hat B/U/R/L, `45` Pinky, `46` Middle, `47` Upper |
| Internal keypad | `0x500x5F` | 4×4, **no lamps** |
| External keypad | `0x600x6F` | 4×4, **no lamps** |
(`0x480x4F` is a gap — not valid addresses.)
### Ownership: pick lamps the profile doesn't use
Lamps on buttons the active profile maps as *lighted* (`Lit` in the editor,
`iRIO` bit `0x8000`) belong to the automatic press/release feedback — endpoint
writes to them are **dropped** (logged once per address) so your effect can't
fight the built-in behavior. Design your effects on buttons the profile leaves
unlit — a dedicated "warning" MFD cluster the game doesn't bind, for example —
or deliberately leave the target buttons un-Lit in the profile.
### Rate budget
The RIO link is 9600 baud, shared with the ~55 ms analog poll. RIOJoy
coalesces lamp state per address (latest wins) and sends **at most one changed
lamp per 25 ms** (~40/s). Practical consequences:
- Send *state changes*, not periodic refreshes. Repeating the current state
costs nothing but also does nothing.
- A whole-panel effect (`lamp-all`, or sweeping many addresses) takes ~25 ms ×
changed-lamp-count to fully land — a 104-lamp sweep is ~3 s. Fine for an
attract mode; wrong for a fast strobe. For fast effects, use the board's own
flash modes on a few lamps instead.
## The plasma display model
The plasma is a **128 × 32 dot-matrix panel** on its own serial port at 9600
8N1 (so display updates never contend with the input link). It is fully
dot-addressable: it has a text mode (cursor + fonts + attributes) **and a raw
bitmap mode** (`ESC P` graphics write — see
[Bitmap graphics](#bitmap-graphics-esc-p)). The command set is documented in
vRIO's `VPlasma.Core/Protocol/PlasmaProtocol.cs`, recovered from the Tesla
4.10 sources and the display firmware dump.
Text mode has two glyph sizes:
| Font | Cell | Fits per line | Auto-selected when |
|---|---|---|---|
| large (font 5) | 10×14 px | ~12 chars | text ≤ 9 chars |
| small (font 2) | 5×7 px | ~25 chars | text 1020 chars (longer is truncated to 20) |
What the endpoint exposes (v1):
- **`plasma text <text>`** — auto-fit: short text renders large, longer text
small, centered on the display (the `PlasmaPosText` behavior the original
games used; its pivot is x=56, faithfully a touch left of the true 128-px
center). This is the right default for callsigns, scores, and status words.
- **`plasma text <x> <y> <text>`** — explicit cursor position in **pixels**,
top-left origin; the firmware accepts x 0127, y 031. You choose the
position; the font still auto-fits by length. Use this to keep two fields on
screen at once (e.g. callsign top line, score bottom line:
`plasma text 2 2 "VIPER 1-1"` + `plasma text 2 18 "SCORE 4200"`).
- **`plasma text <x> <y> <font> <text>`** — as above with an explicit font
(`2` small 5×7, `5` large 10×14, `0` auto). Auto picks the font by LENGTH,
so short positioned text always renders large; the explicit form is how a
short field ("1", "1000") fits inside a score box.
- **`plasma clear`** — blank the display.
- **`plasma row <y> <hex32>`** — one full 128-px bitmap row; see
[Bitmap graphics](#bitmap-graphics-esc-p).
- **`plasma box <x> <y> <w> <h>`** — outlined box, interior blanked: the
overlay chrome for a field drawn on top of other content (the original
games' rank|score box over the callsign). Byte-aligned horizontally — put
box edges on 8-px boundaries where neighbors matter.
Text is **Latin-1** (one byte per char) — don't send UTF-8.
Not exposed through the endpoint yet (small extensions when needed): text
attribute selection (intensity/underline/reverse/flash) and filled-only boxes.
### Bitmap graphics (`ESC P`)
The display accepts **raw bitmap rows** — this is how the native Red Planet
game draws everything (it renders into a local 1-bpp buffer and streams the
*changed* rows). The wire command:
```
ESC P s y x w h data…
```
`s` = screen (single-screen hardware, ignored), `y` = top row (031),
`x` = left **byte column** (015), `w` = bytes per row, `h` = rows, followed
by `w×h` data bytes, **MSB = leftmost pixel**. The native game always sends
whole rows: `x=0, w=16, h=1` — 16 bytes covering one full 128-px row.
Budget the bandwidth: a full-frame repaint is 32 rows × (7-byte header +
16 data bytes) ≈ 740 bytes ≈ **0.77 s** at 9600 baud. That's why the native
game diffs and streams only changed rows — an animation that touches a few
rows per tick is smooth; full-frame repaints are ~1 fps. Text mode is far
cheaper for text; reserve bitmaps for logos, custom gauges, and icons.
The endpoint exposes whole-row writes as a line command:
```
plasma row <y> <32 hex digits>
```
`y` is 031; the 32 hex digits are the row's 16 bytes left-to-right, MSB =
leftmost pixel. Rows stream strictly in arrival order (up to 128 queued;
overflow drops the incoming row and counts it), so push a frame as rows 031
and it lands intact. Example — a horizontal rule across row 16 and a lit
top-left corner block:
```
plasma row 16 FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
plasma row 0 F0000000000000000000000000000000
```
For animation, keep a 1-bpp frame buffer client-side and send only the rows
that changed since the last tick — exactly what the native game does. The
partial-span form of `ESC P` (arbitrary `x/w/h`) exists in
`PlasmaCommands.GraphicsWrite` for host-side code but is not exposed as a
line command.
### Update semantics
Plasma writes are **single-flight over a bounded queue** whose rules follow
what each command means: flooded `text` updates coalesce to the newest value
(safe to spam a score — the display shows the latest), `clear` discards
everything queued before it, and bitmap `row`s stream strictly in order
(never coalesced — a frame is many rows). Still: update fields on change, not
on a timer.
Lifecycle: on profile activation the display clears and shows the profile's
`PlasmaGreeting` (if set); your first `plasma` command replaces it. On
teardown (profile switch, dormancy, native-game yield) RIOJoy blanks the
display and releases the port.
`plasma text` does **not** clear the rest of the display — it draws at a
position. When a new value is shorter than the old one (`SCORE 900` after
`SCORE 1200`), stale pixels can remain; pad the text to a fixed width or
`plasma clear` first when the layout changes.
## Recipes
### Any XInput game — rumble, zero code
Add to the profile's JSON (`%APPDATA%\RIOJoy\config.json`):
```json
"Feedback": { "Rumble": { "LargeMotorLamps": [32, 33], "SmallMotorLamps": [34], "Threshold": 24 } }
```
Vibration now flashes Upper-Middle-MFD lamps `0x200x22`: off below the
threshold, slow/med/fast flash as intensity rises, per motor. Works with any
game that rumbles the ViGEm pad (net48 flavor only).
### DCS World — Export.lua
A pipe opens as a file on Windows; write lines, flush, done:
```lua
local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w")
local wasCaution = nil
function LuaExportAfterNextFrame()
if not rio then return end
local caution = LoGetMCPState and LoGetMCPState().MasterWarning
if caution ~= wasCaution then -- send changes, not frames
wasCaution = caution
rio:write(caution and "lamp 0x12 fast bright\n" or "lamp 0x12 off\n")
rio:flush()
end
end
function LuaExportStart()
if rio then
rio:write('plasma text "' .. (LoGetPilotName() or "PILOT") .. '"\n')
rio:flush()
end
end
```
(Or configure `"UdpPort"` and use DCS's `socket` library — same lines over
UDP, one or more per datagram.)
### SimHub / other telemetry hubs
Any plugin that can emit custom TCP/UDP/serial output can target the UDP
endpoint (`127.0.0.1:<UdpPort>`) with protocol lines. Map telemetry properties
to `lamp` lines (e.g. shift light → `lamp 0x3D fast bright`) and text
properties to `plasma text`.
### Games with no API — log tailing
```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))
Get-Content 'C:\games\thegame\events.log' -Wait -Tail 0 | ForEach-Object {
if ($_ -match 'PLAYER_HIT') { $w.WriteLine('lamp 0x24 fast bright'); $w.Flush() }
if ($_ -match 'SCORE=(\d+)') { $w.WriteLine("plasma text SCORE $($Matches[1])"); $w.Flush() }
}
```
## Testing without hardware
Both output ports accept `pipe:` endpoints, so the [vRIO](https://gitea.mysticmachines.com/VWE/VRIO)
emulators stand in for the cabinet:
- Profile `RioComPort: "pipe:vrio"` → vRIO's board emulator; its panel shows
lamp states, including flash.
- Profile `PlasmaComPort: "pipe:vplasma"` → the vPlasma display emulator
renders the 112×32 output. On a machine with neither display nor COM2, set
`PlasmaComPort: "off"` instead (the app default is `COM2`).
Then drive the feedback pipe from PowerShell (snippets in
[FEEDBACK.md](FEEDBACK.md#client-snippets)) and watch the emulators.
## Checklist for a new integration
1. Give the game's profile a `"Feedback"` section (it's off otherwise).
2. Choose effect lamps that the profile does **not** mark Lit; note their
addresses from the map above.
3. Decide plasma layout: one auto-centered field, or fixed pixel positions for
multiple fields (pad to fixed width).
4. Emit on **state change** only; let the board do the blinking.
5. Reconnect logic: on pipe write failure, close, reopen, retry — RIOJoy's
endpoint accepts reconnects forever, and commands sent while dormant are
dropped by design (your client doesn't need to track RIOJoy's state).
6. Bench-test against `pipe:vrio` / `pipe:vplasma` before touching the cabinet.
+122 -2
View File
@@ -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 - `PlasmaCommands` ports the `CPlasma` ESC command set (clear/cursor/font/attr/box
draw+fill/text) + `GetFontSize` + the `PlasmaPosText` auto-fit/centering; draw+fill/text) + `GetFontSize` + the `PlasmaPosText` auto-fit/centering;
`PlasmaDisplay` writes them over the secondary COM transport. `PlasmaDisplay` writes them over the secondary COM transport.
-**Remaining:** hardware verification of axis feel + plasma output; the -**Remaining:** hardware verification of axis feel + plasma output. Runtime
game-specific `PlasmaScoreDraw` layout is profile content (Phase 5/7). 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 ✅ ### Phase 5 — Tray app + profiles — code-complete ✅
Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray` Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray`
@@ -431,6 +434,123 @@ XP consumes pre-rendered wallpapers.
computers, adds shortcuts); the single dist zip carries everything computers, adds shortcuts); the single dist zip carries everything
needed for both XP and 10/11, including offline redistributables. 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`](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 ## Open items / risks
+103
View File
@@ -0,0 +1,103 @@
{
"Name": "Tesla",
"MatchExecutables": [ "Descent3" ],
"RioComPort": "COM1",
"PlasmaComPort": "COM2",
"PlasmaGreeting": null,
"WallpaperPath": null,
"Calibration": {
"InvertX": false,
"InvertY": false,
"InvertZ": false,
"InvertXR": false,
"InvertYR": false,
"InvertZR": false,
"EnableZR": true
},
"AxisRouting": {
"X": { "Target": "LeftThumbX", "Mode": "Centered" },
"Y": { "Target": "LeftThumbY", "Mode": "Centered" },
"Z": { "Target": "RightThumbY", "Mode": "UnipolarPositive" },
"Rx": { "Target": "None", "Mode": "Centered" },
"Ry": { "Target": "None", "Mode": "Centered" },
"Rz": { "Target": "RightThumbX", "Mode": "Centered" }
},
"Feedback": {
"AllowLampCommands": true,
"AllowPlasmaText": true,
"Rumble": null
},
"Buttons": {
"47": 32817,
"46": 32818,
"45": 32819,
"44": 32820,
"43": 32821,
"42": 32822,
"41": 32823,
"40": 32824,
"39": 32825,
"38": 32816,
"18": 32840,
"17": 32850,
"16": 32777,
"15": 32954,
"14": 32990,
"13": 32989,
"12": 32987,
"11": 32956,
"10": 32958,
"8": 32988,
"61": 32781,
"63": 32858,
"64": 36865,
"65": 40962,
"66": 40960,
"67": 40961,
"68": 40963,
"69": 36868,
"70": 36867,
"71": 36866
},
"OverlayLabels": {
"b-2F": "WPN 1",
"b-2E": "WPN 2",
"b-2D": "WPN 3",
"b-2C": "WPN 4",
"b-2B": "WPN 5",
"b-2A": "WPN 6",
"b-29": "WPN 7",
"b-28": "WPN 8",
"b-27": "WPN 9",
"b-26": "WPN 10",
"b-10": "AUTOMAP",
"b-11": "REAR VIEW",
"b-12": "HEADLIGHT",
"b-0F": "CM PREV",
"b-0E": "CM NEXT",
"b-0D": "INV NEXT",
"b-0C": "INV PREV",
"b-0B": "CYCLE PRI",
"b-0A": "CYCLE SEC",
"b-08": "INV USE",
"b-3D": "PANIC / CM",
"b-3F": "REVERSE",
"b-40": "FIRE",
"b-41": "SLIDE DN",
"b-42": "SLIDE UP",
"b-43": "SLIDE R",
"b-44": "SLIDE L",
"b-45": "AFTERBURN",
"b-46": "FLARE",
"b-47": "MISSILE"
}
}
+19 -2
View File
@@ -200,7 +200,19 @@ public sealed class AxisCalibrator
// 16838 (not 16383) and a +2 nudge are deliberate legacy anti-snap tweaks. // 16838 (not 16383) and a +2 nudge are deliberate legacy anti-snap tweaks.
_joystickXLast = lJx > 0 ? 16838 - ((lJx + 2) * sRightRate) : AxisOutputs.Center; _joystickXLast = lJx > 0 ? 16838 - ((lJx + 2) * sRightRate) : AxisOutputs.Center;
} }
// lJx == 0 leaves _joystickXLast unchanged (legacy behavior). else
{
// Deliberate divergence from the legacy port, same reasoning as the
// throttle detent above: the legacy held the previous output on a raw
// of exactly 0. A real pot jitters and never rests at exact 0, so the
// hold was invisible on hardware - but vRIO's pad deadzone emits
// sustained exact zeros on release, and the hold latched the last
// in-motion output indefinitely: the ship kept turning at whatever
// rate the stick commanded the instant before release (bench
// 2026-08-01, Descent 3 drifting on yaw/pitch with the stick
// centered). Exact center in must be exact center out.
_joystickXLast = AxisOutputs.Center;
}
return _config.InvertX ? AxisOutputs.Max - _joystickXLast : _joystickXLast; return _config.InvertX ? AxisOutputs.Max - _joystickXLast : _joystickXLast;
} }
@@ -223,7 +235,12 @@ public sealed class AxisCalibrator
lJy -= DeadzoneJoystick; lJy -= DeadzoneJoystick;
_joystickYLast = lJy > 0 ? AxisOutputs.Center - (lJy * sDownRate) : AxisOutputs.Center; _joystickYLast = lJy > 0 ? AxisOutputs.Center - (lJy * sDownRate) : AxisOutputs.Center;
} }
// lJy == 0 leaves _joystickYLast unchanged (legacy behavior). else
{
// Exact 0 centers rather than holding the previous output - see the
// matching branch in JoystickX for the full story.
_joystickYLast = AxisOutputs.Center;
}
return _config.InvertY ? AxisOutputs.Max - _joystickYLast : _joystickYLast; return _config.InvertY ? AxisOutputs.Max - _joystickYLast : _joystickYLast;
} }
+5
View File
@@ -10,6 +10,9 @@ namespace RioJoy.Core.Compat;
internal static class TaskCompat internal static class TaskCompat
{ {
#if NET40 #if NET40
/// <summary>net40 has no <c>Task.CompletedTask</c>.</summary>
public static Task CompletedTask { get; } = TaskEx.FromResult(true);
public static Task Run(Action action) => TaskEx.Run(action); public static Task Run(Action action) => TaskEx.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) => public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
@@ -25,6 +28,8 @@ internal static class TaskCompat
return TaskEx.FromResult(true); return TaskEx.FromResult(true);
} }
#else #else
public static Task CompletedTask => Task.CompletedTask;
public static Task Run(Action action) => Task.Run(action); public static Task Run(Action action) => Task.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) => public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
@@ -0,0 +1,109 @@
using RioJoy.Core.Compat;
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Feedback;
/// <summary>
/// The rate governor between feedback lamp traffic and the 9600-baud RIO link.
/// Every lamp command crosses the link's stop-and-wait command gate (worst case
/// ~150 ms with retransmits) shared with the ~55 ms analog poll, and nothing
/// downstream coalesces — so feedback paths must post here, never call
/// <see cref="ILampSink"/> directly. Keeps a desired/last-sent shadow of all
/// 112 addresses; the pump sends at most one <i>changed</i> lamp per tick
/// (round-robin for fairness), so bursts of identical states collapse to
/// nothing and a flooding client cannot starve the analog poll. One instance
/// per profile activation, so shadow state never leaks across profiles.
/// </summary>
public sealed class CoalescingLampScheduler
{
/// <summary>Default pump tick: ≤40 lamp commands/s at 9600 baud stays polite.</summary>
public static readonly TimeSpan DefaultSendInterval = TimeSpan.FromMilliseconds(25);
private readonly ILampSink _sink;
private readonly TimeSpan _sendInterval;
private readonly object _gate = new();
private readonly byte?[] _desired = new byte?[RioAddress.TableSize];
private readonly byte?[] _lastSent = new byte?[RioAddress.TableSize];
private int _cursor;
public CoalescingLampScheduler(ILampSink sink, TimeSpan? sendInterval = null)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
TimeSpan interval = sendInterval ?? DefaultSendInterval;
// Floor at 1 ms: Task.Delay(0) completes synchronously, which would turn
// RunAsync into an infinite synchronous loop that never yields.
_sendInterval = interval > TimeSpan.Zero ? interval : TimeSpan.FromMilliseconds(1);
}
/// <summary>
/// Set the desired state for one lamp. Thread-safe and non-blocking (safe
/// from the ViGEm callback thread and pipe reader threads). Invalid
/// addresses are ignored — rumble config addresses arrive here unvalidated.
/// </summary>
public void Post(int address, byte state)
{
if (!RioAddress.IsValid(address))
return;
lock (_gate)
_desired[address] = state;
}
/// <summary>Set the desired state for every valid lamp address.</summary>
public void PostAll(byte state)
{
lock (_gate)
{
for (int a = 0; a < RioAddress.TableSize; a++)
{
if (RioAddress.IsValid(a))
_desired[a] = state;
}
}
}
/// <summary>
/// The pump loop: send one changed lamp, sleep a tick, repeat until
/// cancelled. Exits cleanly on cancellation (started fire-and-forget, so it
/// must never fault).
/// </summary>
public async Task RunAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
SendNextChanged();
await TaskCompat.Delay(_sendInterval, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private void SendNextChanged()
{
int address = -1;
byte state = 0;
lock (_gate)
{
for (int i = 0; i < _desired.Length; i++)
{
int a = (_cursor + i) % _desired.Length;
if (_desired[a] is byte want && _lastSent[a] != want)
{
address = a;
state = want;
_lastSent[a] = want;
_cursor = a + 1; // resume after this one — round-robin fairness
break;
}
}
}
// Outside the lock: SetLamp is fire-and-forget but no reason to hold it.
if (address >= 0)
_sink.SetLamp(address, state);
}
}
@@ -0,0 +1,63 @@
namespace RioJoy.Core.Feedback;
/// <summary>What a parsed feedback line asks the cockpit to do.</summary>
public enum FeedbackCommandKind
{
/// <summary>Set one lamp to a state (<c>lamp &lt;addr&gt; &lt;state&gt;</c>).</summary>
Lamp,
/// <summary>Set every valid lamp address to a state (<c>lamp-all &lt;state&gt;</c>).</summary>
LampAll,
/// <summary>Write text to the plasma display (<c>plasma text [x y] &lt;text&gt;</c>).</summary>
PlasmaText,
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
PlasmaClear,
/// <summary>One full 128-px bitmap row (<c>plasma row &lt;y&gt; &lt;32 hex digits&gt;</c>).</summary>
PlasmaRow,
/// <summary>Outlined box with a blanked interior (<c>plasma box &lt;x&gt; &lt;y&gt; &lt;w&gt; &lt;h&gt;</c>).</summary>
PlasmaBox,
}
/// <summary>
/// One inbound cockpit-feedback command, produced by
/// <see cref="FeedbackLineParser"/> from a protocol line (docs/FEEDBACK.md) and
/// consumed by the feedback router. Addresses are already validated against the
/// RIO address space; lamp states are complete state bytes
/// (<see cref="Protocol.RioLampState"/>).
/// </summary>
public sealed record FeedbackCommand
{
public FeedbackCommandKind Kind { get; init; }
/// <summary>RIO lamp address (<see cref="FeedbackCommandKind.Lamp"/> only).</summary>
public int Address { get; init; }
/// <summary>Lamp state byte (<see cref="FeedbackCommandKind.Lamp"/>/<see cref="FeedbackCommandKind.LampAll"/>).</summary>
public byte LampState { get; init; }
/// <summary>Display text (<see cref="FeedbackCommandKind.PlasmaText"/> only).</summary>
public string? Text { get; init; }
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).
/// For <see cref="FeedbackCommandKind.PlasmaRow"/>, <see cref="Y"/> is the row.</summary>
public byte X { get; init; }
public byte Y { get; init; }
/// <summary>Row pixel bytes (<see cref="FeedbackCommandKind.PlasmaRow"/> only; 16 bytes, MSB leftmost).</summary>
public byte[]? Data { get; init; }
/// <summary>Explicit font id for <see cref="FeedbackCommandKind.PlasmaText"/>;
/// 0 = auto-fit by length (the default, and the only pre-font behavior).</summary>
public byte Font { get; init; }
/// <summary>Box width in pixels (<see cref="FeedbackCommandKind.PlasmaBox"/> only).</summary>
public byte Width { get; init; }
/// <summary>Box height in pixels (<see cref="FeedbackCommandKind.PlasmaBox"/> only).</summary>
public byte Height { get; init; }
}
@@ -0,0 +1,61 @@
namespace RioJoy.Core.Feedback;
/// <summary>
/// App-level inbound-feedback endpoint settings
/// (<see cref="Profiles.AppConfig.Feedback"/>; null there = these defaults:
/// named pipe on under <see cref="DefaultPipeName"/>, UDP off). The endpoint is
/// app-lifetime — clients keep their connection across profile switches and
/// dormancy; per-profile settings only gate what gets applied
/// (<see cref="ProfileFeedbackConfig"/>). Serialized into config.json, so no
/// vendor types.
/// </summary>
public sealed record FeedbackEndpointConfig
{
public const string DefaultPipeName = "riojoy-feedback";
/// <summary>Listen on <c>\\.\pipe\&lt;PipeName&gt;</c> for feedback lines.</summary>
public bool PipeEnabled { get; init; } = true;
public string PipeName { get; init; } = DefaultPipeName;
/// <summary>
/// UDP loopback port to also listen on; null = UDP off. Datagrams carry one
/// or more complete protocol lines (docs/FEEDBACK.md).
/// </summary>
public int? UdpPort { get; init; }
}
/// <summary>
/// Per-profile feedback application settings
/// (<see cref="Profiles.RioProfile.Feedback"/>; null there = inbound feedback
/// is not applied for this profile — commands are dropped).
/// </summary>
public sealed record ProfileFeedbackConfig
{
/// <summary>Apply inbound <c>lamp</c>/<c>lamp-all</c> commands.</summary>
public bool AllowLampCommands { get; init; } = true;
/// <summary>Apply inbound <c>plasma</c> commands.</summary>
public bool AllowPlasmaText { get; init; } = true;
/// <summary>XInput rumble → lamp flash mapping; null = off.</summary>
public RumbleLampConfig? Rumble { get; init; }
}
/// <summary>
/// Maps ViGEm pad vibration onto cockpit lamps: each motor drives its listed
/// RIO lamp addresses through flash states scaled by intensity (off below
/// <see cref="Threshold"/>, then slow/med/fast thirds — the board sustains the
/// blink, so constant rumble costs one lamp command).
/// </summary>
public sealed record RumbleLampConfig
{
/// <summary>RIO lamp addresses driven by the large (low-frequency) motor.</summary>
public List<int> LargeMotorLamps { get; init; } = new();
/// <summary>RIO lamp addresses driven by the small (high-frequency) motor.</summary>
public List<int> SmallMotorLamps { get; init; } = new();
/// <summary>Motor value (0-255) below which the lamps turn off.</summary>
public byte Threshold { get; init; } = 24;
}
@@ -0,0 +1,76 @@
using System.Text;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Assembles raw endpoint bytes into protocol lines for
/// <see cref="FeedbackLineParser"/>: LF terminates a line, a preceding CR is
/// stripped (CRLF and LF both work), and bytes decode as Latin-1 (one byte =
/// one char — the plasma wire encoding, so every byte 0x20-0xFF round-trips).
/// A line longer than <see cref="MaxLineLength"/> is discarded through its next
/// LF, which keeps a binary client that connected by mistake from ballooning
/// the buffer. Not thread-safe; each connection/datagram reader owns one.
/// </summary>
public sealed class FeedbackLineBuffer
{
public const int MaxLineLength = 256;
private readonly StringBuilder _line = new();
private bool _discarding;
/// <summary>Feed <paramref name="count"/> bytes; returns the completed lines.</summary>
public IEnumerable<string> Feed(byte[] buffer, int count)
{
List<string>? lines = null;
for (int i = 0; i < count; i++)
{
byte b = buffer[i];
if (b == (byte)'\n')
{
if (!_discarding)
{
if (_line.Length > 0 && _line[_line.Length - 1] == '\r')
_line.Length--;
(lines ??= new List<string>()).Add(_line.ToString());
}
_line.Length = 0;
_discarding = false;
}
else if (!_discarding)
{
if (_line.Length >= MaxLineLength)
{
_line.Length = 0;
_discarding = true;
}
else
{
_line.Append((char)b); // Latin-1: byte == code point
}
}
}
return lines ?? Enumerable.Empty<string>(); // net40: no Array.Empty
}
/// <summary>
/// End-of-datagram flush (UDP): the remaining buffered content is one final
/// line even without a trailing LF. Returns <see langword="null"/> when
/// there is nothing buffered. Pipe readers never flush — they wait for LF.
/// </summary>
public string? Flush()
{
if (_discarding)
{
_discarding = false;
_line.Length = 0;
return null;
}
if (_line.Length == 0)
return null;
if (_line[_line.Length - 1] == '\r')
_line.Length--;
string s = _line.ToString();
_line.Length = 0;
return s.Length == 0 ? null : s;
}
}
@@ -0,0 +1,443 @@
using System.Globalization;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Parses one line of the inbound feedback protocol (docs/FEEDBACK.md) into a
/// <see cref="FeedbackCommand"/>. Pure and forgiving: keywords are
/// case-insensitive, malformed lines produce an error string (the caller logs
/// and drops them — a bad line must never cost a client its connection).
/// This is also the validation boundary for lamp addresses:
/// <c>SerialLampSink</c> casts to <c>byte</c> unchecked, so out-of-range
/// addresses are rejected here.
/// </summary>
public static class FeedbackLineParser
{
/// <summary>
/// Parse one line. Returns <see langword="true"/> with a command when the
/// line is actionable. Returns <see langword="false"/> with
/// <paramref name="error"/> <see langword="null"/> for blank/comment lines
/// (skip silently) or an error message for malformed ones (log + drop).
/// </summary>
public static bool TryParse(string line, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
if (string.IsNullOrEmpty(line))
return false;
string s = line.Trim();
if (s.Length == 0 || s[0] == '#' || s[0] == ';')
return false; // blank or comment
int pos = 0;
string keyword = NextToken(s, ref pos)!;
switch (keyword.ToLowerInvariant())
{
case "lamp":
return TryParseLamp(s, pos, all: false, out command, out error);
case "lamp-all":
return TryParseLamp(s, pos, all: true, out command, out error);
case "plasma":
return TryParsePlasma(s, pos, out command, out error);
default:
error = $"unknown command '{keyword}'";
return false;
}
}
private static bool TryParseLamp(
string s, int pos, bool all, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
int address = 0;
if (!all)
{
string? addrToken = NextToken(s, ref pos);
if (addrToken is null)
{
error = "lamp needs an address and a state";
return false;
}
if (!TryParseNumber(addrToken, out address))
{
error = $"bad lamp address '{addrToken}'";
return false;
}
if (!RioAddress.IsValid(address))
{
error = $"lamp address 0x{address:X2} out of range " +
"(valid: 0x00-0x47, 0x50-0x5F, 0x60-0x6F)";
return false;
}
}
string? first = NextToken(s, ref pos);
if (first is null)
{
error = "missing lamp state";
return false;
}
string? second = NextToken(s, ref pos);
if (NextToken(s, ref pos) is string extra)
{
error = $"unexpected token '{extra}'";
return false;
}
byte state;
if (second is null)
{
// Single token: a raw state byte, or a brightness word (flash = solid).
if (TryParseNumber(first, out int raw))
{
if (raw is < 0 or > 0x3F)
{
error = $"raw lamp state must be 0x00-0x3F, got '{first}'";
return false;
}
state = (byte)raw;
}
else if (TryBrightness(first, out LampField1 f1, out LampField2 f2))
{
state = RioLampState.Compose(LampFlash.Solid, f1, f2);
}
else
{
error = $"unrecognized lamp state '{first}'";
return false;
}
}
else
{
if (!TryFlash(first, out LampFlash flash))
{
error = $"unrecognized flash mode '{first}' (solid|slow|med|fast)";
return false;
}
if (!TryBrightness(second, out LampField1 f1, out LampField2 f2))
{
error = $"unrecognized brightness '{second}' (off|dim|bright)";
return false;
}
state = RioLampState.Compose(flash, f1, f2);
}
command = new FeedbackCommand
{
Kind = all ? FeedbackCommandKind.LampAll : FeedbackCommandKind.Lamp,
Address = address,
LampState = state,
};
return true;
}
private static bool TryParsePlasma(
string s, int pos, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
string? sub = NextToken(s, ref pos);
if (sub is null)
{
error = "plasma needs a subcommand (text|clear|row)";
return false;
}
switch (sub.ToLowerInvariant())
{
case "clear":
if (NextToken(s, ref pos) is string extra)
{
error = $"unexpected token '{extra}'";
return false;
}
command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear };
return true;
case "text":
return TryParsePlasmaText(s, pos, out command, out error);
case "row":
return TryParsePlasmaRow(s, pos, out command, out error);
case "box":
return TryParsePlasmaBox(s, pos, out command, out error);
default:
error = $"unknown plasma subcommand '{sub}'";
return false;
}
}
// plasma row <y> <32 hex digits>: one full 128-px bitmap row (16 bytes,
// MSB = leftmost pixel), matching the native game's whole-row streaming.
private static bool TryParsePlasmaRow(
string s, int pos, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
string? yToken = NextToken(s, ref pos);
if (yToken is null || !TryParseNumber(yToken, out int y))
{
error = "plasma row needs a row number and 32 hex digits";
return false;
}
if (y is < 0 or > 31)
{
error = $"plasma row {y} out of range (0-31)";
return false;
}
string? hex = NextToken(s, ref pos);
if (hex is null)
{
error = "plasma row needs 32 hex digits of row data";
return false;
}
if (NextToken(s, ref pos) is string extra)
{
error = $"unexpected token '{extra}'";
return false;
}
if (hex.Length != 32)
{
error = $"plasma row data must be exactly 32 hex digits (16 bytes), got {hex.Length}";
return false;
}
var data = new byte[16];
for (int i = 0; i < 16; i++)
{
int hi = HexNibble(hex[i * 2]);
int lo = HexNibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0)
{
error = $"plasma row data has a non-hex character ('{hex[hi < 0 ? i * 2 : i * 2 + 1]}')";
return false;
}
data[i] = (byte)((hi << 4) | lo);
}
command = new FeedbackCommand
{
Kind = FeedbackCommandKind.PlasmaRow,
Y = (byte)y,
Data = data,
};
return true;
}
// plasma box <x> <y> <w> <h>: outlined box with a blanked interior, pixel
// coordinates. The wire's graphics command spans whole bytes, so the write
// covers the byte-aligned span containing x..x+w-1; pixels inside that
// span but outside the box are cleared (see PlasmaDisplay.BoxAsync).
private static bool TryParsePlasmaBox(
string s, int pos, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
int[] v = new int[4];
string[] names = { "x", "y", "w", "h" };
for (int i = 0; i < 4; i++)
{
string? tok = NextToken(s, ref pos);
if (tok is null || !TryParseNumber(tok, out v[i]))
{
error = "plasma box needs four numbers: x y w h";
return false;
}
}
if (NextToken(s, ref pos) is string extra)
{
error = $"unexpected token '{extra}'";
return false;
}
if (v[0] is < 0 or > 127 || v[1] is < 0 or > 31)
{
error = $"plasma box position ({v[0]},{v[1]}) out of range (x 0-127, y 0-31)";
return false;
}
if (v[2] < 1 || v[0] + v[2] > 128 || v[3] < 1 || v[1] + v[3] > 32)
{
error = $"plasma box {v[2]}x{v[3]} at ({v[0]},{v[1]}) exceeds the 128x32 panel";
return false;
}
command = new FeedbackCommand
{
Kind = FeedbackCommandKind.PlasmaBox,
X = (byte)v[0],
Y = (byte)v[1],
Width = (byte)v[2],
Height = (byte)v[3],
};
return true;
}
private static int HexNibble(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
private static bool TryParsePlasmaText(
string s, int pos, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
// Optional "x y" position: taken only when the first TWO tokens are both
// numeric (so `plasma text 42` displays "42"; use quotes to force text).
// A THIRD numeric token after a position, with text still following, is
// an explicit font id (0 = auto-fit by length; 2 small 5x7, 5 large
// 10x14) - short positioned text otherwise always renders large, which
// cannot fit inside a score box. Unpositioned text takes no font (the
// auto-center math chooses it); quote text that starts with numbers.
byte x = 0, y = 0, font = 0;
int textStart = pos;
int peek = pos;
string? t1 = NextToken(s, ref peek);
if (t1 is not null && TryParseNumber(t1, out int xv))
{
string? t2 = NextToken(s, ref peek);
if (t2 is not null && TryParseNumber(t2, out int yv))
{
if (xv is < 0 or > 255 || yv is < 0 or > 255)
{
error = $"plasma position ({xv},{yv}) out of range (0-255)";
return false;
}
x = (byte)xv;
y = (byte)yv;
textStart = peek;
int fontPeek = peek;
string? t3 = NextToken(s, ref fontPeek);
if (t3 is not null && TryParseNumber(t3, out int fv))
{
// Only a font when text still follows - `plasma text 2 2 7`
// keeps displaying "7" as it always has.
if (TryTakeText(s, fontPeek, out string? peekText, out _) &&
!string.IsNullOrEmpty(peekText))
{
if (fv is < 0 or > 7)
{
error = $"plasma font {fv} out of range (0-7; 0 = auto)";
return false;
}
font = (byte)fv;
textStart = fontPeek;
}
}
}
// t1 numeric but t2 not: the whole remainder (from textStart) is text
}
if (!TryTakeText(s, textStart, out string? text, out error))
return false;
if (text is null)
{
error = "plasma text needs text to display";
return false;
}
command = new FeedbackCommand
{
Kind = FeedbackCommandKind.PlasmaText,
Text = text,
X = x,
Y = y,
Font = font,
};
return true;
}
// Rest-of-line text: quoted (quotes stripped, no escapes, nothing may follow
// the closing quote) or the trimmed remainder. Null = nothing there.
private static bool TryTakeText(string s, int pos, out string? text, out string? error)
{
text = null;
error = null;
while (pos < s.Length && char.IsWhiteSpace(s[pos]))
pos++;
if (pos >= s.Length)
return true;
if (s[pos] == '"')
{
int close = s.IndexOf('"', pos + 1);
if (close < 0)
{
error = "unterminated quote in plasma text";
return false;
}
if (close + 1 < s.Length && s.Substring(close + 1).Trim().Length != 0)
{
error = "unexpected content after closing quote";
return false;
}
text = s.Substring(pos + 1, close - pos - 1);
return true;
}
text = s.Substring(pos).TrimEnd();
return true;
}
private static string? NextToken(string s, ref int pos)
{
while (pos < s.Length && char.IsWhiteSpace(s[pos]))
pos++;
if (pos >= s.Length)
return null;
int start = pos;
while (pos < s.Length && !char.IsWhiteSpace(s[pos]))
pos++;
return s[start..pos];
}
// Decimal, or hex with an 0x/0X prefix.
private static bool TryParseNumber(string token, out int value)
{
if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return int.TryParse(
token.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
return int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value);
}
private static bool TryFlash(string token, out LampFlash flash)
{
switch (token.ToLowerInvariant())
{
case "solid": flash = LampFlash.Solid; return true;
case "slow": flash = LampFlash.FlashSlow; return true;
case "med": flash = LampFlash.FlashMed; return true;
case "fast": flash = LampFlash.FlashFast; return true;
default: flash = LampFlash.Solid; return false;
}
}
// Brightness words set both fields, matching SolidOff/SolidDim/SolidBright.
private static bool TryBrightness(string token, out LampField1 f1, out LampField2 f2)
{
switch (token.ToLowerInvariant())
{
case "off": f1 = LampField1.Off; f2 = LampField2.Off; return true;
case "dim": f1 = LampField1.Dim; f2 = LampField2.Dim; return true;
case "bright": f1 = LampField1.Bright; f2 = LampField2.Bright; return true;
default: f1 = LampField1.Off; f2 = LampField2.Off; return false;
}
}
}
@@ -0,0 +1,220 @@
using System.IO.Pipes;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Named-pipe listener for the inbound feedback protocol: serves
/// <c>\\.\pipe\&lt;name&gt;</c>, reassembles lines
/// (<see cref="FeedbackLineBuffer"/>), and hands each to the owner — parsing
/// and routing live in <see cref="FeedbackService"/>, so this class is pure
/// transport. Modeled on vRIO's <c>VRioPipeService</c> (dedicated background
/// threads — net40 has no <c>WaitForConnectionAsync</c>; throwaway poke-connect
/// on stop because a pending <c>WaitForConnection</c> can survive Dispose on
/// net48), with two deliberate differences: the pipe is
/// <see cref="PipeDirection.In"/> — the server never writes, so the 0-byte
/// pipe-buffer write deadlock class cannot occur and no reply path exists — and
/// up to <see cref="MaxClients"/> clients may stay connected at once (a sim
/// export script and a SimHub plugin both live here). Clients reconnect
/// forever; a malformed or overlong line never costs a client its connection.
/// </summary>
public sealed class FeedbackPipeServer : IDisposable
{
/// <summary>Concurrent client cap (pipe instances of the served name).</summary>
public const int MaxClients = 4;
private readonly string _pipeName;
private readonly Action<string> _onLine;
private readonly Action<string>? _log;
private readonly SemaphoreSlim _slots = new(MaxClients, MaxClients);
private readonly object _stateGate = new();
private readonly List<NamedPipeServerStream> _open = new();
private readonly List<Thread> _readers = new();
private Thread? _accept;
private volatile bool _running;
public FeedbackPipeServer(string pipeName, Action<string> onLine, Action<string>? log = null)
{
if (string.IsNullOrWhiteSpace(pipeName))
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
_pipeName = pipeName;
_onLine = onLine ?? throw new ArgumentNullException(nameof(onLine));
_log = log;
}
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
public string PipeName => _pipeName;
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
public void Start()
{
if (_running)
return;
_running = true;
_accept = new Thread(AcceptLoop)
{
IsBackground = true,
Name = $"RIOJoy feedback pipe ({_pipeName})",
};
_accept.Start();
_log?.Invoke($@"feedback: listening on \\.\pipe\{_pipeName}");
}
public void Dispose()
{
if (!_running)
return;
_running = false;
// A WaitForConnection pending on a disposed stream can survive the
// Dispose on net48; a throwaway client connect releases it either way.
try
{
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
poke.Connect(100);
}
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
NamedPipeServerStream[] open;
Thread[] readers;
lock (_stateGate)
{
open = _open.ToArray();
_open.Clear();
readers = _readers.ToArray();
_readers.Clear();
}
foreach (NamedPipeServerStream pipe in open)
{
try { pipe.Dispose(); }
catch (IOException) { }
}
_accept?.Join(1000);
_accept = null;
foreach (Thread reader in readers)
reader.Join(1000);
}
private void AcceptLoop()
{
bool busyLogged = false; // log a name collision once, not per retry
while (_running)
{
// At capacity, park until a reader frees its slot (timed, so
// shutdown can't wedge on a missed release).
if (!_slots.Wait(200))
continue;
if (!_running)
{
_slots.Release();
return;
}
NamedPipeServerStream pipe;
try
{
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.In, MaxClients,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Name already served — most likely a second RIOJoy instance.
_slots.Release();
if (!busyLogged)
{
busyLogged = true;
_log?.Invoke($@"feedback: \\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying");
}
for (int i = 0; i < 20 && _running; i++)
Thread.Sleep(100);
continue;
}
busyLogged = false;
lock (_stateGate)
_open.Add(pipe);
try
{
pipe.WaitForConnection();
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
{
Drop(pipe);
continue; // disposed by Dispose(), or the client vanished mid-connect
}
if (!_running)
{
Drop(pipe);
return;
}
var reader = new Thread(() => ReadUntilDisconnect(pipe))
{
IsBackground = true,
Name = $"RIOJoy feedback pipe reader ({_pipeName})",
};
lock (_stateGate)
_readers.Add(reader);
reader.Start(); // the reader owns the slot + stream from here
}
}
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
{
var buffer = new byte[512];
var lines = new FeedbackLineBuffer();
try
{
while (_running)
{
int n;
try
{
n = pipe.Read(buffer, 0, buffer.Length);
}
catch (Exception ex) when (
ex is IOException or ObjectDisposedException or InvalidOperationException)
{
return; // client gone or shutdown
}
if (n == 0)
return; // client closed its end
foreach (string line in lines.Feed(buffer, n))
Handle(line);
}
}
finally
{
Drop(pipe);
lock (_stateGate)
_readers.Remove(Thread.CurrentThread);
}
}
private void Handle(string line)
{
try
{
_onLine(line);
}
catch (Exception ex)
{
// The line sink must never kill a reader; log and keep serving.
_log?.Invoke($"feedback: line handler failed: {ex.Message}");
}
}
private void Drop(NamedPipeServerStream pipe)
{
lock (_stateGate)
_open.Remove(pipe);
try { pipe.Dispose(); }
catch (IOException) { }
_slots.Release();
}
}
+270
View File
@@ -0,0 +1,270 @@
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Applies inbound <see cref="FeedbackCommand"/>s to the active profile's
/// outputs. The listeners dispatch here from their reader threads; the target
/// (scheduler + map + plasma + per-profile config) is attached on profile
/// activation and detached on teardown — detached, everything drops silently
/// (dormancy and native-game yield are normal, not errors).
///
/// Precedence: a <c>lamp</c> write to an address whose map entry has
/// <see cref="RioMapEntry.HasLamp"/> is dropped — the <see cref="InputRouter"/>
/// owns those lamps (bright on press / dim on release) and feedback must not
/// fight it. Such drops are logged once per address per attach so a
/// misconfigured client is diagnosable. <c>lamp-all</c> silently skips
/// profile-owned lamps for the same reason.
///
/// Plasma writes are single-flight over a small bounded queue whose rules
/// match what each command means: <c>text</c> coalesces per position (only the
/// newest queued text at the same (x,y) survives — a flooding score updater
/// shows the latest value, while other fields on the glass keep theirs),
/// <c>clear</c> flushes everything queued before it, and bitmap <c>row</c>s
/// and <c>box</c>es are FIFO in arrival order (a frame is many rows;
/// coalescing would tear it). The bound caps what a flooding client can queue
/// against the 9600-baud display port.
/// </summary>
public sealed class FeedbackRouter
{
private sealed class Target
{
public Target(CoalescingLampScheduler lamps, RioInputMap map,
PlasmaDisplay? plasma, ProfileFeedbackConfig config)
{
Lamps = lamps;
Map = map;
Plasma = plasma;
Config = config;
}
public CoalescingLampScheduler Lamps { get; }
public RioInputMap Map { get; }
public PlasmaDisplay? Plasma { get; }
public ProfileFeedbackConfig Config { get; }
public HashSet<int> LoggedOwnedDrops { get; } = new();
}
// ~4 full bitmap frames; beyond this an incoming row is dropped (counted).
private const int MaxPlasmaQueue = 128;
private readonly object _gate = new();
private readonly List<FeedbackCommand> _plasmaQueue = new();
private Target? _target;
private bool _plasmaBusy;
private long _dropped;
/// <summary>Diagnostics (dropped profile-owned lamp writes, plasma faults).</summary>
public event Action<string>? Logged;
/// <summary>Commands dropped for any reason (detached, disallowed, profile-owned).</summary>
public long DroppedCommands => Interlocked.Read(ref _dropped);
/// <summary>Point feedback at the just-activated profile's outputs.</summary>
public void Attach(CoalescingLampScheduler lamps, RioInputMap map,
PlasmaDisplay? plasma, ProfileFeedbackConfig config)
{
if (lamps is null) throw new ArgumentNullException(nameof(lamps));
if (map is null) throw new ArgumentNullException(nameof(map));
if (config is null) throw new ArgumentNullException(nameof(config));
lock (_gate)
{
_target = new Target(lamps, map, plasma, config);
_plasmaQueue.Clear(); // queued content belonged to the previous profile
}
}
/// <summary>Drop the target; subsequent commands are dropped (counted).</summary>
public void Detach()
{
lock (_gate)
{
_target = null;
_plasmaQueue.Clear();
}
}
/// <summary>Apply one command. Thread-safe, non-blocking.</summary>
public void Dispatch(FeedbackCommand command)
{
if (command is null)
return;
Target? target;
lock (_gate)
target = _target;
if (target is null)
{
Interlocked.Increment(ref _dropped);
return;
}
switch (command.Kind)
{
case FeedbackCommandKind.Lamp:
DispatchLamp(target, command);
break;
case FeedbackCommandKind.LampAll:
DispatchLampAll(target, command);
break;
case FeedbackCommandKind.PlasmaText:
case FeedbackCommandKind.PlasmaClear:
case FeedbackCommandKind.PlasmaRow:
case FeedbackCommandKind.PlasmaBox:
DispatchPlasma(target, command);
break;
}
}
private void DispatchLamp(Target target, FeedbackCommand command)
{
if (!target.Config.AllowLampCommands)
{
Interlocked.Increment(ref _dropped);
return;
}
if (target.Map[command.Address].HasLamp)
{
Interlocked.Increment(ref _dropped);
bool firstTime;
lock (_gate)
firstTime = target.LoggedOwnedDrops.Add(command.Address);
if (firstTime)
Logged?.Invoke(
$"feedback: lamp 0x{command.Address:X2} is profile-mapped (HasLamp) — dropped");
return;
}
target.Lamps.Post(command.Address, command.LampState);
}
private void DispatchLampAll(Target target, FeedbackCommand command)
{
if (!target.Config.AllowLampCommands)
{
Interlocked.Increment(ref _dropped);
return;
}
for (int a = 0; a < RioAddress.TableSize; a++)
{
if (RioAddress.IsValid(a) && !target.Map[a].HasLamp)
target.Lamps.Post(a, command.LampState);
}
}
private void DispatchPlasma(Target target, FeedbackCommand command)
{
if (target.Plasma is null || !target.Config.AllowPlasmaText)
{
Interlocked.Increment(ref _dropped);
return;
}
lock (_gate)
{
switch (command.Kind)
{
case FeedbackCommandKind.PlasmaClear:
// A clear supersedes everything queued before it.
for (int i = 0; i < _plasmaQueue.Count; i++)
Interlocked.Increment(ref _dropped);
_plasmaQueue.Clear();
_plasmaQueue.Add(command);
break;
case FeedbackCommandKind.PlasmaText:
// Only the newest text FOR THE SAME POSITION survives (a
// score updater shows the latest value). Texts at other
// positions are other fields - the documented multi-field
// layout (callsign top, score bottom) sends several in a
// burst, and the original global coalescing ate all but
// the last of them. Queued rows/boxes keep their place.
for (int i = _plasmaQueue.Count - 1; i >= 0; i--)
{
if (_plasmaQueue[i].Kind == FeedbackCommandKind.PlasmaText &&
_plasmaQueue[i].X == command.X && _plasmaQueue[i].Y == command.Y)
{
_plasmaQueue.RemoveAt(i);
Interlocked.Increment(ref _dropped); // superseded before it ran
}
}
if (_plasmaQueue.Count >= MaxPlasmaQueue)
{
Interlocked.Increment(ref _dropped);
return;
}
_plasmaQueue.Add(command);
break;
default: // PlasmaRow/PlasmaBox: strict FIFO — a bitmap frame is many rows
if (_plasmaQueue.Count >= MaxPlasmaQueue)
{
Interlocked.Increment(ref _dropped); // client outran the display
return;
}
_plasmaQueue.Add(command);
break;
}
if (_plasmaBusy)
return;
_plasmaBusy = true;
command = TakeQueuedPlasma()!;
}
StartPlasmaWrite(target, command);
}
// Caller holds _gate.
private FeedbackCommand? TakeQueuedPlasma()
{
if (_plasmaQueue.Count == 0)
return null;
FeedbackCommand head = _plasmaQueue[0];
_plasmaQueue.RemoveAt(0);
return head;
}
private void StartPlasmaWrite(Target target, FeedbackCommand command)
{
Task write = command.Kind switch
{
FeedbackCommandKind.PlasmaClear => target.Plasma!.ClearAsync(),
FeedbackCommandKind.PlasmaRow => target.Plasma!.RowAsync(command.Y, command.Data!),
FeedbackCommandKind.PlasmaBox =>
target.Plasma!.BoxAsync(command.X, command.Y, command.Width, command.Height),
_ => target.Plasma!.PosTextAsync(command.Text ?? string.Empty, command.X, command.Y,
0, command.Font),
};
write.ContinueWith(w =>
{
if (w.Exception is not null) // observe: an unobserved fault kills net40
Logged?.Invoke($"feedback: plasma write failed: {w.Exception.GetBaseException().Message}");
FeedbackCommand? next;
Target? current;
lock (_gate)
{
current = _target; // queued content applies to the *current* profile's display
if (current?.Plasma is null || !current.Config.AllowPlasmaText)
{
for (int i = 0; i < _plasmaQueue.Count; i++)
Interlocked.Increment(ref _dropped);
_plasmaQueue.Clear();
_plasmaBusy = false;
return;
}
next = TakeQueuedPlasma();
if (next is null)
{
_plasmaBusy = false; // queue drained; a racing Dispatch starts fresh
return;
}
// Busy stays true across the chained write, so queue ordering
// holds — concurrent dispatches keep appending behind us.
}
StartPlasmaWrite(current, next);
}, TaskContinuationOptions.ExecuteSynchronously);
}
}
+127
View File
@@ -0,0 +1,127 @@
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
namespace RioJoy.Core.Feedback;
/// <summary>
/// The inbound game-feedback endpoint, assembled: pipe + UDP listeners feed
/// protocol lines here; lines parse into <see cref="FeedbackCommand"/>s and
/// route to the active profile's outputs. App-lifetime by design — the
/// coordinator creates one lazily and keeps it across profile switches, so
/// external clients hold their connection through switches and dormancy;
/// <see cref="Attach"/>/<see cref="Detach"/> only swap where commands land
/// (detached = dropped). <see cref="Attach"/> owns the per-activation
/// <see cref="CoalescingLampScheduler"/> (creates it, runs its pump, cancels it
/// on detach) and returns it so the rumble adapter can share the one rate
/// governor.
/// </summary>
public sealed class FeedbackService : IDisposable
{
// A misbehaving client can emit garbage at line rate; log the first few and
// go quiet instead of flooding the tray status/log.
private const int MaxMalformedLogs = 5;
private readonly FeedbackEndpointConfig _config;
private readonly FeedbackRouter _router = new();
private FeedbackPipeServer? _pipe;
private FeedbackUdpListener? _udp;
private CancellationTokenSource? _schedulerCts;
private bool _started;
private long _malformed;
public FeedbackService(FeedbackEndpointConfig? config)
{
_config = config ?? new FeedbackEndpointConfig();
_router.Logged += message => Logged?.Invoke(message);
}
/// <summary>Diagnostics: listener lifecycle, malformed lines, dropped lamp writes.</summary>
public event Action<string>? Logged;
/// <summary>Total lines that failed to parse (all clients).</summary>
public long MalformedLines => Interlocked.Read(ref _malformed);
/// <summary>Commands dropped (detached, disallowed, or profile-owned lamps).</summary>
public long DroppedCommands => _router.DroppedCommands;
/// <summary>Start the configured listeners (idempotent).</summary>
public void Start()
{
if (_started)
return;
_started = true;
if (_config.PipeEnabled)
{
_pipe = new FeedbackPipeServer(_config.PipeName, HandleLine, OnLog);
_pipe.Start();
}
if (_config.UdpPort is int port)
{
try
{
_udp = new FeedbackUdpListener(port, HandleLine, OnLog);
_udp.Start();
}
catch (System.Net.Sockets.SocketException ex)
{
// Port taken — feedback still works over the pipe; say so and go on.
OnLog($"feedback: UDP port {port} unavailable ({ex.Message}) — pipe only");
}
}
}
/// <summary>
/// Point inbound feedback at a just-activated profile's outputs. Returns the
/// live lamp scheduler (share it with the rumble adapter — one governor for
/// all feedback lamp traffic).
/// </summary>
public CoalescingLampScheduler Attach(
ILampSink lamps, RioInputMap map, PlasmaDisplay? plasma, ProfileFeedbackConfig config)
{
Detach();
var scheduler = new CoalescingLampScheduler(lamps);
_schedulerCts = new CancellationTokenSource();
_ = scheduler.RunAsync(_schedulerCts.Token); // exits cleanly on cancel, never faults
_router.Attach(scheduler, map, plasma, config);
return scheduler;
}
/// <summary>Drop the profile target; subsequent commands are dropped (counted).</summary>
public void Detach()
{
_router.Detach();
_schedulerCts?.Cancel();
_schedulerCts?.Dispose();
_schedulerCts = null;
}
private void HandleLine(string line)
{
if (FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error))
{
_router.Dispatch(command!);
}
else if (error is not null)
{
long count = Interlocked.Increment(ref _malformed);
if (count <= MaxMalformedLogs)
OnLog($"feedback: bad line ({error})" +
(count == MaxMalformedLogs ? " — further malformed lines suppressed" : string.Empty));
}
}
private void OnLog(string message) => Logged?.Invoke(message);
public void Dispose()
{
Detach();
_pipe?.Dispose();
_pipe = null;
_udp?.Dispose();
_udp = null;
_started = false;
}
}
@@ -0,0 +1,110 @@
using System.Net;
using System.Net.Sockets;
namespace RioJoy.Core.Feedback;
/// <summary>
/// UDP loopback listener for the inbound feedback protocol — the transport sim
/// export scripts speak natively (DCS Export.lua, SimHub, X-Plane). Binds
/// <see cref="IPAddress.Loopback"/> only, so nothing off-machine can inject
/// commands and no firewall prompt appears. Each datagram carries one or more
/// complete protocol lines; end-of-datagram terminates the final line even
/// without a trailing LF, and nothing fragments across datagrams. Blocking
/// <c>Receive</c> on a background thread (net40 has no <c>ReceiveAsync</c> —
/// one code path for both flavors); <c>Close</c> unblocks it on dispose.
/// </summary>
public sealed class FeedbackUdpListener : IDisposable
{
/// <summary>Datagrams larger than this are dropped (guards a hostile/broken sender).</summary>
public const int MaxDatagramBytes = 4096;
private readonly UdpClient _udp;
private readonly Action<string> _onLine;
private readonly Action<string>? _log;
private Thread? _thread;
private volatile bool _running;
/// <summary>Binds immediately; throws <see cref="SocketException"/> if the port is taken.</summary>
public FeedbackUdpListener(int port, Action<string> onLine, Action<string>? log = null)
{
_onLine = onLine ?? throw new ArgumentNullException(nameof(onLine));
_log = log;
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
Port = ((IPEndPoint)_udp.Client.LocalEndPoint!).Port;
}
/// <summary>The bound port (resolves a requested port of 0 to the ephemeral one).</summary>
public int Port { get; }
/// <summary>Start receiving (idempotent).</summary>
public void Start()
{
if (_running)
return;
_running = true;
_thread = new Thread(ReceiveLoop)
{
IsBackground = true,
Name = $"RIOJoy feedback UDP (:{Port})",
};
_thread.Start();
_log?.Invoke($"feedback: listening on udp://127.0.0.1:{Port}");
}
public void Dispose()
{
if (!_running)
{
_udp.Close();
return;
}
_running = false;
_udp.Close(); // unblocks the pending Receive with a SocketException
_thread?.Join(1000);
_thread = null;
}
private void ReceiveLoop()
{
var lines = new FeedbackLineBuffer(); // reset per datagram via Flush
while (_running)
{
IPEndPoint? remote = null;
byte[] datagram;
try
{
datagram = _udp.Receive(ref remote!);
}
catch (Exception ex) when (ex is SocketException or ObjectDisposedException)
{
if (!_running)
return; // closed by Dispose
continue; // e.g. ICMP port-unreachable reflected as SocketException
}
if (datagram.Length > MaxDatagramBytes)
{
_log?.Invoke($"feedback: dropped oversize {datagram.Length}-byte datagram");
continue;
}
foreach (string line in lines.Feed(datagram, datagram.Length))
Handle(line);
if (lines.Flush() is string tail) // datagram end terminates the last line
Handle(tail);
}
}
private void Handle(string line)
{
try
{
_onLine(line);
}
catch (Exception ex)
{
_log?.Invoke($"feedback: line handler failed: {ex.Message}");
}
}
}
@@ -0,0 +1,66 @@
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Maps XInput vibration onto cockpit lamp flash: each motor's intensity
/// becomes off / slow / med / fast (bright) on that motor's configured lamp
/// addresses. Subscribed to <c>ViGEmJoystickSink.RumbleChanged</c>, which fires
/// on a ViGEm-owned thread at XInput rates — <see cref="OnRumble"/> therefore
/// only computes a state byte and posts to the shared
/// <see cref="CoalescingLampScheduler"/> when it changed. The board sustains
/// the blink from the state byte, so a game holding constant rumble costs one
/// lamp command, and XInput's stream of identical values costs nothing.
/// (TFM-neutral; only the ViGEm hookup is net48-only.)
/// </summary>
public sealed class RumbleLampAdapter
{
private readonly RumbleLampConfig _config;
private readonly CoalescingLampScheduler _lamps;
private readonly object _gate = new();
private int _lastLarge = -1; // last posted state byte; -1 = none yet
private int _lastSmall = -1;
public RumbleLampAdapter(RumbleLampConfig config, CoalescingLampScheduler lamps)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_lamps = lamps ?? throw new ArgumentNullException(nameof(lamps));
}
/// <summary>Vibration update from the pad. Thread-safe, non-blocking.</summary>
public void OnRumble(byte large, byte small)
{
lock (_gate)
{
Apply(large, _config.LargeMotorLamps, ref _lastLarge);
Apply(small, _config.SmallMotorLamps, ref _lastSmall);
}
}
private void Apply(byte value, List<int> addresses, ref int lastState)
{
byte state = MapMotor(value, _config.Threshold);
if (state == lastState)
return;
lastState = state;
foreach (int address in addresses)
_lamps.Post(address, state); // invalid config addresses drop in Post
}
/// <summary>
/// Motor byte → lamp state: below <paramref name="threshold"/> is off; the
/// remaining range splits into thirds of slow / med / fast flash, bright.
/// </summary>
public static byte MapMotor(byte value, byte threshold)
{
if (value < threshold)
return RioLampState.SolidOff;
int span = 256 - threshold;
int offset = value - threshold;
LampFlash flash = offset < span / 3 ? LampFlash.FlashSlow
: offset < span * 2 / 3 ? LampFlash.FlashMed
: LampFlash.FlashFast;
return RioLampState.Compose(flash, LampField1.Bright, LampField2.Bright);
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Globalization;
using RioJoy.Core.Profiles;
namespace RioJoy.Core.Hosting;
/// <summary>
/// The process a pod-bundled RIOJoy lives alongside (<c>--exit-with</c>):
/// either a PID or an executable name, normalized the same way auto-switch
/// triggers are (basename, no <c>.exe</c>, lower-case) so launch scripts can
/// pass whatever they have.
/// </summary>
public sealed record CompanionTarget
{
public int? Pid { get; init; }
/// <summary>Normalized executable name (when <see cref="Pid"/> is null).</summary>
public string? Name { get; init; }
public static CompanionTarget Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Companion target is required.", nameof(value));
return int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int pid)
? new CompanionTarget { Pid = pid }
: new CompanionTarget { Name = AutoSwitchResolver.Normalize(value) };
}
public override string ToString() => Pid is int p ? $"pid {p}" : Name ?? "?";
}
/// <summary>
/// Pure decision core of <c>--exit-with</c>: RIOJoy should exit once its
/// companion game has run and then gone away. Launch order is not guaranteed
/// (the pod start script fires both), so a companion that has <i>never</i>
/// been seen only triggers exit after a startup grace — covering both "game
/// still loading" and "game failed to launch, don't linger forever". The
/// caller polls (the tray's 1 s timer) and supplies elapsed time, so this
/// stays clock-free and unit-testable.
/// </summary>
public sealed class CompanionExit
{
public static readonly TimeSpan DefaultStartupGrace = TimeSpan.FromSeconds(60);
private readonly TimeSpan _grace;
private bool _seen;
public CompanionExit(TimeSpan? startupGrace = null)
{
_grace = startupGrace ?? DefaultStartupGrace;
}
/// <summary>True once RIOJoy should tear down and exit.</summary>
public bool ShouldExit(bool companionRunning, TimeSpan elapsed)
{
if (companionRunning)
{
_seen = true;
return false;
}
return _seen || elapsed >= _grace;
}
}
+9
View File
@@ -23,6 +23,15 @@ public static class RioAddress
/// <summary>Size of the <c>iRIO</c> table (addresses 0x00..0x6F inclusive).</summary> /// <summary>Size of the <c>iRIO</c> table (addresses 0x00..0x6F inclusive).</summary>
public const int TableSize = MaxAddress + 1; // 112 public const int TableSize = MaxAddress + 1; // 112
/// <summary>
/// True when <paramref name="address"/> is a real input/lamp address: the 72
/// buttons or one of the two keypads. The 0x480x4F gap is unused.
/// </summary>
public static bool IsValid(int address) =>
(address >= 0 && address < ButtonCount) ||
(address >= Keypad0Base && address <= Keypad0Base + 0x0F) ||
(address >= Keypad1Base && address <= MaxAddress);
/// <summary>Address for a digital button event (<paramref name="index"/> 0x000x47).</summary> /// <summary>Address for a digital button event (<paramref name="index"/> 0x000x47).</summary>
public static int FromButton(byte index) public static int FromButton(byte index)
{ {
@@ -46,6 +46,18 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
_pad = pad; _pad = pad;
} }
/// <summary>
/// XInput vibration set by the game, as (large, small) motor bytes — the
/// feedback channel that works with unmodified games (rumble → cockpit lamp
/// flash, Phase 9). Raised on a ViGEm-owned thread at XInput rates:
/// handlers must not block (compute + post to a rate-limited scheduler
/// only). Plain byte delegate so consumers stay free of ViGEm types.
/// </summary>
public event Action<byte, byte>? RumbleChanged;
private void OnFeedback(object sender, Xbox360FeedbackReceivedEventArgs e) =>
RumbleChanged?.Invoke(e.LargeMotor, e.SmallMotor);
/// <summary> /// <summary>
/// Apply a per-profile axis routing (<see langword="null"/> = the default /// Apply a per-profile axis routing (<see langword="null"/> = the default
/// legacy routing) and neutralize the pad's axis state — all four thumb axes /// legacy routing) and neutralize the pad's axis state — all four thumb axes
@@ -83,6 +95,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
pad.AutoSubmitReport = false; // submit once per logical update pad.AutoSubmitReport = false; // submit once per logical update
pad.Connect(); pad.Connect();
sink = new ViGEmJoystickSink(client, pad); sink = new ViGEmJoystickSink(client, pad);
pad.FeedbackReceived += sink.OnFeedback; // game rumble → RumbleChanged
return true; return true;
} }
catch catch
@@ -140,6 +153,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
public void Dispose() public void Dispose()
{ {
_pad.FeedbackReceived -= OnFeedback;
try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ } try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ }
_client.Dispose(); _client.Dispose();
} }
+56 -5
View File
@@ -37,6 +37,15 @@ public static class PlasmaCommands
/// <summary>Select font (<c>ESC K font</c>).</summary> /// <summary>Select font (<c>ESC K font</c>).</summary>
public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font }; public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font };
/// <summary>Panel width in pixels (columns 0127).</summary>
public const int Columns = 128;
/// <summary>Panel height in pixel rows (031).</summary>
public const int Rows = 32;
/// <summary>Bytes per full bitmap row (128 px / 8, byte columns 015).</summary>
public const int RowBytes = Columns / 8;
/// <summary>Draw a box outline (<c>ESC X l t r b</c>).</summary> /// <summary>Draw a box outline (<c>ESC X l t r b</c>).</summary>
public static byte[] BoxDraw(byte left, byte top, byte right, byte bottom) => public static byte[] BoxDraw(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'X', left, top, right, bottom }; new[] { Esc, (byte)'X', left, top, right, bottom };
@@ -45,6 +54,45 @@ public static class PlasmaCommands
public static byte[] BoxFill(byte left, byte top, byte right, byte bottom) => public static byte[] BoxFill(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'x', (byte)0, left, top, right, bottom }; new[] { Esc, (byte)'x', (byte)0, left, top, right, bottom };
/// <summary>
/// Bitmap graphics write (<c>ESC P s y x w h data…</c>): 1-bpp pixels,
/// MSB = leftmost, starting at row <paramref name="y"/> (031) and byte
/// column <paramref name="x"/> (015), <paramref name="bytesPerRow"/> bytes
/// across <paramref name="rows"/> rows. Command set recovered in vRIO's
/// <c>PlasmaProtocol.cs</c> (Tesla 4.10 sources + firmware dump); the
/// native game streams whole changed rows (<c>x=0, w=16, h=1</c> —
/// <see cref="GraphicsRow"/>).
/// </summary>
public static byte[] GraphicsWrite(byte y, byte x, byte bytesPerRow, byte rows, byte[] data)
{
if (data is null) throw new ArgumentNullException(nameof(data));
if (y >= Rows)
throw new ArgumentOutOfRangeException(nameof(y), $"Row must be 0..{Rows - 1}.");
if (x >= RowBytes)
throw new ArgumentOutOfRangeException(nameof(x), $"Byte column must be 0..{RowBytes - 1}.");
if (bytesPerRow == 0 || x + bytesPerRow > RowBytes)
throw new ArgumentOutOfRangeException(nameof(bytesPerRow), "Row span exceeds the panel width.");
if (rows == 0 || y + rows > Rows)
throw new ArgumentOutOfRangeException(nameof(rows), "Row span exceeds the panel height.");
if (data.Length != bytesPerRow * rows)
throw new ArgumentException($"Expected {bytesPerRow * rows} data bytes, got {data.Length}.", nameof(data));
var command = new byte[7 + data.Length];
command[0] = Esc;
command[1] = (byte)'P';
command[2] = 0; // screen — single-screen hardware
command[3] = y;
command[4] = x;
command[5] = bytesPerRow;
command[6] = rows;
Array.Copy(data, 0, command, 7, data.Length);
return command;
}
/// <summary>One full 128-px bitmap row at <paramref name="y"/> (16 bytes, MSB leftmost).</summary>
public static byte[] GraphicsRow(byte y, byte[] row) =>
GraphicsWrite(y, 0, (byte)RowBytes, 1, row);
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary> /// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
public static byte[] Text(string text) public static byte[] Text(string text)
{ {
@@ -65,10 +113,13 @@ public static class PlasmaCommands
/// <summary> /// <summary>
/// Compute the auto-fit font and centered (x, y) for positioned text, porting /// Compute the auto-fit font and centered (x, y) for positioned text, porting
/// the <c>PlasmaPosText</c> layout logic (riovjoy2.cpp#L2235). For non-score /// the <c>PlasmaPosText</c> layout logic (riovjoy2.cpp#L2235). Font 0 = auto:
/// text (<paramref name="font"/> ≠ 2), the font is chosen from the length /// chosen from the length (≤9 → font 5, else font 2, capping length at 20).
/// (≤9 → font 5, else font 2, capping length at 20). When the caller passes /// A nonzero <paramref name="font"/> is honored as given the legacy code
/// (0, 0), the text is centered around cell (56, 15) for the chosen font. /// special-cased only its Score font (2); generalizing lets short text
/// render small, e.g. digits inside a score box, which auto-fit never
/// would. When the caller passes (0, 0), the text is centered around cell
/// (56, 15) for the chosen font.
/// </summary> /// </summary>
public static (byte x, byte y, byte font, int length) ResolvePosText( public static (byte x, byte y, byte font, int length) ResolvePosText(
string text, byte x, byte y, byte font) string text, byte x, byte y, byte font)
@@ -76,7 +127,7 @@ public static class PlasmaCommands
if (text is null) throw new ArgumentNullException(nameof(text)); if (text is null) throw new ArgumentNullException(nameof(text));
int len = text.Length; int len = text.Length;
if (font != 2) // not the Score font if (font == 0) // auto-fit by length
{ {
if (len <= 9) font = 5; if (len <= 9) font = 5;
else { font = 2; if (len > 20) len = 20; } else { font = 2; if (len > 20) len = 20; }
+88 -13
View File
@@ -1,3 +1,4 @@
using RioJoy.Core.Compat;
using RioJoy.Core.Serial; using RioJoy.Core.Serial;
namespace RioJoy.Core.Plasma; namespace RioJoy.Core.Plasma;
@@ -6,11 +7,15 @@ namespace RioJoy.Core.Plasma;
/// Drives the plasma / VFD text display over its (secondary) serial transport, /// Drives the plasma / VFD text display over its (secondary) serial transport,
/// writing the ESC sequences built by <see cref="PlasmaCommands"/>. Thin async /// writing the ESC sequences built by <see cref="PlasmaCommands"/>. Thin async
/// wrapper around an <see cref="IRioTransport"/>; the display is write-only. The /// wrapper around an <see cref="IRioTransport"/>; the display is write-only. The
/// content shown is per-profile (Phase 5+). /// content shown is per-profile (Phase 5+). A write lock keeps each command's
/// ESC sequence contiguous on the wire — <see cref="PosTextAsync"/> is five
/// separate writes, and concurrent callers (greeting vs. feedback text) would
/// otherwise interleave fragments and corrupt the display.
/// </summary> /// </summary>
public sealed class PlasmaDisplay public sealed class PlasmaDisplay
{ {
private readonly IRioTransport _transport; private readonly IRioTransport _transport;
private readonly SemaphoreSlim _writeLock = new(1, 1);
public PlasmaDisplay(IRioTransport transport) public PlasmaDisplay(IRioTransport transport)
{ {
@@ -18,35 +23,105 @@ public sealed class PlasmaDisplay
} }
public Task ClearAsync(CancellationToken ct = default) => public Task ClearAsync(CancellationToken ct = default) =>
WriteAsync(PlasmaCommands.Clear(), ct); WriteLockedAsync(new[] { PlasmaCommands.Clear() }, ct);
/// <summary>
/// Set the cursor mode (<c>ESC G n</c>): 0 hidden, 1 steady, 3 flashing.
/// The native games send <c>ESC G 0</c> once at startup
/// (<c>L4PLASMA.CPP</c>) — without it the firmware's cursor artifact sits
/// wherever text last ended.
/// </summary>
public Task CursorAsync(byte mode, CancellationToken ct = default) =>
WriteLockedAsync(new[] { PlasmaCommands.Cursor(mode) }, ct);
public Task CursorHomeAsync(CancellationToken ct = default) => public Task CursorHomeAsync(CancellationToken ct = default) =>
WriteAsync(PlasmaCommands.CursorHome(), ct); WriteLockedAsync(new[] { PlasmaCommands.CursorHome() }, ct);
public Task TextAsync(string text, CancellationToken ct = default) => public Task TextAsync(string text, CancellationToken ct = default) =>
WriteAsync(PlasmaCommands.Text(text), ct); WriteLockedAsync(new[] { PlasmaCommands.Text(text) }, ct);
/// <summary>
/// Write one full 128-px bitmap row at <paramref name="y"/> (031):
/// 16 bytes, MSB = leftmost pixel (<see cref="PlasmaCommands.GraphicsRow"/>).
/// </summary>
public Task RowAsync(byte y, byte[] row, CancellationToken ct = default) =>
WriteLockedAsync(new[] { PlasmaCommands.GraphicsRow(y, row) }, ct);
/// <summary>
/// Draw an outlined box with a blanked interior — the overlay chrome the
/// original games drew for their rank|score field over the callsign
/// (L4GAUGE.cpp's outlined 63×12 box). Pixel coordinates; a single
/// graphics write (<c>ESC P</c>).
///
/// <para>The wire's graphics command addresses whole bytes horizontally,
/// so the write covers the byte-aligned span containing
/// <paramref name="x"/>..<paramref name="x"/>+<paramref name="w"/>-1;
/// pixels inside that span but outside the box are cleared. Callers who
/// care should place boxes on 8-px boundaries.</para>
/// </summary>
public Task BoxAsync(byte x, byte y, byte w, byte h, CancellationToken ct = default)
{
if (w == 0 || h == 0 || x + w > PlasmaCommands.Columns || y + h > PlasmaCommands.Rows)
throw new ArgumentOutOfRangeException(nameof(w), "Box exceeds the 128x32 panel.");
int right = x + w - 1;
int firstByte = x / 8;
int lastByte = right / 8;
int spanBytes = lastByte - firstByte + 1;
var data = new byte[spanBytes * h];
for (int r = 0; r < h; r++)
{
bool edgeRow = r == 0 || r == h - 1;
for (int px = firstByte * 8; px <= lastByte * 8 + 7; px++)
{
if (px < x || px > right)
continue; // inside the byte span, outside the box: stays 0
bool lit = edgeRow || px == x || px == right;
if (lit)
data[r * spanBytes + (px / 8 - firstByte)] |= (byte)(0x80 >> (px % 8));
}
}
return WriteLockedAsync(
new[] { PlasmaCommands.GraphicsWrite(y, (byte)firstByte, (byte)spanBytes, h, data) }, ct);
}
/// <summary> /// <summary>
/// Position the cursor, set attribute + font, and write text — the /// Position the cursor, set attribute + font, and write text — the
/// <c>PlasmaPosText</c> sequence (auto-fit via /// <c>PlasmaPosText</c> sequence (auto-fit via
/// <see cref="PlasmaCommands.ResolvePosText"/>). Pass (0,0) to auto-center. /// <see cref="PlasmaCommands.ResolvePosText"/>). Pass (0,0) to auto-center.
/// </summary> /// </summary>
public async Task PosTextAsync( public Task PosTextAsync(
string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0, string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (string.IsNullOrEmpty(text)) if (string.IsNullOrEmpty(text))
return; return TaskCompat.CompletedTask;
(byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font); (byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font);
await WriteAsync(PlasmaCommands.CursorX(rx), ct).ConfigureAwait(false); return WriteLockedAsync(new[]
await WriteAsync(PlasmaCommands.CursorY(ry), ct).ConfigureAwait(false); {
await WriteAsync(PlasmaCommands.FontAttr(attr), ct).ConfigureAwait(false); PlasmaCommands.CursorX(rx),
await WriteAsync(PlasmaCommands.Font(rfont), ct).ConfigureAwait(false); PlasmaCommands.CursorY(ry),
await WriteAsync(PlasmaCommands.Text(text[..len]), ct).ConfigureAwait(false); PlasmaCommands.FontAttr(attr),
PlasmaCommands.Font(rfont),
PlasmaCommands.Text(text[..len]),
}, ct);
} }
private Task WriteAsync(byte[] data, CancellationToken ct) => private async Task WriteLockedAsync(byte[][] chunks, CancellationToken ct)
_transport.WriteAsync(data, ct); {
await TaskCompat.WaitAsync(_writeLock, ct).ConfigureAwait(false);
try
{
foreach (byte[] chunk in chunks)
await _transport.WriteAsync(chunk, ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
} }
+7
View File
@@ -55,6 +55,13 @@ public sealed class AppConfig
/// </summary> /// </summary>
public string? OverlayTemplatePath { get; set; } public string? OverlayTemplatePath { get; set; }
/// <summary>
/// Inbound game-feedback endpoint settings (Phase 9); null = the
/// <see cref="Feedback.FeedbackEndpointConfig"/> defaults (named pipe on,
/// UDP off).
/// </summary>
public Feedback.FeedbackEndpointConfig? Feedback { get; set; }
/// <summary>Find a profile by name (case-insensitive), or null.</summary> /// <summary>Find a profile by name (case-insensitive), or null.</summary>
public RioProfile? FindProfile(string? name) => public RioProfile? FindProfile(string? name) =>
name is null name is null
+29
View File
@@ -0,0 +1,29 @@
namespace RioJoy.Core.Profiles;
/// <summary>
/// Resolves which config file the app uses: a <b>portable</b>
/// <c>config.json</c> sitting beside the executable wins over the per-user
/// roaming store. Portable mode is how a pod-bundled RIOJoy (one copy shipped
/// inside each podized game's folder, PLAN.md §Phase 10) carries its own
/// profile with no shared state and no import step; the roaming store remains
/// the resident/dev-box default.
/// </summary>
public static class ConfigLocator
{
/// <summary>The portable config's file name, looked for beside the exe.</summary>
public const string PortableConfigFileName = "config.json";
/// <summary>
/// The portable config path for <paramref name="exeDirectory"/> if one
/// exists there, else <paramref name="roamingConfigPath"/>.
/// </summary>
public static string Resolve(string? exeDirectory, string roamingConfigPath)
{
if (roamingConfigPath is null) throw new ArgumentNullException(nameof(roamingConfigPath));
if (string.IsNullOrWhiteSpace(exeDirectory))
return roamingConfigPath;
string portable = Path.Combine(exeDirectory, PortableConfigFileName);
return File.Exists(portable) ? portable : roamingConfigPath;
}
}
+6
View File
@@ -41,6 +41,12 @@ public sealed class RioProfile
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary> /// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
public string? PlasmaGreeting { get; set; } public string? PlasmaGreeting { get; set; }
/// <summary>
/// How inbound game feedback (lamp/plasma commands, rumble) is applied while
/// this profile is active; null = feedback is not applied (commands dropped).
/// </summary>
public Feedback.ProfileFeedbackConfig? Feedback { get; set; }
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary> /// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
public string? WallpaperPath { get; set; } public string? WallpaperPath { get; set; }
+8
View File
@@ -46,6 +46,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// </summary> /// </summary>
public bool EchoAllLamps { get; set; } public bool EchoAllLamps { get; set; }
/// <summary>
/// This runtime's serial lamp sink — the target the inbound feedback
/// endpoint's scheduler drives (Phase 9). Feedback paths must rate-limit
/// through a <see cref="Feedback.CoalescingLampScheduler"/>, never call
/// <see cref="ILampSink.SetLamp"/> directly (see its remarks).
/// </summary>
public ILampSink Lamps => _lamp;
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary> /// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
public event Action<RioCommandCode>? DiagnosticToggle; public event Action<RioCommandCode>? DiagnosticToggle;
+61 -22
View File
@@ -5,6 +5,7 @@ using RioJoy.Core.Output;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
using RioJoy.Core.Protocol; using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
namespace RioJoy.Tray.Editor; namespace RioJoy.Tray.Editor;
@@ -30,26 +31,32 @@ public sealed class ProfileEditorForm : Form
private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 }; private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 };
private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 }; private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 };
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 }; // RIO endpoint: editable so any COM name or pipe:name goes; the drop-down
private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; // offers the app default, the machine's COM ports, and the vRIO pipe.
private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 171), AutoSize = true }; private readonly ComboBox _portBox = new() { Location = new Point(66, 68), Width = 234, DropDownStyle = ComboBoxStyle.DropDown };
private readonly ComboBox _valueCombo = new() { Location = new Point(70, 168), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly string _defaultPortItem;
private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 200), AutoSize = true };
private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 200), AutoSize = true }; private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 96), MaximumSize = new Size(310, 0) };
private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 200), AutoSize = true }; private readonly TextBox _labelBox = new() { Location = new Point(70, 128), Width = 230 };
private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 200), AutoSize = true }; private readonly ComboBox _kindBox = new() { Location = new Point(70, 162), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 228), AutoSize = true }; private readonly Label _valueLabel = new() { Text = "Key:", Location = new Point(12, 199), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 262), Width = 110 }; private readonly ComboBox _valueCombo = new() { Location = new Point(70, 196), Width = 230, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 262), Width = 110 }; private readonly CheckBox _shift = new() { Text = "Shift", Location = new Point(70, 228), AutoSize = true };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 296), Width = 110 }; private readonly CheckBox _ctrl = new() { Text = "Ctrl", Location = new Point(140, 228), AutoSize = true };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 296), Width = 80 }; private readonly CheckBox _alt = new() { Text = "Alt", Location = new Point(200, 228), AutoSize = true };
private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 328), AutoSize = true }; private readonly CheckBox _ext = new() { Text = "Ext", Location = new Point(250, 228), AutoSize = true };
private readonly CheckBox _lit = new() { Text = "Lit", Location = new Point(70, 256), AutoSize = true };
private readonly Button _apply = new() { Text = "Apply to cell", Location = new Point(70, 290), Width = 110 };
private readonly Button _unassign = new() { Text = "Unassign", Location = new Point(190, 290), Width = 110 };
private readonly Button _save = new() { Text = "Save profile", Location = new Point(70, 324), Width = 110 };
private readonly Button _close = new() { Text = "Close", Location = new Point(190, 324), Width = 80 };
private readonly CheckBox _outputToggle = new() { Text = "Send button output to the PC", Location = new Point(12, 356), AutoSize = true };
private readonly TextBox _statusBox = new() private readonly TextBox _statusBox = new()
{ {
Location = new Point(12, 646), Location = new Point(12, 674),
Size = new Size(306, 145), Size = new Size(306, 140),
Multiline = true, Multiline = true,
ReadOnly = true, ReadOnly = true,
ScrollBars = ScrollBars.Vertical, ScrollBars = ScrollBars.Vertical,
@@ -104,13 +111,38 @@ public sealed class ProfileEditorForm : Form
/// <summary>Raised when the "send output to the PC" toggle changes (true = send).</summary> /// <summary>Raised when the "send output to the PC" toggle changes (true = send).</summary>
public event Action<bool>? OutputsEnabledChanged; public event Action<bool>? OutputsEnabledChanged;
public ProfileEditorForm(RioProfile profile) /// <param name="profile">The profile to edit (mutated in place; Save persists).</param>
/// <param name="defaultEndpoint">
/// The app-wide RIO endpoint (<see cref="AppConfig.DefaultRioComPort"/>), shown
/// on the port picker's "(app default)" entry. Null just hides the value.
/// </param>
public ProfileEditorForm(RioProfile profile, string? defaultEndpoint = null)
{ {
_profile = profile ?? throw new ArgumentNullException(nameof(profile)); _profile = profile ?? throw new ArgumentNullException(nameof(profile));
Text = $"RIOJoy — Edit profile: {profile.Name}"; Text = $"RIOJoy — Edit profile: {profile.Name}";
_nameBox.Text = profile.Name; _nameBox.Text = profile.Name;
_matchBox.Text = string.Join(", ", profile.MatchExecutables); _matchBox.Text = string.Join(", ", profile.MatchExecutables);
// Endpoint suggestions: app default, the machine's COM ports, the vRIO
// pipe. Free text stays allowed — any COM name or pipe:name works.
_defaultPortItem = defaultEndpoint is null ? "(app default)" : $"(app default: {defaultEndpoint})";
_portBox.Items.Add(_defaultPortItem);
try
{
foreach (string port in System.IO.Ports.SerialPort.GetPortNames()
.Distinct().OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
_portBox.Items.Add(port);
}
catch (Exception)
{
// Enumerating ports is best-effort (registry read) — typing still works.
}
_portBox.Items.Add(RioTransportFactory.PipeScheme + "vrio");
if (string.IsNullOrWhiteSpace(profile.RioComPort))
_portBox.SelectedIndex = 0;
else
_portBox.Text = profile.RioComPort;
ClientSize = new Size(1320, 820); ClientSize = new Size(1320, 820);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500); MinimumSize = new Size(900, 500);
@@ -162,16 +194,18 @@ public sealed class ProfileEditorForm : Form
panel.Controls.Add(_nameBox); panel.Controls.Add(_nameBox);
panel.Controls.Add(new Label { Text = "Triggers:", Location = new Point(12, 43), AutoSize = true }); panel.Controls.Add(new Label { Text = "Triggers:", Location = new Point(12, 43), AutoSize = true });
panel.Controls.Add(_matchBox); panel.Controls.Add(_matchBox);
panel.Controls.Add(new Label { Text = "RIO port:", Location = new Point(12, 71), AutoSize = true });
panel.Controls.Add(_portBox);
panel.Controls.Add(_info); panel.Controls.Add(_info);
panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 103), AutoSize = true }); panel.Controls.Add(new Label { Text = "Label:", Location = new Point(12, 131), AutoSize = true });
panel.Controls.Add(_labelBox); panel.Controls.Add(_labelBox);
panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 137), AutoSize = true }); panel.Controls.Add(new Label { Text = "Action:", Location = new Point(12, 165), AutoSize = true });
panel.Controls.Add(_kindBox); panel.Controls.Add(_kindBox);
panel.Controls.Add(_valueLabel); panel.Controls.Add(_valueLabel);
panel.Controls.Add(_valueCombo); panel.Controls.Add(_valueCombo);
panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close, _outputToggle }); panel.Controls.AddRange(new Control[] { _shift, _ctrl, _alt, _ext, _lit, _apply, _unassign, _save, _close, _outputToggle });
panel.Controls.Add(BuildCommandGroup()); panel.Controls.Add(BuildCommandGroup());
panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 628), AutoSize = true }); panel.Controls.Add(new Label { Text = "RIO reply:", Location = new Point(12, 656), AutoSize = true });
panel.Controls.Add(_statusBox); panel.Controls.Add(_statusBox);
return panel; return panel;
@@ -180,7 +214,7 @@ public sealed class ProfileEditorForm : Form
// A button per RIO device command, fired against the live RIO via CommandRequested. // A button per RIO device command, fired against the live RIO via CommandRequested.
private GroupBox BuildCommandGroup() private GroupBox BuildCommandGroup()
{ {
var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 358), Size = new Size(306, 262) }; var group = new GroupBox { Text = "RIO commands (live)", Location = new Point(12, 386), Size = new Size(306, 262) };
int y = 24; int y = 24;
foreach ((string label, RioCommandCode code) in RioCommands) foreach ((string label, RioCommandCode code) in RioCommands)
@@ -359,6 +393,11 @@ public sealed class ProfileEditorForm : Form
.Where(s => s.Length > 0) .Where(s => s.Length > 0)
.ToList(); .ToList();
// RIO endpoint: a COM name or pipe:name (e.g. pipe:vrio); blank or the
// "(app default)" entry stores null = follow DefaultRioComPort.
string port = _portBox.Text.Trim();
_profile.RioComPort = port.Length == 0 || port == _defaultPortItem ? null : port;
ApplyToCell(); ApplyToCell();
try try
{ {
+87 -2
View File
@@ -1,3 +1,4 @@
using RioJoy.Core.Hosting;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
namespace RioJoy.Tray; namespace RioJoy.Tray;
@@ -10,6 +11,11 @@ internal static class Program
// so a crash never leaves a stale lock. // so a crash never leaves a stale lock.
private const string SingleInstanceMutex = "RIOJoy.Tray.SingleInstance"; private const string SingleInstanceMutex = "RIOJoy.Tray.SingleInstance";
// Pod handoff (game A's copy tearing down while game B's starts): how long a
// --exit-with launch waits for the predecessor to release the mutex before
// giving up. Plain launches keep the historical instant silent exit.
private static readonly TimeSpan PredecessorWait = TimeSpan.FromSeconds(15);
/// <summary> /// <summary>
/// Entry point. RIOJoy runs as a background tray application with no main /// Entry point. RIOJoy runs as a background tray application with no main
/// window: an ApplicationContext owns the NotifyIcon and the runtime, so the /// window: an ApplicationContext owns the NotifyIcon and the runtime, so the
@@ -20,6 +26,24 @@ internal static class Program
/// exits without starting the tray. Output goes to stdout/stderr, which a /// exits without starting the tray. Output goes to stdout/stderr, which a
/// GUI-subsystem exe only delivers when redirected — check the exit code /// GUI-subsystem exe only delivers when redirected — check the exit code
/// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it.</para> /// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it.</para>
///
/// <para><c>--exit-with &lt;exe|pid&gt;</c> runs as a pod-bundled companion
/// (PLAN.md §Phase 10): RIOJoy exits by itself — full teardown, ports
/// released, wallpaper restored — once the named game process has run and
/// then gone away (or never appeared within the startup grace). Also makes
/// startup wait briefly for a predecessor instance instead of exiting, so
/// back-to-back game launches hand the cockpit over cleanly.</para>
///
/// <para><c>--profile &lt;name&gt;</c> activates that profile immediately
/// and explicitly — <b>no foreground detection at all</b>. Pod launch
/// scripts pass it so the ViGEm pad and the ports exist <i>before</i> the
/// game starts and enumerates controllers; the auto-switch watcher never
/// runs. On success the named event
/// <see cref="TrayApplicationContext.ReadyEventName"/> is signaled — a pod
/// launcher waits on it (with a timeout) instead of counting input
/// devices. Exit code 4 when the named profile is not in the config; 5
/// when explicit activation fails (reason on stderr) — distinguishable
/// from a hang, so launcher failures stay legible.</para>
/// </summary> /// </summary>
[STAThread] [STAThread]
private static int Main(string[] args) private static int Main(string[] args)
@@ -27,18 +51,79 @@ internal static class Program
if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase)) if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase))
return ImportProfile(args); return ImportProfile(args);
CompanionTarget? exitWith;
string? profileName;
try
{
exitWith = ParseValue(args, "--exit-with", "a process name or pid") is string target
? CompanionTarget.Parse(target)
: null;
profileName = ParseValue(args, "--profile", "a profile name");
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"usage: RioJoy.Tray [--profile <name>] [--exit-with <exe|pid>] ({ex.Message})");
return 2;
}
// Validate the explicit profile up front so a pod script's typo is a
// scriptable failure, not a silently idle tray.
if (profileName is not null &&
ConfigStore.Load(TrayApplicationContext.ConfigPath).FindProfile(profileName) is null)
{
Console.Error.WriteLine(
$"profile '{profileName}' not found in {TrayApplicationContext.ConfigPath}");
return 4;
}
using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew); using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew);
if (!createdNew) bool podMode = exitWith is not null || profileName is not null;
if (!createdNew && !WaitForPredecessor(instance, wait: podMode))
return 0; // another RIOJoy is already running in this session return 0; // another RIOJoy is already running in this session
// net48 has no source-generated ApplicationConfiguration.Initialize(); // net48 has no source-generated ApplicationConfiguration.Initialize();
// do the equivalent setup directly. // do the equivalent setup directly.
Application.EnableVisualStyles(); Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext()); var context = new TrayApplicationContext(exitWith, profileName);
if (!context.TryActivateExplicitProfile())
{
context.Dispose(); // failure reason already on stderr
return 5;
}
Application.Run(context);
return 0; return 0;
} }
private static string? ParseValue(string[] args, string flag, string what)
{
for (int i = 0; i < args.Length; i++)
{
if (!string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
continue;
if (i + 1 >= args.Length)
throw new ArgumentException($"{flag} needs {what}");
return args[i + 1];
}
return null;
}
// The predecessor's mutex is released by the OS when its process exits; an
// abandoned wait means it crashed while owning it — either way we own it now.
private static bool WaitForPredecessor(Mutex instance, bool wait)
{
if (!wait)
return false;
try
{
return instance.WaitOne(PredecessorWait);
}
catch (AbandonedMutexException)
{
return true;
}
}
private static int ImportProfile(string[] args) private static int ImportProfile(string[] args)
{ {
if (args.Length != 2) if (args.Length != 2)
+146 -1
View File
@@ -1,8 +1,10 @@
using RioJoy.Core; using RioJoy.Core;
using RioJoy.Core.Calibration; using RioJoy.Core.Calibration;
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Output; using RioJoy.Core.Output;
using RioJoy.Core.Overlay; using RioJoy.Core.Overlay;
using RioJoy.Core.Plasma;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
using RioJoy.Core.Serial; using RioJoy.Core.Serial;
#if !NET40 #if !NET40
@@ -44,6 +46,21 @@ public sealed class RioCoordinator : IDisposable
private IDisposable? _joystick; private IDisposable? _joystick;
private string? _activeProfileName; private string? _activeProfileName;
// The plasma display's own transport (null when the profile runs without one).
// Released on every teardown — the native games open this port too.
private IRioTransport? _plasmaTransport;
private PlasmaDisplay? _plasma;
// The inbound feedback endpoint (Phase 9). Created lazily on first
// activation and kept for the app's lifetime, so external clients hold
// their pipe/UDP connection across profile switches and dormancy — only
// Attach/Detach swings where (whether) commands land.
private FeedbackService? _feedback;
private CoalescingLampScheduler? _feedbackScheduler; // per-activation (rumble shares it)
#if !NET40
private Action? _rumbleUnhook; // detaches the rumble adapter from the pad
#endif
// The user's own desktop wallpaper, captured the first time we override it with // The user's own desktop wallpaper, captured the first time we override it with
// a cockpit wallpaper. null = we are not currently overriding (nothing to // a cockpit wallpaper. null = we are not currently overriding (nothing to
// restore). "" is a valid captured value (the user had no wallpaper). // restore). "" is a valid captured value (the user had no wallpaper).
@@ -59,6 +76,12 @@ public sealed class RioCoordinator : IDisposable
/// <summary>Raised (with a short status string) whenever the active state changes.</summary> /// <summary>Raised (with a short status string) whenever the active state changes.</summary>
public event Action<string>? StatusChanged; public event Action<string>? StatusChanged;
/// <summary>
/// Feedback-endpoint diagnostics (malformed lines, dropped profile-owned
/// lamp writes, listener lifecycle). Also mirrored to the debugger output.
/// </summary>
public event Action<string>? FeedbackLog;
/// <summary>Current status line for the tray.</summary> /// <summary>Current status line for the tray.</summary>
public string Status { get; private set; } = "Dormant"; public string Status { get; private set; } = "Dormant";
@@ -229,9 +252,10 @@ public sealed class RioCoordinator : IDisposable
AnalogPollInterval = TimeSpan.FromMilliseconds( AnalogPollInterval = TimeSpan.FromMilliseconds(
Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config
}); });
RioInputMap map = profile.ToInputMap(); // shared: runtime routing + feedback precedence
_runtime = new RioRuntime( _runtime = new RioRuntime(
_link, _link,
profile.ToInputMap(), map,
input, input,
joystick, joystick,
new AxisCalibrator(profile.Calibration)); new AxisCalibrator(profile.Calibration));
@@ -241,6 +265,28 @@ public sealed class RioCoordinator : IDisposable
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
_ = _link.RunAsync(_cts.Token); _ = _link.RunAsync(_cts.Token);
_runtime.Start(); _runtime.Start();
if (routeInput)
{
// Plasma + inbound feedback are live-profile concerns; editor
// sessions run without them (commands drop at the endpoint).
note += OpenPlasma(profile, config);
AttachFeedback(profile, map, config);
#if !NET40 // rumble arrives via ViGEm, so the XP flavor has no source for it
if (realJoystick is ViGEmJoystickSink rumblePad &&
profile.Feedback?.Rumble is RumbleLampConfig rumbleConfig &&
_feedbackScheduler is not null)
{
// Game rumble → lamp flash, through the same rate governor
// as the pipe/UDP lamp commands.
var adapter = new RumbleLampAdapter(rumbleConfig, _feedbackScheduler);
rumblePad.RumbleChanged += adapter.OnRumble;
_rumbleUnhook = () => rumblePad.RumbleChanged -= adapter.OnRumble;
}
#endif
}
_activeProfileName = profile.Name; _activeProfileName = profile.Name;
SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}"); SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}");
} }
@@ -255,6 +301,82 @@ public sealed class RioCoordinator : IDisposable
ApplyWallpaper(profile); ApplyWallpaper(profile);
} }
/// <summary>
/// Open the plasma display for <paramref name="profile"/> and show its
/// greeting (Phase 9 — closes Phase 4's dangling wiring). The port is the
/// profile's <see cref="RioProfile.PlasmaComPort"/>, falling back to
/// <see cref="AppConfig.DefaultPlasmaComPort"/>; null/empty/<c>"off"</c>
/// disables (the default is "COM2", so plasma-less machines want "off").
/// Best-effort: returns a status suffix on failure — a missing display must
/// never break activation.
/// </summary>
private string OpenPlasma(RioProfile profile, AppConfig config)
{
string? plasmaPort = profile.PlasmaComPort ?? config.DefaultPlasmaComPort;
if (string.IsNullOrWhiteSpace(plasmaPort) ||
string.Equals(plasmaPort!.Trim(), "off", StringComparison.OrdinalIgnoreCase))
return string.Empty;
try
{
_plasmaTransport = _transportFactory(plasmaPort);
_plasma = new PlasmaDisplay(_plasmaTransport);
FireAndForget(ShowGreetingAsync(_plasma, profile.PlasmaGreeting));
return string.Empty;
}
catch (Exception ex)
{
_plasmaTransport?.Dispose();
_plasmaTransport = null;
_plasma = null;
return $" [plasma: {ex.Message}]";
}
}
private static async Task ShowGreetingAsync(PlasmaDisplay plasma, string? greeting)
{
try
{
// Hide the cursor before anything draws, as the native games did
// once at startup (ESC G 0, L4PLASMA.CPP): the firmware otherwise
// leaves its cursor artifact wherever text last ended, which reads
// as a stray block parked in the nameplate.
await plasma.CursorAsync(0).ConfigureAwait(false);
await plasma.ClearAsync().ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(greeting))
await plasma.PosTextAsync(greeting!).ConfigureAwait(false); // (0,0) = auto-center
}
catch
{
// Best-effort: a wedged display port must not surface anywhere.
}
}
/// <summary>
/// Point the (app-lifetime) feedback endpoint at the new runtime's outputs.
/// A profile with no <see cref="RioProfile.Feedback"/> section leaves the
/// endpoint detached — clients stay connected, commands drop.
/// </summary>
private void AttachFeedback(RioProfile profile, RioInputMap map, AppConfig config)
{
if (_feedback is null)
{
_feedback = new FeedbackService(config.Feedback);
_feedback.Logged += message =>
{
System.Diagnostics.Debug.WriteLine(message);
FeedbackLog?.Invoke(message);
};
_feedback.Start();
}
if (profile.Feedback is ProfileFeedbackConfig fb && _runtime is not null)
_feedbackScheduler = _feedback.Attach(_runtime.Lamps, map, _plasma, fb);
}
private static void FireAndForget(Task task) =>
task.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
/// <summary> /// <summary>
/// Generate the profile's cockpit wallpaper from the configured overlay template /// Generate the profile's cockpit wallpaper from the configured overlay template
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/> /// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
@@ -345,6 +467,16 @@ public sealed class RioCoordinator : IDisposable
private void Teardown() private void Teardown()
{ {
// Feedback first: detach the router and stop the lamp-scheduler pump so
// nothing races the disposal below (the endpoint itself stays up —
// clients keep their connections; their commands now drop).
_feedback?.Detach();
_feedbackScheduler = null;
#if !NET40
_rumbleUnhook?.Invoke();
_rumbleUnhook = null;
#endif
_runtime?.Dispose(); _runtime?.Dispose();
_runtime = null; _runtime = null;
@@ -359,6 +491,17 @@ public sealed class RioCoordinator : IDisposable
_editorInput = null; _editorInput = null;
_editorJoystick = null; _editorJoystick = null;
if (_plasma is not null)
{
// Best-effort blank before releasing the display (2 bytes at 9600
// baud ≈ 2 ms; the bound only bites on a wedged port).
try { _plasma.ClearAsync().Wait(200); }
catch { /* best-effort */ }
_plasma = null;
}
_plasmaTransport?.Dispose(); // releases the plasma COM port (native games open it too)
_plasmaTransport = null;
_transport?.Dispose(); // releases the COM port _transport?.Dispose(); // releases the COM port
_transport = null; _transport = null;
@@ -374,6 +517,8 @@ public sealed class RioCoordinator : IDisposable
public void Dispose() public void Dispose()
{ {
Teardown(); Teardown();
_feedback?.Dispose(); // now the endpoint itself: drop clients, stop listening
_feedback = null;
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
} }
} }
+170 -29
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using RioJoy.Core; using RioJoy.Core;
using RioJoy.Core.Hosting;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Overlay; using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
@@ -11,34 +13,53 @@ namespace RioJoy.Tray;
/// Owns the tray icon, menu, and the RIOJoy runtime. The menu mirrors the legacy /// Owns the tray icon, menu, and the RIOJoy runtime. The menu mirrors the legacy
/// console menu (axis resets, version/status, diagnostic toggles, quit) and adds /// console menu (axis resets, version/status, diagnostic toggles, quit) and adds
/// profile selection (auto vs. manual). The app's start/stop lifecycle is owned by /// profile selection (auto vs. manual). The app's start/stop lifecycle is owned by
/// the TeslaConsole launcher, so there is no "start with Windows" toggle. The /// the TeslaConsole launcher (or, pod-bundled, by <c>--exit-with</c>), so there is
/// auto-switch watcher is polled on a UI timer so menu/status updates stay on the /// no "start with Windows" toggle. The auto-switch watcher is polled on a UI timer
/// UI thread. /// so menu/status updates stay on the UI thread.
/// </summary> /// </summary>
internal sealed class TrayApplicationContext : ApplicationContext internal sealed class TrayApplicationContext : ApplicationContext
{ {
// Internal so Program's --import-profile writes the same store the tray reads. // Internal so Program's --import-profile writes the same store the tray reads.
internal static readonly string ConfigPath = // A portable config.json beside the exe (pod-bundled deploys) wins over the
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json"); // per-user roaming store (resident/dev-box mode) — see ConfigLocator.
internal static readonly string ConfigPath = ConfigLocator.Resolve(
AppDomain.CurrentDomain.BaseDirectory,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json"));
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
private readonly AppConfig _config; private readonly AppConfig _config;
private readonly RioCoordinator _coordinator; private readonly RioCoordinator _coordinator;
private readonly AutoSwitchWatcher _watcher; private readonly AutoSwitchWatcher? _watcher; // null = explicit (--profile) mode
private readonly System.Windows.Forms.Timer _pollTimer; private readonly System.Windows.Forms.Timer _pollTimer;
private readonly NotifyIcon _trayIcon; private readonly NotifyIcon _trayIcon;
private readonly ToolStripMenuItem _statusItem; private readonly ToolStripMenuItem _statusItem;
public TrayApplicationContext() // --exit-with companion state (null = resident mode).
private readonly CompanionTarget? _exitWith;
private readonly CompanionExit _companionExit = new();
private readonly Stopwatch _sinceStart = Stopwatch.StartNew();
private readonly string? _explicitProfile;
public TrayApplicationContext(CompanionTarget? exitWith = null, string? explicitProfile = null)
{ {
_exitWith = exitWith;
_explicitProfile = explicitProfile;
_config = ConfigStore.Load(ConfigPath); _config = ConfigStore.Load(ConfigPath);
_coordinator = new RioCoordinator(() => _config); _coordinator = new RioCoordinator(() => _config);
_coordinator.StatusChanged += _ => RefreshOnUiThread(); _coordinator.StatusChanged += _ => RefreshOnUiThread();
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config); // Explicit (--profile) mode runs NO foreground detection at all: pod
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d); // launch scripts activate the profile before the game starts, so the
// ViGEm pad and the ports already exist when the game enumerates
// controllers — a watcher activating ~1 s after the window appears is
// too late for startup enumeration.
if (explicitProfile is null)
{
_watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config);
_watcher.DecisionChanged += d => _coordinator.ApplyDecision(d);
}
_statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false }; _statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false };
@@ -50,11 +71,90 @@ internal sealed class TrayApplicationContext : ApplicationContext
ContextMenuStrip = BuildMenu(), ContextMenuStrip = BuildMenu(),
}; };
// Poll the foreground app on the UI thread. // Poll the foreground app (and the --exit-with companion) on the UI thread.
_pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds }; _pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds };
_pollTimer.Tick += (_, _) => _watcher.Poll(); _pollTimer.Tick += (_, _) =>
{
_watcher?.Poll();
CheckCompanion();
};
_pollTimer.Start(); _pollTimer.Start();
_watcher.Poll();
if (explicitProfile is null)
_watcher!.Poll();
// Explicit activation runs via TryActivateExplicitProfile (called by
// Program between construction and the message loop, so a failure can
// become an exit code the launcher chain can read).
}
/// <summary>
/// The named event a pod launcher waits on instead of guessing from device
/// enumeration: signaled once the explicit profile is active (ports
/// acquired; virtual pad present when ViGEmBus is installed). Dies with
/// the process, so it can never go stale.
/// </summary>
internal const string ReadyEventName = "RIOJoy.Tray.Ready";
private EventWaitHandle? _readyEvent;
/// <summary>
/// Explicit (--profile) activation: activate now, and signal
/// <see cref="ReadyEventName"/> on success so the launcher can start the
/// game knowing the virtual pad already exists. False = activation failed
/// (port busy, bad endpoint, …) — the caller reports and exits.
/// </summary>
internal bool TryActivateExplicitProfile()
{
if (_explicitProfile is null)
return true; // resident mode — nothing to do
// Program.Main validated the name against the same store already.
RioProfile profile = _config.FindProfile(_explicitProfile)!;
_coordinator.SetManualProfile(profile);
if (_coordinator.Runtime is null)
{
Console.Error.WriteLine($"RioJoy: explicit activation failed: {_coordinator.Status}");
return false;
}
// Either side may create the event first (launcher-waits-then-start or
// start-then-wait); ManualReset + same name converge on one object.
_readyEvent = new EventWaitHandle(
initialState: false, EventResetMode.ManualReset, ReadyEventName);
_readyEvent.Set();
return true;
}
// Pod-bundled mode: quit (full teardown — ports released, wallpaper restored,
// plasma blanked) once the companion game has run and then exited, or never
// appeared within the startup grace.
private void CheckCompanion()
{
if (_exitWith is null)
return;
if (_companionExit.ShouldExit(IsCompanionRunning(_exitWith), _sinceStart.Elapsed))
Quit();
}
private static bool IsCompanionRunning(CompanionTarget target)
{
if (target.Pid is int pid)
{
try
{
using Process process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false; // no such process
}
}
Process[] matches = Process.GetProcessesByName(target.Name);
foreach (Process process in matches)
process.Dispose();
return matches.Length > 0;
} }
private ContextMenuStrip BuildMenu() private ContextMenuStrip BuildMenu()
@@ -231,36 +331,76 @@ internal sealed class TrayApplicationContext : ApplicationContext
// is suppressed (no keystrokes); the editor only shows which button is pressed. // is suppressed (no keystrokes); the editor only shows which button is pressed.
_coordinator.BeginEditorSession(profile); _coordinator.BeginEditorSession(profile);
var editor = new ProfileEditorForm(profile); var editor = new ProfileEditorForm(profile, _config.DefaultRioComPort);
editor.IsNameAvailable = name => !_config.Profiles.Any( editor.IsNameAvailable = name => !_config.Profiles.Any(
p => !ReferenceEquals(p, profile) && string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); p => !ReferenceEquals(p, profile) && string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
editor.Saved += _ => ConfigStore.Save(_config, ConfigPath);
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
editor.OutputsEnabledChanged += enabled => _coordinator.SetEditorOutputs(enabled);
RioRuntime? runtime = _coordinator.Runtime; // The editor's live wiring targets the CURRENT runtime, which is replaced
Action<int, bool>? activity = null; // when the session re-arms (endpoint change below) — so hook/unhook by pair.
if (runtime is not null) Action? unhook = null;
void HookRuntime()
{ {
activity = editor.ShowLiveActivity; RioRuntime? runtime = _coordinator.Runtime;
runtime.ButtonActivity += activity; if (runtime is null)
{
unhook = null;
return;
}
runtime.ButtonActivity += editor.ShowLiveActivity;
runtime.AxesUpdated += editor.ShowAxes; runtime.AxesUpdated += editor.ShowAxes;
runtime.VersionReceived += editor.ShowVersion; runtime.VersionReceived += editor.ShowVersion;
runtime.CheckReceived += editor.AddCheckStatus; runtime.CheckReceived += editor.AddCheckStatus;
} unhook = () =>
editor.FormClosed += (_, _) =>
{
if (runtime is not null && activity is not null)
{ {
runtime.ButtonActivity -= activity; runtime.ButtonActivity -= editor.ShowLiveActivity;
runtime.AxesUpdated -= editor.ShowAxes; runtime.AxesUpdated -= editor.ShowAxes;
runtime.VersionReceived -= editor.ShowVersion; runtime.VersionReceived -= editor.ShowVersion;
runtime.CheckReceived -= editor.AddCheckStatus; runtime.CheckReceived -= editor.AddCheckStatus;
};
}
bool outputsOn = false;
string armedEndpoint = profile.RioComPort ?? _config.DefaultRioComPort;
editor.Saved += _ =>
{
ConfigStore.Save(_config, ConfigPath);
// A saved port/pipe change takes effect immediately: re-arm the held
// session on the new endpoint so the live RIO buttons follow it.
string endpoint = profile.RioComPort ?? _config.DefaultRioComPort;
if (!string.Equals(endpoint, armedEndpoint, StringComparison.OrdinalIgnoreCase))
{
unhook?.Invoke();
_coordinator.BeginEditorSession(profile);
HookRuntime();
_coordinator.SetEditorOutputs(outputsOn); // new gates start closed
armedEndpoint = endpoint;
} }
};
editor.CommandRequested += cmd => _coordinator.Runtime?.Trigger(cmd);
editor.OutputsEnabledChanged += enabled =>
{
outputsOn = enabled;
_coordinator.SetEditorOutputs(enabled);
};
HookRuntime();
editor.FormClosed += (_, _) =>
{
unhook?.Invoke();
_coordinator.EndEditorSession(); _coordinator.EndEditorSession();
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game) if (_watcher is not null)
_watcher.Poll(); {
_watcher.Reset(); // re-sync with the foreground app (may re-activate a game)
_watcher.Poll();
}
else if (_explicitProfile is not null &&
_config.FindProfile(_explicitProfile) is RioProfile explicitProfile)
{
_coordinator.SetManualProfile(explicitProfile); // back to the explicit pod profile
}
}; };
editor.Show(); editor.Show();
} }
@@ -360,6 +500,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
_pollTimer.Dispose(); _pollTimer.Dispose();
_coordinator.Dispose(); _coordinator.Dispose();
_trayIcon.Dispose(); _trayIcon.Dispose();
_readyEvent?.Dispose();
} }
base.Dispose(disposing); base.Dispose(disposing);
@@ -163,4 +163,31 @@ public class AxisCalibratorTests
cal.Update(Report(throttle: -400)); // power-on with lever pushed: becomes the start cal.Update(Report(throttle: -400)); // power-on with lever pushed: becomes the start
Assert.Equal(0, cal.Update(Report(throttle: -100)).Z); // above max → re-based, rest Assert.Equal(0, cal.Update(Report(throttle: -100)).Z); // above max → re-based, rest
} }
[Fact]
public void Joystick_ReleaseToExactZero_Centers_NotHeld()
{
// Regression: the legacy left the output unchanged on a raw of exactly 0,
// which a jittering pot never produces - but vRIO's pad deadzone does,
// sustained, on every release. The held output latched the last in-motion
// value and the ship kept turning with the stick centered (bench
// 2026-08-01). A fast release goes straight from deflected to exact 0
// with no intermediate sample; both axes must land on center.
var cal = new AxisCalibrator();
cal.Update(Report(x: -3000, y: 2000)); // hard over
AxisOutputs o = cal.Update(Report(x: 0, y: 0)); // released, deadzoned to exact 0
Assert.Equal(AxisOutputs.Center, o.X);
Assert.Equal(AxisOutputs.Center, o.Y);
}
[Fact]
public void Joystick_ExactZero_StaysCentered_AcrossRepeatedPolls()
{
// The latch fed forward: once held off-center, every subsequent exact-0
// poll (one per 55 ms, forever) kept it there. Center must be stable.
var cal = new AxisCalibrator();
cal.Update(Report(x: 5000));
for (int i = 0; i < 5; i++)
Assert.Equal(AxisOutputs.Center, cal.Update(Report(x: 0)).X);
}
} }
@@ -0,0 +1,141 @@
using System.Globalization;
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class CoalescingLampSchedulerTests
{
private static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(1); // pump as fast as timers allow
private static Task WaitFor(Func<bool> condition, int timeoutMs = 5000) =>
FeedbackWait.For(condition, timeoutMs);
// "Lamp(0x12,0x3C)" → (0x12, 0x3C)
private static (int Address, byte State) ParseLamp(string entry)
{
string[] parts = entry["Lamp(0x".Length..^1].Split(new[] { ",0x" }, StringSplitOptions.None);
return (int.Parse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture),
byte.Parse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture));
}
[Fact]
public async Task Post_SameAddressRepeatedly_SendsOnlyTheLatestState()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
for (byte s = 0; s <= 0x30; s++)
scheduler.Post(0x12, s); // a burst of updates while nothing pumps
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= 1);
await Task.Delay(50); // give a buggy scheduler time to send the rest
cts.Cancel();
await pump.WithTimeout();
string entry = Assert.Single(sink.Snapshot());
Assert.Equal((0x12, (byte)0x30), ParseLamp(entry)); // burst collapsed to the last state
}
[Fact]
public async Task Post_UnchangedState_IsNotResent()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
scheduler.Post(0x05, RioLampState.SolidBright);
await WaitFor(() => sink.Snapshot().Length >= 1);
scheduler.Post(0x05, RioLampState.SolidBright); // same state again
await Task.Delay(50);
cts.Cancel();
await pump.WithTimeout();
Assert.Single(sink.Snapshot()); // no resend for an unchanged lamp
}
[Fact]
public async Task Pump_SendsAtMostOneLampPerTick()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(200));
scheduler.Post(0x01, RioLampState.SolidBright);
scheduler.Post(0x02, RioLampState.SolidBright);
scheduler.Post(0x03, RioLampState.SolidBright);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= 1);
// The next tick is ~200 ms out; three pending lamps must not burst.
Assert.Single(sink.Snapshot());
cts.Cancel();
await pump.WithTimeout();
}
[Fact]
public async Task PostAll_CoversExactlyTheValidAddressSet()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
scheduler.PostAll(RioLampState.SolidOff);
int validCount = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid);
Assert.Equal(104, validCount); // 72 buttons + 2×16 keypad keys; 0x48-0x4F is a gap
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= validCount);
await Task.Delay(50);
cts.Cancel();
await pump.WithTimeout();
var sent = sink.Snapshot().Select(ParseLamp).ToArray();
Assert.Equal(validCount, sent.Length); // nothing sent twice, no gap addresses
Assert.All(sent, s => Assert.Equal(RioLampState.SolidOff, s.State));
Assert.Equal(
Enumerable.Range(0, RioAddress.TableSize).Where(RioAddress.IsValid),
sent.Select(s => s.Address).OrderBy(a => a));
}
[Fact]
public async Task Post_InvalidAddress_Ignored()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
scheduler.Post(0x48, RioLampState.SolidBright); // gap address (rumble config is unvalidated)
scheduler.Post(0x70, RioLampState.SolidBright);
scheduler.Post(-1, RioLampState.SolidBright);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await pump.WithTimeout();
Assert.Empty(sink.Snapshot());
}
[Fact]
public async Task Cancel_StopsThePump()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
cts.Cancel();
await pump.WithTimeout(); // exits cleanly, no OperationCanceledException
scheduler.Post(0x01, RioLampState.SolidBright);
await Task.Delay(50);
Assert.Empty(sink.Snapshot()); // a stopped pump sends nothing
}
}
@@ -0,0 +1,344 @@
using System.Text;
using RioJoy.Core.Feedback;
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackLineParserTests
{
private static FeedbackCommand Parse(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
Assert.True(ok, $"expected '{line}' to parse, got error: {error}");
return command!;
}
private static string ParseError(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out _, out string? error);
Assert.False(ok);
Assert.NotNull(error);
return error!;
}
[Theory]
[InlineData("lamp 0x12 bright", 0x12, 0x3C)] // solid implied; = SolidBright
[InlineData("lamp 18 bright", 18, 0x3C)] // decimal address
[InlineData("lamp 0x12 dim", 0x12, 0x14)] // = SolidDim
[InlineData("lamp 0x12 off", 0x12, 0x00)] // = SolidOff
[InlineData("lamp 0x12 fast bright", 0x12, 0x3F)]
[InlineData("lamp 0x12 slow dim", 0x12, 0x15)]
[InlineData("lamp 0x12 med bright", 0x12, 0x3E)]
[InlineData("lamp 0x12 solid bright", 0x12, 0x3C)]
[InlineData("LAMP 0x12 FAST BRIGHT", 0x12, 0x3F)] // keywords case-insensitive
[InlineData("lamp 0x12 0x36", 0x12, 0x36)] // raw state byte
[InlineData("lamp 0x12 20", 0x12, 20)] // raw state, decimal
public void Lamp_Forms_ComposeTheDocumentedStateByte(string line, int address, int state)
{
FeedbackCommand cmd = Parse(line);
Assert.Equal(FeedbackCommandKind.Lamp, cmd.Kind);
Assert.Equal(address, cmd.Address);
Assert.Equal((byte)state, cmd.LampState);
}
[Fact]
public void Lamp_WordStates_MatchRioLampStateCompose()
{
Assert.Equal(RioLampState.SolidBright, Parse("lamp 0 bright").LampState);
Assert.Equal(RioLampState.SolidDim, Parse("lamp 0 dim").LampState);
Assert.Equal(RioLampState.SolidOff, Parse("lamp 0 off").LampState);
Assert.Equal(
RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright),
Parse("lamp 0 fast bright").LampState);
}
[Theory]
[InlineData(0x00)]
[InlineData(0x47)]
[InlineData(0x50)]
[InlineData(0x5F)]
[InlineData(0x60)]
[InlineData(0x6F)]
public void Lamp_AddressRangeEdges_Accepted(int address)
{
Assert.Equal(address, Parse($"lamp 0x{address:X2} dim").Address);
}
[Theory]
[InlineData("lamp 0x48 dim")] // gap between buttons and keypad 0
[InlineData("lamp 0x4F dim")]
[InlineData("lamp 0x70 dim")] // beyond MaxAddress
[InlineData("lamp 200 dim")]
public void Lamp_AddressOutOfRange_Rejected(string line)
{
Assert.Contains("out of range", ParseError(line));
}
[Theory]
[InlineData("lamp 0x12 0x40")] // raw state above 6 lamp-state bits
[InlineData("lamp 0x12 64")]
public void Lamp_RawStateAboveSixBits_Rejected(string line)
{
Assert.Contains("0x00-0x3F", ParseError(line));
}
[Theory]
[InlineData("lamp")]
[InlineData("lamp 0x12")]
[InlineData("lamp 0x12 blinky")]
[InlineData("lamp 0x12 fast blinky")]
[InlineData("lamp 0x12 fast bright extra")]
[InlineData("lamp banana dim")]
[InlineData("bogus 1 2")]
public void Lamp_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
[Fact]
public void LampAll_ParsesStateWithoutAddress()
{
FeedbackCommand cmd = Parse("lamp-all off");
Assert.Equal(FeedbackCommandKind.LampAll, cmd.Kind);
Assert.Equal(RioLampState.SolidOff, cmd.LampState);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("# a comment")]
[InlineData("; also a comment")]
[InlineData(" # indented comment")]
public void BlankAndComment_SkippedWithoutError(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
Assert.False(ok);
Assert.Null(command);
Assert.Null(error);
}
[Fact]
public void PlasmaClear_Parses()
{
Assert.Equal(FeedbackCommandKind.PlasmaClear, Parse("plasma clear").Kind);
}
[Fact]
public void PlasmaText_Quoted_StripsQuotes()
{
FeedbackCommand cmd = Parse("plasma text \"VIPER 1-1\"");
Assert.Equal(FeedbackCommandKind.PlasmaText, cmd.Kind);
Assert.Equal("VIPER 1-1", cmd.Text);
Assert.Equal(0, cmd.X); // (0,0) = auto-center
Assert.Equal(0, cmd.Y);
}
[Fact]
public void PlasmaText_Unquoted_TakesRestOfLine()
{
Assert.Equal("VIPER 1-1", Parse("plasma text VIPER 1-1").Text);
}
[Fact]
public void PlasmaText_TwoLeadingNumbers_ArePosition()
{
FeedbackCommand cmd = Parse("plasma text 12 3 \"FUEL LOW\"");
Assert.Equal(12, cmd.X);
Assert.Equal(3, cmd.Y);
Assert.Equal("FUEL LOW", cmd.Text);
}
[Fact]
public void PlasmaText_OneLeadingNumber_IsText()
{
// Only two consecutive numeric tokens form a position; a single one is text.
FeedbackCommand cmd = Parse("plasma text 42 kills");
Assert.Equal(0, cmd.X);
Assert.Equal(0, cmd.Y);
Assert.Equal("42 kills", cmd.Text);
}
[Fact]
public void PlasmaText_QuotedNumber_IsText()
{
Assert.Equal("42", Parse("plasma text \"42\"").Text);
}
[Fact]
public void PlasmaText_Latin1Chars_Preserved()
{
// Latin-1 range survives the parser untouched (plasma wire encoding).
Assert.Equal("CAFÉ ÜBER", Parse("plasma text \"CAFÉ ÜBER\"").Text);
}
[Theory]
[InlineData("plasma")]
[InlineData("plasma bogus")]
[InlineData("plasma clear extra")]
[InlineData("plasma text")]
[InlineData("plasma text \"unterminated")]
[InlineData("plasma text \"done\" trailing")]
[InlineData("plasma text 300 1 \"X\"")] // position out of byte range
public void Plasma_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
[Fact]
public void PlasmaRow_ParsesRowAndHexData()
{
FeedbackCommand cmd = Parse("plasma row 5 80000000000000000000000000000001");
Assert.Equal(FeedbackCommandKind.PlasmaRow, cmd.Kind);
Assert.Equal(5, cmd.Y);
Assert.NotNull(cmd.Data);
Assert.Equal(16, cmd.Data!.Length);
Assert.Equal(0x80, cmd.Data[0]); // leftmost pixel lit (MSB-first)
Assert.Equal(0x01, cmd.Data[15]);
Assert.All(cmd.Data.Skip(1).Take(14), b => Assert.Equal(0, b));
}
[Fact]
public void PlasmaRow_HexRowNumber_AndMixedCaseHex()
{
FeedbackCommand cmd = Parse("PLASMA ROW 0x1F AaBbCcDdEeFf00112233445566778899");
Assert.Equal(31, cmd.Y);
Assert.Equal(0xAA, cmd.Data![0]);
Assert.Equal(0x99, cmd.Data[15]);
}
[Theory]
[InlineData("plasma row")] // no row
[InlineData("plasma row 5")] // no data
[InlineData("plasma row 32 80000000000000000000000000000001")] // row out of range
[InlineData("plasma row 5 8000")] // too short
[InlineData("plasma row 5 800000000000000000000000000000010A")] // too long
[InlineData("plasma row 5 8000000000000000000000000000000G")] // non-hex char
[InlineData("plasma row 5 80000000000000000000000000000001 x")] // trailing token
public void PlasmaRow_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
[Fact]
public void PlasmaText_ThirdNumberWithTextFollowing_IsFont()
{
FeedbackCommand cmd = Parse("plasma text 35 21 2 \"1\"");
Assert.Equal(35, cmd.X);
Assert.Equal(21, cmd.Y);
Assert.Equal(2, cmd.Font);
Assert.Equal("1", cmd.Text);
}
[Fact]
public void PlasmaText_ThirdNumberAsLastToken_IsTextNotFont()
{
// `plasma text 2 2 7` keeps displaying "7", as it always has.
FeedbackCommand cmd = Parse("plasma text 2 2 7");
Assert.Equal(2, cmd.X);
Assert.Equal(2, cmd.Y);
Assert.Equal(0, cmd.Font);
Assert.Equal("7", cmd.Text);
}
[Fact]
public void PlasmaText_NoFontGiven_IsAuto()
{
Assert.Equal(0, Parse("plasma text 12 3 \"FUEL LOW\"").Font);
Assert.Equal(0, Parse("plasma text \"VIPER 1-1\"").Font);
}
[Fact]
public void PlasmaText_FontOutOfRange_Rejected()
{
Assert.Contains("font", ParseError("plasma text 2 2 99 \"X\""));
}
[Fact]
public void PlasmaBox_ParsesGeometry()
{
FeedbackCommand cmd = Parse("plasma box 32 19 64 12");
Assert.Equal(FeedbackCommandKind.PlasmaBox, cmd.Kind);
Assert.Equal(32, cmd.X);
Assert.Equal(19, cmd.Y);
Assert.Equal(64, cmd.Width);
Assert.Equal(12, cmd.Height);
}
[Theory]
[InlineData("plasma box")] // nothing
[InlineData("plasma box 32 19 64")] // missing h
[InlineData("plasma box 32 19 64 12 x")] // trailing token
[InlineData("plasma box 128 0 1 1")] // x out of range
[InlineData("plasma box 0 32 1 1")] // y out of range
[InlineData("plasma box 100 0 40 1")] // spills off the right edge
[InlineData("plasma box 0 28 1 8")] // spills off the bottom
[InlineData("plasma box 0 0 0 5")] // zero width
public void PlasmaBox_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
}
public class FeedbackLineBufferTests
{
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
[Fact]
public void Feed_SplitsOnLf_AndStripsCr()
{
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("lamp 1 dim\r\nplasma clear\n");
Assert.Equal(
new[] { "lamp 1 dim", "plasma clear" },
buffer.Feed(data, data.Length));
}
[Fact]
public void Feed_ReassemblesLinesSplitAcrossChunks()
{
var buffer = new FeedbackLineBuffer();
byte[] a = Latin1("lamp 0x12 fa");
byte[] b = Latin1("st bright\n");
Assert.Empty(buffer.Feed(a, a.Length));
Assert.Equal(new[] { "lamp 0x12 fast bright" }, buffer.Feed(b, b.Length));
}
[Fact]
public void Feed_DiscardsOverlongLine_ThenRecovers()
{
var buffer = new FeedbackLineBuffer();
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50) + "\nlamp 1 dim\n");
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(junk, junk.Length));
}
[Fact]
public void Feed_DecodesLatin1Bytes()
{
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("plasma text \"CAFÉ\"\n");
Assert.Equal(new[] { "plasma text \"CAFÉ\"" }, buffer.Feed(data, data.Length));
}
[Fact]
public void Flush_ReturnsTrailingLineWithoutLf()
{
// UDP datagrams may omit the final LF; end-of-datagram is a terminator.
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("lamp 1 dim\nlamp 2 off");
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(data, data.Length));
Assert.Equal("lamp 2 off", buffer.Flush());
Assert.Null(buffer.Flush()); // flushed state is consumed
}
[Fact]
public void Flush_EmptyOrDiscarding_ReturnsNull()
{
var buffer = new FeedbackLineBuffer();
Assert.Null(buffer.Flush());
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50));
Assert.Empty(buffer.Feed(junk, junk.Length));
Assert.Null(buffer.Flush()); // overlong tail is discarded, not returned
}
}
@@ -0,0 +1,156 @@
using System.IO.Pipes;
using System.Text;
using RioJoy.Core.Feedback;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackPipeServerTests
{
private static string UniqueName() => $"riojoy-fb-test-{Guid.NewGuid():N}";
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
/// <summary>Collector whose Count/Snapshot are safe against the reader threads.</summary>
private sealed class Lines
{
private readonly List<string> _lines = new();
public void Add(string line)
{
lock (_lines) _lines.Add(line);
}
public int Count
{
get { lock (_lines) return _lines.Count; }
}
public string[] Snapshot()
{
lock (_lines) return _lines.ToArray();
}
}
// The accept loop arms asynchronously after Start; retry until it listens.
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
try
{
client.Connect(200);
return client;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
client.Dispose();
Assert.True(DateTime.UtcNow < deadline, $"could not connect to {name}: {ex.Message}");
Thread.Sleep(20);
}
}
}
private static void Send(NamedPipeClientStream client, string text)
{
byte[] data = Latin1(text);
client.Write(data, 0, data.Length);
client.Flush();
}
[Fact]
public async Task Lines_AreDeliveredInOrder()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "lamp 1 dim\r\nplasma clear\n");
await FeedbackWait.For(() => lines.Count >= 2);
Assert.Equal(new[] { "lamp 1 dim", "plasma clear" }, lines.Snapshot());
}
[Fact]
public async Task Line_SplitAcrossWrites_Reassembles()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "lamp 0x12 fa");
Send(client, "st bright\n");
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 0x12 fast bright", Assert.Single(lines.Snapshot()));
}
[Fact]
public async Task TwoConcurrentClients_BothDeliver()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream a = Connect(name);
using NamedPipeClientStream b = Connect(name); // second instance while A stays connected
Send(a, "lamp 1 dim\n");
Send(b, "lamp 2 off\n");
await FeedbackWait.For(() => lines.Count >= 2);
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off" }, lines.Snapshot().OrderBy(l => l));
}
[Fact]
public async Task ClientDisconnect_ThenReconnect_Works()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using (NamedPipeClientStream first = Connect(name))
Send(first, "lamp 1 dim\n");
await FeedbackWait.For(() => lines.Count >= 1);
using NamedPipeClientStream second = Connect(name); // server re-arms after the EOF
Send(second, "lamp 2 off\n");
await FeedbackWait.For(() => lines.Count >= 2);
}
[Fact]
public async Task ThrowingLineHandler_DoesNotKillTheConnection()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, line =>
{
if (line.Contains("boom"))
throw new InvalidOperationException("handler bug");
lines.Add(line);
});
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "boom\nlamp 1 dim\n");
await FeedbackWait.For(() => lines.Count >= 1); // the good line still lands
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public void Dispose_UnblocksThePendingAccept()
{
var server = new FeedbackPipeServer(UniqueName(), _ => { });
server.Start();
Thread.Sleep(100); // let the accept loop park in WaitForConnection
server.Dispose(); // must not hang on the pending accept (poke-connect)
}
}
@@ -0,0 +1,284 @@
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackRouterTests : IDisposable
{
private readonly RecordingSink _sink = new();
private readonly CoalescingLampScheduler _scheduler;
private readonly CancellationTokenSource _cts = new();
private readonly Task _pump;
public FeedbackRouterTests()
{
_scheduler = new CoalescingLampScheduler(_sink, TimeSpan.FromMilliseconds(1));
_pump = _scheduler.RunAsync(_cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_pump.Wait(TimeSpan.FromSeconds(5));
_cts.Dispose();
}
private static FeedbackCommand Lamp(int address, byte state) =>
new() { Kind = FeedbackCommandKind.Lamp, Address = address, LampState = state };
private static FeedbackCommand Text(string text) =>
new() { Kind = FeedbackCommandKind.PlasmaText, Text = text };
private static FeedbackCommand Row(byte y)
{
var data = new byte[16];
data[0] = y; // distinguishable payload per row
return new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaRow, Y = y, Data = data };
}
[Fact]
public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied()
{
var map = new RioInputMap();
map[0x10] = RioMapEntry.Create(RioRouteKind.Keyboard, 0x41, lit: true); // InputRouter owns this lamp
var router = new FeedbackRouter();
var logged = new List<string>();
router.Logged += logged.Add;
router.Attach(_scheduler, map, plasma: null, new ProfileFeedbackConfig());
router.Dispatch(Lamp(0x11, RioLampState.SolidBright)); // unowned → applied
await FeedbackWait.For(() => _sink.Snapshot().Length >= 1);
Assert.Equal("Lamp(0x11,0x3C)", Assert.Single(_sink.Snapshot()));
router.Dispatch(Lamp(0x10, RioLampState.SolidBright)); // owned → dropped
router.Dispatch(Lamp(0x10, RioLampState.SolidOff));
await Task.Delay(50);
Assert.Single(_sink.Snapshot());
Assert.Equal(2, router.DroppedCommands);
Assert.Single(logged); // logged once per address per attach, not per drop
}
[Fact]
public async Task Detached_CommandsDroppedAndCounted()
{
var router = new FeedbackRouter();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); // never attached
Assert.Equal(1, router.DroppedCommands);
router.Attach(_scheduler, new RioInputMap(), null, new ProfileFeedbackConfig());
router.Detach();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
Assert.Equal(2, router.DroppedCommands);
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
}
[Fact]
public async Task AllowFlags_GateLampAndPlasma()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig { AllowLampCommands = false, AllowPlasmaText = false });
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
router.Dispatch(Text("NOPE"));
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
Assert.False(transport.Writes.TryRead(out _));
Assert.Equal(2, router.DroppedCommands);
}
[Fact]
public async Task LampAll_SkipsProfileOwnedLamps()
{
var map = new RioInputMap();
map[0x00] = RioMapEntry.Create(RioRouteKind.Joystick, 1, lit: true);
var router = new FeedbackRouter();
router.Attach(_scheduler, map, null, new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand
{
Kind = FeedbackCommandKind.LampAll,
LampState = RioLampState.SolidDim,
});
int expected = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid) - 1;
await FeedbackWait.For(() => _sink.Snapshot().Length >= expected);
await Task.Delay(50);
string[] sent = _sink.Snapshot();
Assert.Equal(expected, sent.Length);
Assert.DoesNotContain("Lamp(0x00,0x14)", sent); // the profile-owned lamp is untouched
}
[Fact]
public async Task Plasma_FloodCoalesces_FirstAndLatestOnly()
{
// Park the first text mid-write; everything dispatched meanwhile collapses
// to the single latest pending command.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Text("FIRST")); // goes busy, parked on the gate
router.Dispatch(Text("MID-1")); // pending
router.Dispatch(Text("MID-2")); // supersedes MID-1
router.Dispatch(Text("LAST")); // supersedes MID-2
transport.Open();
var writes = new List<byte[]>();
for (int i = 0; i < 10; i++)
writes.Add(await transport.NextWriteAsync());
await Task.Delay(50);
Assert.Equal(PlasmaCommands.Text("FIRST"), writes[4]); // FIRST's text chunk
Assert.Equal(PlasmaCommands.Text("LAST"), writes[9]); // then only LAST's
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the two superseded middles
}
[Fact]
public async Task PlasmaText_CoalescesPerPosition_OtherFieldsSurvive()
{
// The documented multi-field layout sends several positioned texts in a
// burst (callsign top, score bottom). Coalescing is per (x,y): a newer
// score supersedes the queued score, never the queued callsign.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Text("PARK")); // goes busy, parked on the gate
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "VIPER", X = 2, Y = 2 });
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 1", X = 2, Y = 18 });
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 2", X = 2, Y = 18 }); // supersedes SCORE 1 only
transport.Open();
var texts = new List<string>();
for (int i = 0; i < 15; i++)
{
byte[] w = await transport.NextWriteAsync();
string s = System.Text.Encoding.GetEncoding(28591).GetString(w);
if (w.Length > 0 && w[0] != 0x1B)
texts.Add(s); // the text chunks, minus ESC command prefixes
if (texts.Count == 3)
break;
}
Assert.Equal(new[] { "PARK", "VIPER", "SCORE 2" }, texts);
Assert.Equal(1, router.DroppedCommands); // only SCORE 1 superseded
}
[Fact]
public async Task PlasmaBox_WritesOutlineWithBlankedInterior()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
// 16px-wide box at a byte boundary, 4 rows: bytes are exact.
router.Dispatch(new FeedbackCommand
{ Kind = FeedbackCommandKind.PlasmaBox, X = 32, Y = 10, Width = 16, Height = 4 });
transport.Open();
byte[] w = await transport.NextWriteAsync();
// ESC P s=0 y=10 x=4 w=2 h=4, then 8 data bytes.
Assert.Equal(new byte[] { 0x1B, (byte)'P', 0, 10, 4, 2, 4 }, w.Take(7).ToArray());
byte[] data = w.Skip(7).ToArray();
Assert.Equal(new byte[]
{
0xFF, 0xFF, // top edge: all lit
0x80, 0x01, // interior row: only the side walls
0x80, 0x01,
0xFF, 0xFF, // bottom edge
}, data);
}
[Fact]
public async Task PlasmaRows_StreamInFifoOrder_NotCoalesced()
{
// A bitmap frame is many rows — unlike text, rows must all land, in order.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // goes busy, parked on the gate
router.Dispatch(Row(1));
router.Dispatch(Row(2));
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(1, Row(1).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(2, Row(2).Data!), await transport.NextWriteAsync());
Assert.Equal(0, router.DroppedCommands);
}
[Fact]
public async Task PlasmaClear_FlushesQueuedRowsAndTexts()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // in flight, parked
router.Dispatch(Row(1)); // queued…
router.Dispatch(Text("STALE"));
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); // …flushed
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the flushed row + text
}
[Fact]
public async Task PlasmaRowQueue_IsBounded()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // parked in flight; everything below queues
for (int i = 0; i < 140; i++)
router.Dispatch(Row(1));
Assert.Equal(140 - 128, router.DroppedCommands); // over the 128-entry bound
transport.Open();
// Drain: the parked row + the 128 queued ones.
for (int i = 0; i < 129; i++)
await transport.NextWriteAsync();
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
}
[Fact]
public async Task PlasmaClear_WritesTheClearCommand()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear });
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
}
@@ -0,0 +1,118 @@
using System.IO.Pipes;
using System.Text;
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
/// <summary>
/// End-to-end: pipe client → line assembly → parse → router → lamp scheduler /
/// plasma, the exact path a sim export script exercises.
/// </summary>
public class FeedbackServiceTests
{
private static string UniqueName() => $"riojoy-fb-svc-{Guid.NewGuid():N}";
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
try
{
client.Connect(200);
return client;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
client.Dispose();
Assert.True(DateTime.UtcNow < deadline, $"could not connect: {ex.Message}");
Thread.Sleep(20);
}
}
}
private static void Send(NamedPipeClientStream client, string text)
{
byte[] data = Encoding.GetEncoding(28591).GetBytes(text);
client.Write(data, 0, data.Length);
client.Flush();
}
[Fact]
public async Task PipeClient_DrivesLampsAndPlasma_MalformedLinesSurvive()
{
string name = UniqueName();
var lamps = new RecordingSink();
var plasmaTransport = new FakeTransport();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
service.Attach(lamps, new RioInputMap(), new PlasmaDisplay(plasmaTransport),
new ProfileFeedbackConfig());
using NamedPipeClientStream client = Connect(name);
Send(client, "# cockpit warmup\nlamp 0x11 fast bright\nbogus nonsense\nplasma clear\n");
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
Assert.Equal("Lamp(0x11,0x3F)", Assert.Single(lamps.Snapshot()));
Assert.Equal(PlasmaCommands.Clear(), await plasmaTransport.NextWriteAsync());
Assert.Equal(1, service.MalformedLines); // the bogus line, not the comment
Send(client, "lamp 0x11 off\n"); // the connection survived the bad line
await FeedbackWait.For(() => lamps.Snapshot().Length >= 2);
Assert.Equal("Lamp(0x11,0x00)", lamps.Snapshot()[1]);
}
[Fact]
public async Task PipeClient_StreamsBitmapRows()
{
string name = UniqueName();
var plasmaTransport = new FakeTransport();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
service.Attach(new RecordingSink(), new RioInputMap(),
new PlasmaDisplay(plasmaTransport), new ProfileFeedbackConfig());
using NamedPipeClientStream client = Connect(name);
Send(client, "plasma row 0 FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n" +
"plasma row 1 80000000000000000000000000000001\n");
var solid = Enumerable.Repeat((byte)0xFF, 16).ToArray();
var edges = new byte[16];
edges[0] = 0x80;
edges[15] = 0x01;
Assert.Equal(PlasmaCommands.GraphicsRow(0, solid), await plasmaTransport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(1, edges), await plasmaTransport.NextWriteAsync());
}
[Fact]
public async Task Detach_DropsCommands_ReattachAppliesAgain()
{
string name = UniqueName();
var lamps = new RecordingSink();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
using NamedPipeClientStream client = Connect(name);
// Dormant (never attached): commands drop, the client stays connected.
Send(client, "lamp 0x01 bright\n");
await FeedbackWait.For(() => service.DroppedCommands >= 1);
Assert.Empty(lamps.Snapshot());
// Profile activates: the same client now drives lamps.
service.Attach(lamps, new RioInputMap(), null, new ProfileFeedbackConfig());
Send(client, "lamp 0x01 bright\n");
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
// Yield to a native game: back to dropping, still connected.
service.Detach();
Send(client, "lamp 0x01 off\n");
await FeedbackWait.For(() => service.DroppedCommands >= 2);
Assert.Single(lamps.Snapshot());
}
}
@@ -0,0 +1,17 @@
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
internal static class FeedbackWait
{
/// <summary>Poll until <paramref name="condition"/> holds, failing at the deadline.</summary>
public static async Task For(Func<bool> condition, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
Assert.True(DateTime.UtcNow < deadline, "condition not reached in time");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,100 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using RioJoy.Core.Feedback;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackUdpListenerTests
{
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
private sealed class Lines
{
private readonly List<string> _lines = new();
public void Add(string line)
{
lock (_lines) _lines.Add(line);
}
public int Count
{
get { lock (_lines) return _lines.Count; }
}
public string[] Snapshot()
{
lock (_lines) return _lines.ToArray();
}
}
private static void Send(int port, byte[] datagram)
{
using var udp = new UdpClient();
udp.Send(datagram, datagram.Length, new IPEndPoint(IPAddress.Loopback, port));
}
[Fact]
public async Task Datagram_WithoutTrailingLf_IsOneLine()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, lines.Add); // 0 → ephemeral
Assert.NotEqual(0, listener.Port);
listener.Start();
Send(listener.Port, Latin1("lamp 1 dim")); // datagram end terminates the line
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public async Task Datagram_WithMultipleLines_DeliversEach()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, lines.Add);
listener.Start();
Send(listener.Port, Latin1("lamp 1 dim\nlamp 2 off\nplasma clear"));
await FeedbackWait.For(() => lines.Count >= 3);
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off", "plasma clear" }, lines.Snapshot());
}
[Fact]
public async Task ThrowingLineHandler_DoesNotKillTheListener()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, line =>
{
if (line.Contains("boom"))
throw new InvalidOperationException("handler bug");
lines.Add(line);
});
listener.Start();
Send(listener.Port, Latin1("boom\n"));
Send(listener.Port, Latin1("lamp 1 dim\n"));
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public void Dispose_UnblocksThePendingReceive()
{
var listener = new FeedbackUdpListener(0, _ => { });
listener.Start();
Thread.Sleep(50); // let the loop park in Receive
listener.Dispose(); // Close must unblock it without hanging
}
[Fact]
public void PortInUse_ThrowsSocketException()
{
using var first = new FeedbackUdpListener(0, _ => { });
Assert.Throws<SocketException>(() => new FeedbackUdpListener(first.Port, _ => { }));
}
}
@@ -0,0 +1,108 @@
using RioJoy.Core.Feedback;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class RumbleLampAdapterTests
{
// Flash + Bright/Bright state bytes the bands resolve to.
private const byte SlowBright = 0x3D;
private const byte MedBright = 0x3E;
private const byte FastBright = 0x3F;
[Theory]
[InlineData(0, 24, 0x00)] // below threshold → off
[InlineData(23, 24, 0x00)]
[InlineData(24, 24, SlowBright)] // first third of the remaining range
[InlineData(100, 24, SlowBright)]
[InlineData(101, 24, MedBright)] // second third
[InlineData(177, 24, MedBright)]
[InlineData(178, 24, FastBright)] // top third
[InlineData(255, 24, FastBright)]
[InlineData(0, 0, SlowBright)] // threshold 0 = never off
public void MapMotor_BandsResolveToDocumentedStates(int value, int threshold, byte expected)
{
Assert.Equal(expected, RumbleLampAdapter.MapMotor((byte)value, (byte)threshold));
}
[Fact]
public void MapMotor_MatchesRioLampStateCompose()
{
Assert.Equal(
RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright),
RumbleLampAdapter.MapMotor(255, 24));
Assert.Equal(RioLampState.SolidOff, RumbleLampAdapter.MapMotor(0, 24));
}
[Fact]
public async Task OnRumble_DrivesEachMotorsConfiguredAddresses()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(new RumbleLampConfig
{
LargeMotorLamps = { 0x20, 0x21 },
SmallMotorLamps = { 0x30 },
}, scheduler);
adapter.OnRumble(255, 0); // big hit, no small motor
await FeedbackWait.For(() => sink.Snapshot().Length >= 3);
cts.Cancel();
await pump;
string[] sent = sink.Snapshot();
Assert.Contains("Lamp(0x20,0x3F)", sent); // large motor lamps flash fast
Assert.Contains("Lamp(0x21,0x3F)", sent);
Assert.Contains("Lamp(0x30,0x00)", sent); // small motor lamps confirmed off
}
[Fact]
public async Task OnRumble_RepeatedIdenticalValues_PostNothingNew()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(
new RumbleLampConfig { LargeMotorLamps = { 0x20 }, SmallMotorLamps = { 0x30 } },
scheduler);
// XInput-style spam: same vibration reported over and over.
for (int i = 0; i < 200; i++)
adapter.OnRumble(200, 0);
await FeedbackWait.For(() => sink.Snapshot().Length >= 2);
await Task.Delay(50);
cts.Cancel();
await pump;
Assert.Equal(2, sink.Snapshot().Length); // one state per motor, ever
}
[Fact]
public async Task OnRumble_ZeroAfterRumble_TurnsTheLampsOff()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(
new RumbleLampConfig { LargeMotorLamps = { 0x20 } }, scheduler);
adapter.OnRumble(255, 0);
await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x3F)"));
adapter.OnRumble(0, 0);
await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x00)"));
cts.Cancel();
await pump;
}
}
@@ -0,0 +1,66 @@
using RioJoy.Core.Hosting;
using Xunit;
namespace RioJoy.Core.Tests.Hosting;
public class CompanionTargetTests
{
[Fact]
public void NumericValue_IsAPid()
{
CompanionTarget target = CompanionTarget.Parse("4312");
Assert.Equal(4312, target.Pid);
Assert.Null(target.Name);
}
[Theory]
[InlineData("d1x-rebirth")]
[InlineData("D1X-Rebirth.exe")]
[InlineData(@"C:\games\descent\d1x-rebirth.exe")]
public void NameForms_NormalizeLikeTriggers(string value)
{
CompanionTarget target = CompanionTarget.Parse(value);
Assert.Null(target.Pid);
Assert.Equal("d1x-rebirth", target.Name);
}
[Fact]
public void BlankValue_Throws()
{
Assert.Throws<ArgumentException>(() => CompanionTarget.Parse(" "));
}
}
public class CompanionExitTests
{
private static readonly TimeSpan Grace = TimeSpan.FromSeconds(60);
[Fact]
public void CompanionSeenThenGone_Exits()
{
var exit = new CompanionExit(Grace);
Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(1)));
Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(2)));
Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(3)));
}
[Fact]
public void CompanionNeverSeen_WaitsOutTheStartupGrace()
{
// Launch order is not guaranteed: the game may still be loading.
var exit = new CompanionExit(Grace);
Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(5)));
Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(59)));
Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(60)));
}
[Fact]
public void LateStart_WithinGrace_StillTracksTheCompanion()
{
var exit = new CompanionExit(Grace);
Assert.False(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(30)));
Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(45))); // game arrived late
Assert.False(exit.ShouldExit(companionRunning: true, TimeSpan.FromSeconds(90))); // grace no longer matters
Assert.True(exit.ShouldExit(companionRunning: false, TimeSpan.FromSeconds(91)));
}
}
@@ -30,6 +30,37 @@ public class PlasmaCommandsTests
Assert.Equal(new byte[] { (byte)'A', (byte)'B', (byte)'C' }, PlasmaCommands.Text("ABC")); Assert.Equal(new byte[] { (byte)'A', (byte)'B', (byte)'C' }, PlasmaCommands.Text("ABC"));
} }
[Fact]
public void GraphicsWrite_LaysOutHeaderThenData()
{
// ESC P s y x w h data… (s=0, single-screen hardware).
byte[] cmd = PlasmaCommands.GraphicsWrite(5, 2, 2, 2, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD });
Assert.Equal(new byte[] { 27, (byte)'P', 0, 5, 2, 2, 2, 0xAA, 0xBB, 0xCC, 0xDD }, cmd);
}
[Fact]
public void GraphicsRow_IsAWholeRowWrite()
{
// The native game's shape: x=0, w=16, h=1 — one full 128-px row.
var row = new byte[16];
row[0] = 0x80; // leftmost pixel (MSB-first)
byte[] cmd = PlasmaCommands.GraphicsRow(31, row);
byte[] expected = new byte[] { 27, (byte)'P', 0, 31, 0, 16, 1 }.Concat(row).ToArray();
Assert.Equal(expected, cmd);
}
[Fact]
public void GraphicsWrite_RejectsOutOfPanelSpans()
{
Assert.Throws<ArgumentOutOfRangeException>(() => PlasmaCommands.GraphicsRow(32, new byte[16]));
Assert.Throws<ArgumentOutOfRangeException>(
() => PlasmaCommands.GraphicsWrite(0, 15, 2, 1, new byte[2])); // spills past byte column 15
Assert.Throws<ArgumentOutOfRangeException>(
() => PlasmaCommands.GraphicsWrite(31, 0, 16, 2, new byte[32])); // spills past row 31
Assert.Throws<ArgumentException>(
() => PlasmaCommands.GraphicsWrite(0, 0, 16, 1, new byte[15])); // data length mismatch
}
[Theory] [Theory]
[InlineData(0, 5, 7)] [InlineData(0, 5, 7)]
[InlineData(3, 5, 7)] [InlineData(3, 5, 7)]
@@ -0,0 +1,92 @@
using RioJoy.Core.Plasma;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Plasma;
public class PlasmaDisplayTests
{
private static byte[][] PosTextChunks(string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0)
{
(byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font);
return new[]
{
PlasmaCommands.CursorX(rx),
PlasmaCommands.CursorY(ry),
PlasmaCommands.FontAttr(attr),
PlasmaCommands.Font(rfont),
PlasmaCommands.Text(text[..len]),
};
}
[Fact]
public async Task PosTextAsync_EmitsThePosTextSequenceInOrder()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.PosTextAsync("VIPER 1-1").WithTimeout();
foreach (byte[] expected in PosTextChunks("VIPER 1-1"))
Assert.Equal(expected, await transport.NextWriteAsync());
}
[Fact]
public async Task PosTextAsync_EmptyText_WritesNothing()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.PosTextAsync("").WithTimeout();
Assert.False(transport.Writes.TryRead(out _));
}
[Fact]
public async Task ClearAsync_WritesTheClearCommand()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.ClearAsync().WithTimeout();
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
[Fact]
public async Task RowAsync_WritesOneGraphicsRowCommand()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
var row = new byte[16];
row[3] = 0xF0;
await display.RowAsync(12, row).WithTimeout();
Assert.Equal(PlasmaCommands.GraphicsRow(12, row), await transport.NextWriteAsync());
}
[Fact]
public async Task PosTextAsync_ConcurrentCalls_DoNotInterleave()
{
// Without the write lock, B's cursor/font fragments land between A's five
// writes and corrupt the ESC stream. Gate A's first write so B has every
// chance to sneak in, then assert the ten writes arrive as A's five
// followed by B's five.
var transport = new GatedTransport();
var display = new PlasmaDisplay(transport);
Task a = display.PosTextAsync("AAAA");
Task b = display.PosTextAsync("BBBB");
transport.Open();
await Task.WhenAll(a, b).WithTimeout();
var writes = new List<byte[]>();
for (int i = 0; i < 10; i++)
writes.Add(await transport.NextWriteAsync());
byte[][] expected = PosTextChunks("AAAA").Concat(PosTextChunks("BBBB")).ToArray();
for (int i = 0; i < expected.Length; i++)
Assert.Equal(expected[i], writes[i]);
}
}
@@ -0,0 +1,50 @@
using RioJoy.Core.Profiles;
using Xunit;
namespace RioJoy.Core.Tests.Profiles;
public class ConfigLocatorTests
{
[Fact]
public void NoPortableFile_ResolvesToRoaming()
{
string dir = Path.Combine(Path.GetTempPath(), $"riojoy-loc-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
Assert.Equal(@"C:\roaming\config.json",
ConfigLocator.Resolve(dir, @"C:\roaming\config.json"));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void PortableFileBesideExe_Wins()
{
string dir = Path.Combine(Path.GetTempPath(), $"riojoy-loc-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
string portable = Path.Combine(dir, ConfigLocator.PortableConfigFileName);
File.WriteAllText(portable, "{}");
Assert.Equal(portable, ConfigLocator.Resolve(dir, @"C:\roaming\config.json"));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MissingExeDirectory_FallsBackToRoaming(string? exeDir)
{
Assert.Equal(@"C:\roaming\config.json",
ConfigLocator.Resolve(exeDir, @"C:\roaming\config.json"));
}
}
@@ -1,4 +1,5 @@
using RioJoy.Core.Calibration; using RioJoy.Core.Calibration;
using RioJoy.Core.Feedback;
using RioJoy.Core.Output; using RioJoy.Core.Output;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
using Xunit; using Xunit;
@@ -86,6 +87,69 @@ public class ConfigStoreTests
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting); Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
} }
[Fact]
public void RoundTrips_FeedbackSections()
{
var config = new AppConfig
{
Feedback = new FeedbackEndpointConfig { PipeName = "riojoy-fb-test", UdpPort = 19900 },
Profiles =
{
new RioProfile
{
Name = "DCS",
Feedback = new ProfileFeedbackConfig
{
AllowPlasmaText = false,
Rumble = new RumbleLampConfig
{
LargeMotorLamps = { 0x12, 0x13 },
SmallMotorLamps = { 0x60 },
Threshold = 32,
},
},
},
},
};
AppConfig back = ConfigStore.Deserialize(ConfigStore.Serialize(config));
Assert.NotNull(back.Feedback);
Assert.True(back.Feedback!.PipeEnabled);
Assert.Equal("riojoy-fb-test", back.Feedback.PipeName);
Assert.Equal(19900, back.Feedback.UdpPort);
// These records hold List<int>, so no record value equality — per-property.
ProfileFeedbackConfig fb = Assert.Single(back.Profiles).Feedback!;
Assert.True(fb.AllowLampCommands);
Assert.False(fb.AllowPlasmaText);
Assert.NotNull(fb.Rumble);
Assert.Equal(new[] { 0x12, 0x13 }, fb.Rumble!.LargeMotorLamps);
Assert.Equal(new[] { 0x60 }, fb.Rumble.SmallMotorLamps);
Assert.Equal(32, fb.Rumble.Threshold);
}
[Fact]
public void Feedback_Unset_StaysNull_AndOffJson()
{
// null = feedback off / endpoint defaults; NullValueHandling.Ignore keeps
// both sections out of the JSON, so pre-Phase-9 files stay byte-compatible.
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
Assert.DoesNotContain("Feedback", json);
AppConfig back = ConfigStore.Deserialize(json);
Assert.Null(back.Feedback);
Assert.Null(Assert.Single(back.Profiles).Feedback);
}
[Fact]
public void ShippedDescentProfile_ParsesWithFeedbackOff()
{
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
Assert.Null(p.Feedback); // pre-Phase-9 profile documents deserialize with feedback off
}
[Fact] [Fact]
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets() public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
{ {
@@ -48,6 +48,30 @@ public class RioRuntimeTests
await run; await run;
} }
[Fact]
public async Task Lamps_SetLamp_SendsALampRequestOverTheLink()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// The accessor the feedback endpoint's lamp scheduler drives (Phase 9).
runtime.Lamps.SetLamp(0x12, RioLampState.SolidBright);
Assert.Equal(
PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x12, RioLampState.SolidBright }),
await fake.NextWriteAsync());
cts.Cancel();
await run;
}
[Fact] [Fact]
public async Task AnalogReply_DrivesAllSixAxes() public async Task AnalogReply_DrivesAllSixAxes()
{ {
@@ -0,0 +1,50 @@
using System.Threading.Channels;
using RioJoy.Core.Serial;
namespace RioJoy.Core.Tests.Serial;
/// <summary>
/// <see cref="IRioTransport"/> whose first write blocks until <see cref="Open"/>
/// — lets a test park one writer mid-sequence while another tries to cut in
/// (write-lock and latest-wins assertions).
/// </summary>
internal sealed class GatedTransport : IRioTransport
{
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly object _armLock = new();
private bool _gateArmed = true;
public string Description => "gated";
/// <summary>Release the parked first write.</summary>
public void Open() => _gate.Release();
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
Task.FromResult(0);
public async Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
bool wait;
lock (_armLock)
{
wait = _gateArmed;
_gateArmed = false;
}
if (wait)
await _gate.WaitAsync(cancellationToken);
_writes.Writer.TryWrite((byte[])data.Clone());
}
/// <summary>Read the next write, failing if none arrives in time.</summary>
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
{
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
return await _writes.Reader.ReadAsync(cts.Token);
}
/// <summary>True when no further write has arrived.</summary>
public bool NoMoreWrites => !_writes.Reader.TryPeek(out _);
public void Dispose() { }
}