Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e108de89 | ||
|
|
23453667d2 | ||
|
|
1854afaa73 | ||
|
|
63bdb2c1da | ||
|
|
870a8a257d | ||
|
|
bdd30678e3 | ||
|
|
5befd1d511 | ||
|
|
38cf37c5e6 | ||
|
|
97caf124a6 | ||
|
|
9a792193f9 | ||
|
|
80222c5ea7 | ||
|
|
44b636ddd3 | ||
|
|
66c3cbdb57 | ||
|
|
94d32a1f06 | ||
|
|
ad7ac19ab2 | ||
|
|
d13d434e88 |
@@ -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 1–5 are implemented and tested (241 unit tests). The `RioGamepad` virtual
|
Phases 1–5, 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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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: `0x00–0x47`
|
||||||
|
(the 72 buttons), `0x50–0x5F` (keypad 0), `0x60–0x6F` (keypad 1). See
|
||||||
|
[PROTOCOL.md §5](PROTOCOL.md).
|
||||||
|
- **`<state>`** — either words: `[solid|slow|med|fast] off|dim|bright` (flash
|
||||||
|
defaults to `solid`), or a raw state byte `0x00–0x3F` (the `LampRequest`
|
||||||
|
state, [PROTOCOL.md §3](PROTOCOL.md)). `lamp 0x12 fast bright` = flash-fast
|
||||||
|
at full brightness. **The board sustains the blink** — one command starts a
|
||||||
|
flash, another (`solid dim`, `off`, …) ends it.
|
||||||
|
- **`plasma text`** — the rest of the line is the text, or quote it
|
||||||
|
(`"VIPER 1-1"`; quotes stripped, no escapes). Two leading *numeric* tokens
|
||||||
|
are a cursor position `x y`; omitted (or `0 0`) auto-fits and centers
|
||||||
|
(`PlasmaPosText`). To display something that starts with two numbers, quote
|
||||||
|
it. 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>` 0–31 (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` 0–127, `y` 0–31, 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` (0–255) the lamps are off; the rest of the range maps to
|
||||||
|
slow / med / fast flash at full brightness, per motor. Constant rumble costs
|
||||||
|
one lamp command (the board blinks on its own). net48 flavor only (the XP
|
||||||
|
flavor has no ViGEm).
|
||||||
|
|
||||||
|
## Client snippets
|
||||||
|
|
||||||
|
DCS-style `Export.lua` (a pipe opens as a file on Windows):
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w")
|
||||||
|
-- in your export tick:
|
||||||
|
if masterCaution then rio:write("lamp 0x12 fast bright\n")
|
||||||
|
else rio:write("lamp 0x12 off\n") end
|
||||||
|
rio:write('plasma text "' .. callsign .. '"\n')
|
||||||
|
rio:flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell, pipe (hand-testing on the cabinet):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$p = New-Object IO.Pipes.NamedPipeClientStream '.', 'riojoy-feedback', ([IO.Pipes.PipeDirection]::Out)
|
||||||
|
$p.Connect(2000)
|
||||||
|
$w = New-Object IO.StreamWriter $p, ([Text.Encoding]::GetEncoding(28591))
|
||||||
|
$w.WriteLine('lamp 0x12 fast bright'); $w.WriteLine('plasma text "VIPER 1-1"'); $w.Flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell, UDP (with `"UdpPort": 19910` configured):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$u = New-Object Net.Sockets.UdpClient
|
||||||
|
$b = [Text.Encoding]::GetEncoding(28591).GetBytes("lamp 0x12 fast bright`nplasma text 42 kills")
|
||||||
|
$u.Send($b, $b.Length, '127.0.0.1', 19910) | Out-Null
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation map
|
||||||
|
|
||||||
|
`src/RioJoy.Core/Feedback/`: `FeedbackLineParser` (grammar → `FeedbackCommand`),
|
||||||
|
`FeedbackLineBuffer` (bytes → lines), `FeedbackPipeServer` / `FeedbackUdpListener`
|
||||||
|
(transports), `CoalescingLampScheduler` (the rate governor — all feedback lamp
|
||||||
|
traffic goes through it, never straight to `ILampSink`), `FeedbackRouter`
|
||||||
|
(gating + ownership + plasma single-flight), `RumbleLampAdapter`, and
|
||||||
|
`FeedbackService` (the façade `RioCoordinator` owns). Tests mirror the layout in
|
||||||
|
`tests/RioJoy.Core.Tests/Feedback/`.
|
||||||
@@ -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 `0x00–0x47`, grouped into five MFD
|
||||||
|
clusters and four columns — the physical map is in
|
||||||
|
[OUTPUT-INTEGRATION.md](OUTPUT-INTEGRATION.md#address-map-functional-groups).
|
||||||
|
- **Two 4×4 keypads**: internal `0x50–0x5F`, external `0x60–0x6F` (key label →
|
||||||
|
address = base + hex digit; no lamps).
|
||||||
|
- **5 analog inputs** — joystick X/Y, throttle, left pedal, right pedal —
|
||||||
|
calibrated into **6 virtual axes** (X, Y, Z, Rx, Ry, Rz), each `0..32766`
|
||||||
|
with center `16383`.
|
||||||
|
|
||||||
|
## Per-button routing
|
||||||
|
|
||||||
|
Every mapped address carries one action (the `iRIO` word, PROTOCOL.md §5).
|
||||||
|
The editor exposes these as the **Action** kinds:
|
||||||
|
|
||||||
|
| Kind | What happens on press/release |
|
||||||
|
|---|---|
|
||||||
|
| **Keyboard** | key down/up by **scancode** (so DOS-era and raw-input games see it), with optional Shift/Ctrl/Alt held around it and the extended-key flag for nav keys |
|
||||||
|
| **Joystick** | virtual pad button 1–11 (or 1–96 on the RioGamepad HID) |
|
||||||
|
| **Hat** | the POV hat / D-pad direction (up/right/down/left; release = centered) |
|
||||||
|
| **Mouse** | relative move in clean 50-px steps (up/right/down/left) or left/right click — the legacy build's mixed-up move deltas are fixed in the port |
|
||||||
|
| **RIO command** | internal: axis recalibrations/resets, version/check request, diagnostic toggles — useful on a spare cockpit button so recalibration never needs the desktop |
|
||||||
|
| **Lit** flag | lamp follows the button (dim idle, bright pressed). Also marks the lamp as *profile-owned*, which shields it from the feedback endpoint (see OUTPUT-INTEGRATION.md) |
|
||||||
|
|
||||||
|
### The Xbox 360 button map
|
||||||
|
|
||||||
|
RIO joystick buttons are assigned in this fixed order — pick low numbers for
|
||||||
|
the game's most important actions:
|
||||||
|
|
||||||
|
| RIO joy button | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Pad button | A | B | X | Y | LB | RB | Back | Start | L3 | R3 | Guide |
|
||||||
|
|
||||||
|
The hat maps to the D-pad. Buttons past 11 are dropped by the pad — route
|
||||||
|
those to the keyboard instead. (On the RioGamepad HID all 96 buttons exist
|
||||||
|
natively and no mapping table applies.)
|
||||||
|
|
||||||
|
## Axes
|
||||||
|
|
||||||
|
### Calibration (per profile)
|
||||||
|
|
||||||
|
`Calibration` holds per-axis invert flags and `EnableZR`:
|
||||||
|
|
||||||
|
- **Joystick X/Y** auto-range from observed travel with a small deadzone.
|
||||||
|
- **Throttle (Z)** is the ratcheted lever; calibrated so the detent rest
|
||||||
|
position reads **0**, full forward `32766` — i.e. it is naturally
|
||||||
|
**unipolar**.
|
||||||
|
- **Pedals** feed Rx/Ry directly, or — with `EnableZR` — mix into a single
|
||||||
|
rudder axis: `Rz = 16383 − left/2 + right/2` (Rx/Ry then idle).
|
||||||
|
|
||||||
|
### Routing onto the pad (`AxisRouting`, JSON-only)
|
||||||
|
|
||||||
|
Each of the six axes picks a `Target` and `Mode`
|
||||||
|
(`src/RioJoy.Core/Output/AxisRoutingConfig.cs`; null section = the legacy
|
||||||
|
default routing):
|
||||||
|
|
||||||
|
| Axis | Default target | Conversion |
|
||||||
|
|---|---|---|
|
||||||
|
| X | LeftThumbX | Centered |
|
||||||
|
| Y | LeftThumbY | Centered |
|
||||||
|
| Z | LeftTrigger | trigger byte `value×255/32766` |
|
||||||
|
| Rx | RightThumbX | Centered |
|
||||||
|
| Ry | RightThumbY | Centered |
|
||||||
|
| Rz | RightTrigger | trigger byte |
|
||||||
|
|
||||||
|
Modes for thumb targets: **`Centered`** (`(value−16383)×2` → stick range) for
|
||||||
|
axes that rest mid-travel, **`UnipolarPositive`** (rest 0 = stick center,
|
||||||
|
32766 = stick max — only the upper half is used) for the ratcheted throttle.
|
||||||
|
`Target: "None"` suppresses an axis entirely.
|
||||||
|
|
||||||
|
**The triggers-are-buttons trap:** many games hard-bind the pad triggers to
|
||||||
|
fire/actions. If the throttle rides `LeftTrigger` (the default), advancing the
|
||||||
|
throttle *fires*. The shipped Descent profile
|
||||||
|
([`profiles/descent-d1x.json`](../profiles/descent-d1x.json)) is the worked
|
||||||
|
example: throttle → `RightThumbY` `UnipolarPositive`, rudder mix →
|
||||||
|
`RightThumbX`, pedals `None`, keeping both triggers free for the game.
|
||||||
|
|
||||||
|
## Choosing a strategy per game
|
||||||
|
|
||||||
|
- **Modern XInput game** — pad buttons + axes for the flight controls, keyboard
|
||||||
|
routing for the long tail (MFD pages, systems). Check the game's own binding
|
||||||
|
UI to see the pad.
|
||||||
|
- **DOS / emulated game (DOSBox, source ports)** — mostly keyboard routing (it
|
||||||
|
arrives as scancodes, which DOSBox maps cleanly); axes via the pad if the
|
||||||
|
emulator supports a controller, else map coarse throttle steps to keys.
|
||||||
|
- **Legacy DirectInput sim** — the x360 pad appears as a DirectInput device
|
||||||
|
too; if the game needs more than 11 buttons on the *stick itself*, prefer
|
||||||
|
keyboard routing or run the RioGamepad HID (96 native buttons).
|
||||||
|
- **Anything with a clickable cockpit** — mouse routing gives you cursor
|
||||||
|
nudges and clicks from cockpit buttons.
|
||||||
|
|
||||||
|
## Building the profile
|
||||||
|
|
||||||
|
1. **Create/edit** from the tray: *Edit profile*. The editor shows the cockpit
|
||||||
|
panel in its functional groups; click a button, set its label, action,
|
||||||
|
modifiers, and **Lit**, then Apply. Save writes the config.
|
||||||
|
2. **Live check**: with the RIO (or vRIO) connected, physical presses light the
|
||||||
|
panel and the axis gauges move — before any game is involved. The "Send
|
||||||
|
button output to the PC" toggle turns real keystroke injection on when you
|
||||||
|
want to test into an editor/notepad.
|
||||||
|
3. **Triggers**: comma-separated executable names that auto-activate the
|
||||||
|
profile when their window is foreground (`d1x-rebirth, descent`). Matching
|
||||||
|
is basename, case-insensitive, `.exe` optional. First matching profile
|
||||||
|
wins; DOSBox-hosted games all share the DOSBox exe name (rename per game or
|
||||||
|
switch manually); native games (Firestorm, Red Planet) go in
|
||||||
|
`NativeGameExecutables` instead — RIOJoy releases the ports for them.
|
||||||
|
4. **RIO port**: leave `(app default)`, or a COM name, or `pipe:vrio` for the
|
||||||
|
emulator.
|
||||||
|
5. **JSON-only settings** (edit `%APPDATA%\RIOJoy\config.json`): `AxisRouting`,
|
||||||
|
`Calibration` fine points, `PlasmaComPort`/`PlasmaGreeting`, and the
|
||||||
|
`Feedback` section (see FEEDBACK.md).
|
||||||
|
6. **Legacy import**: the `Import .ini` tray menu converts an original
|
||||||
|
`RIO.ini` (buttons, inverts, greeting). For programmatic install-time
|
||||||
|
handoff, see the next section.
|
||||||
|
|
||||||
|
## Shipping a profile with your game
|
||||||
|
|
||||||
|
Two models, chosen per deployment:
|
||||||
|
|
||||||
|
- **Pod bundle (production — cockpit cabinets):** the game's folder carries
|
||||||
|
its own RIOJoy copy + profile; nothing is registered anywhere. See
|
||||||
|
[Pod-bundled deployment](#pod-bundled-deployment-production) below.
|
||||||
|
- **Import into a resident RIOJoy (dev boxes):** hand a profile document to
|
||||||
|
the shared tray install, as follows.
|
||||||
|
|
||||||
|
A game (or its installer/launcher) hands its profile to RIOJoy as a
|
||||||
|
**single-profile JSON document** plus one command:
|
||||||
|
|
||||||
|
```
|
||||||
|
RioJoy.Tray.exe --import-profile <your-game-riojoy-profile.json>
|
||||||
|
```
|
||||||
|
|
||||||
|
The document is one `RioProfile` object — the shipped
|
||||||
|
[`profiles/descent-d1x.json`](../profiles/descent-d1x.json) is the reference
|
||||||
|
example. It must carry a `"Name"` (imports without one are rejected), and it
|
||||||
|
bundles everything in one payload: `Buttons`, `MatchExecutables` (the
|
||||||
|
triggers), `Calibration`, `AxisRouting`, `PlasmaGreeting`, `Feedback`,
|
||||||
|
overlay labels. Author it in the profile editor, then lift the profile object
|
||||||
|
out of `%APPDATA%\RIOJoy\config.json` into your distribution.
|
||||||
|
|
||||||
|
The import merges into the user's `%APPDATA%\RIOJoy\config.json` — created
|
||||||
|
with defaults if absent, all other content preserved. A profile with the
|
||||||
|
same name (case-insensitive) is **replaced in place**, so re-running the
|
||||||
|
import on a game update is idempotent and never disturbs other games'
|
||||||
|
profiles.
|
||||||
|
|
||||||
|
Contract for installers:
|
||||||
|
|
||||||
|
- **Check the exit code, not stdout** (the tray is a GUI-subsystem exe;
|
||||||
|
console output only appears when redirected): `0` imported, `1` failed,
|
||||||
|
`2` usage, `3` **RIOJoy is running**.
|
||||||
|
- **The tray must not be running** during import — a running tray holds the
|
||||||
|
config in memory and its own saves would silently discard the merge, so
|
||||||
|
the import refuses (exit 3) instead. Sequence: quit/skip the tray →
|
||||||
|
import → (re)launch. On launcher-managed cabinets (TeslaConsole owns the
|
||||||
|
RIOJoy lifecycle) a game's install step can import safely before the next
|
||||||
|
boot.
|
||||||
|
- **Per-user, per-session**: the config lives under the user's `%APPDATA%`
|
||||||
|
and the running-instance check is per-session — run the import in the
|
||||||
|
user's session, not as an elevated SYSTEM step.
|
||||||
|
|
||||||
|
Nothing else needs registering: once the tray starts with the merged config,
|
||||||
|
the profile's `MatchExecutables` auto-activates it whenever the game's window
|
||||||
|
is foreground (tray in Auto mode).
|
||||||
|
|
||||||
|
For games you control end-to-end, keep the profile document in the *game's*
|
||||||
|
repo as the source of truth — dxx-rebirth does this, and a RIOJoy test
|
||||||
|
(`ShippedDescentProfile_MatchesDxxRebirthReferenceCopy`) asserts the two
|
||||||
|
checkouts stay byte-identical so drift is caught in CI.
|
||||||
|
|
||||||
|
## Pod-bundled deployment (production)
|
||||||
|
|
||||||
|
On the pods (the cockpit cabinets) no resident RIOJoy runs at all. Each
|
||||||
|
podized game's install carries its own copy, built by:
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy\build-pod.ps1 -ProfileJson <your-game-profile.json>
|
||||||
|
```
|
||||||
|
|
||||||
|
That emits a **self-contained** drop-in (~8 MB on net48): `riojoy\` — the app,
|
||||||
|
a **portable** `config.json` beside the exe holding just this game's profile
|
||||||
|
(it wins over the per-user `%APPDATA%` store), and all RIOJoy prerequisites
|
||||||
|
(ViGEmBus; on the XP flavor .NET 4.0 + the RioGamepadXP driver) — plus two
|
||||||
|
entry points the game's package wires up:
|
||||||
|
|
||||||
|
- **`install-riojoy.bat`** — call from the game's `postinstall.bat`.
|
||||||
|
Self-elevating and **idempotent**: safe on every install, reinstall, and
|
||||||
|
update; it installs only what's absent and never removes anything. Put
|
||||||
|
**nothing** in the game's pre-uninstall — drivers stay in place by design,
|
||||||
|
since another podized game may share them and idle drivers are harmless.
|
||||||
|
- **`start-riojoy.bat`** — call from the game's launch script before the
|
||||||
|
game:
|
||||||
|
|
||||||
|
```
|
||||||
|
start "" "...\riojoy\app\RioJoy.Tray.exe" --profile "<Name>" --exit-with <game exe>
|
||||||
|
```
|
||||||
|
|
||||||
|
In pod mode **everything is explicit — there is no detection on either
|
||||||
|
side**. `--profile` activates the named profile immediately at startup, so
|
||||||
|
the virtual controller and the ports exist *before* the game launches and
|
||||||
|
enumerates input devices (foreground detection activates ~1 s after the
|
||||||
|
window appears — too late for startup enumeration, and the auto-switch
|
||||||
|
watcher never runs in this mode). On success RIOJoy signals the named event
|
||||||
|
**`RIOJoy.Tray.Ready`** — a launcher waits on that (with a timeout) instead
|
||||||
|
of guessing from device enumeration, and the failure modes stay legible:
|
||||||
|
|
||||||
|
| Launcher observes | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `RIOJoy.Tray.Ready` signaled | profile active; pad + ports exist — start the game |
|
||||||
|
| RIOJoy exited, code 4 | profile name not in the config (script typo) |
|
||||||
|
| RIOJoy exited, code 5 | activation failed — reason on stderr (port busy, bad endpoint) |
|
||||||
|
| no signal, still running | genuinely stuck — timeout and report |
|
||||||
|
|
||||||
|
The event is process-lifetime (it can never go stale); either side may create
|
||||||
|
it first — same name, manual-reset, both converge on one object. Batch-only
|
||||||
|
integrations without a launcher can simply order the script: `start`
|
||||||
|
RIOJoy, then the game — but a real launcher should wait on the event.
|
||||||
|
|
||||||
|
`--exit-with` handles the other end: once the game process has run and then
|
||||||
|
exited, RIOJoy tears itself down completely (ports released, wallpaper
|
||||||
|
restored, plasma blanked) and quits. If the game never appears within 60 s it
|
||||||
|
also quits, so a failed launch can't strand it. Back-to-back launches hand
|
||||||
|
over cleanly: a starting pod instance waits up to 15 s for the previous
|
||||||
|
game's copy to release the single-instance lock.
|
||||||
|
|
||||||
|
Properties that matter on a cabinet: each game pins the RIOJoy build it was
|
||||||
|
verified with (updating RIOJoy for a new game can't regress an old one);
|
||||||
|
native games simply don't bundle RIOJoy, so the COM ports are free for them
|
||||||
|
by construction; and every bundle is fully self-sufficient — drivers install
|
||||||
|
through the game's own postinstall, so a fresh pod needs no separate RIOJoy
|
||||||
|
provisioning pass. The bundled exe is still the full tray app: run it with no
|
||||||
|
arguments on the pod and you have the profile editor.
|
||||||
|
|
||||||
|
## Testing without hardware or game
|
||||||
|
|
||||||
|
- **vRIO** (`pipe:vrio`): click buttons on the emulator's panel and watch them
|
||||||
|
arrive — the editor lights up, the pad reacts.
|
||||||
|
- **joy.cpl** (Game Controllers): shows the virtual pad's axes/buttons moving.
|
||||||
|
- Keyboard routes: open Notepad, enable the editor's output toggle, press
|
||||||
|
cockpit buttons.
|
||||||
|
|
||||||
|
## Checklist for a new game
|
||||||
|
|
||||||
|
1. Find the game's real executable name (foreground window process) → Triggers.
|
||||||
|
2. Decide the axis story first: does the game hard-use triggers? If yes, route
|
||||||
|
the throttle to a thumb axis `UnipolarPositive` (copy the Descent pattern).
|
||||||
|
3. Map the few primary actions to pad buttons 1–11, everything else to
|
||||||
|
keyboard; mark cockpit-lit buttons **Lit**.
|
||||||
|
4. Set `EnableZR` if the game wants one rudder axis rather than two pedals.
|
||||||
|
5. Live-check in the editor, then in-game; bind a spare cockpit button to
|
||||||
|
*RIO command → recalibrate* for the cabinet.
|
||||||
|
6. Add the `Feedback` section if the game will drive lamps/plasma back
|
||||||
|
(OUTPUT-INTEGRATION.md).
|
||||||
@@ -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 `0x00–0x47`) 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 (`0x50–0x5F` internal, `0x60–0x6F` 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 | `0x00–0x07` | 4×2; top row `07 06 05 04`, bottom `03 02 01 00` |
|
||||||
|
| Lower Left MFD | `0x08–0x0F` | 4×2; top `0F 0E 0D 0C`, bottom `0B 0A 09 08` |
|
||||||
|
| Secondary column | `0x10–0x17` | vertical 8 |
|
||||||
|
| Screen column | `0x18–0x1F` | vertical 8 |
|
||||||
|
| Upper Middle MFD | `0x20–0x27` | 4×2; top `27 26 25 24`, bottom `23 22 21 20` |
|
||||||
|
| Upper Left MFD | `0x28–0x2F` | 4×2; top `2F 2E 2D 2C`, bottom `2B 2A 29 28` |
|
||||||
|
| Upper Right MFD | `0x30–0x37` | 4×2; top `37 36 35 34`, bottom `33 32 31 30` |
|
||||||
|
| Throttle column | `0x38–0x3F` | vertical 8 (`3D` Panic, `3F` Throttle) |
|
||||||
|
| Joystick cluster | `0x40–0x47` | `40` Main, `41–44` hat B/U/R/L, `45` Pinky, `46` Middle, `47` Upper |
|
||||||
|
| Internal keypad | `0x50–0x5F` | 4×4, **no lamps** |
|
||||||
|
| External keypad | `0x60–0x6F` | 4×4, **no lamps** |
|
||||||
|
|
||||||
|
(`0x48–0x4F` 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 10–20 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 0–127, y 0–31. 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 (0–31),
|
||||||
|
`x` = left **byte column** (0–15), `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 0–31; 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 0–31
|
||||||
|
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 `0x20–0x22`: 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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <addr> <state></c>).</summary>
|
||||||
|
Lamp,
|
||||||
|
|
||||||
|
/// <summary>Set every valid lamp address to a state (<c>lamp-all <state></c>).</summary>
|
||||||
|
LampAll,
|
||||||
|
|
||||||
|
/// <summary>Write text to the plasma display (<c>plasma text [x y] <text></c>).</summary>
|
||||||
|
PlasmaText,
|
||||||
|
|
||||||
|
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
|
||||||
|
PlasmaClear,
|
||||||
|
|
||||||
|
/// <summary>One full 128-px bitmap row (<c>plasma row <y> <32 hex digits></c>).</summary>
|
||||||
|
PlasmaRow,
|
||||||
|
|
||||||
|
/// <summary>Outlined box with a blanked interior (<c>plasma box <x> <y> <w> <h></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\<PipeName></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\<name></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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 0x48–0x4F 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"/> 0x00–0x47).</summary>
|
/// <summary>Address for a digital button event (<paramref name="index"/> 0x00–0x47).</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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 0–127).</summary>
|
||||||
|
public const int Columns = 128;
|
||||||
|
|
||||||
|
/// <summary>Panel height in pixel rows (0–31).</summary>
|
||||||
|
public const int Rows = 32;
|
||||||
|
|
||||||
|
/// <summary>Bytes per full bitmap row (128 px / 8, byte columns 0–15).</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"/> (0–31) and byte
|
||||||
|
/// column <paramref name="x"/> (0–15), <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; }
|
||||||
|
|||||||
@@ -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"/> (0–31):
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 <exe|pid></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 <name></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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user