Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cca7c77bd | ||
|
|
3512c89dca | ||
|
|
6967e66837 | ||
|
|
2f2438717a | ||
|
|
23dec8901b | ||
|
|
d904f739d0 | ||
|
|
25e3aa2ef5 | ||
|
|
46961d0cd1 | ||
|
|
30c1a85445 | ||
|
|
adfcee4bac | ||
|
|
cef6609ebd | ||
|
|
a76437ae16 | ||
|
|
8697f6c909 | ||
|
|
b0d245d056 | ||
|
|
83341f1ec0 | ||
|
|
2ef632a1c2 | ||
|
|
64cd933bb1 | ||
|
|
fd4dd3bb12 | ||
|
|
4ac688dfa5 | ||
|
|
b5e2ad35af | ||
|
|
6b02eb4d1b | ||
|
|
2e727a4c1c | ||
|
|
398ad1739d | ||
|
|
c6bec4ec2a | ||
|
|
9819949d65 | ||
|
|
38defb5246 | ||
|
|
e8e4522ed4 | ||
|
|
e793af1bb7 | ||
|
|
d4c2615cc5 | ||
|
|
a0f49c8a7c | ||
|
|
61e7d0f600 | ||
|
|
5dd7f78bc2 | ||
|
|
f752e2131a | ||
|
|
edbf3f8340 | ||
|
|
47df14cc61 | ||
|
|
1943b7f8eb | ||
|
|
894521206a | ||
|
|
f7c16f579d | ||
|
|
d7535f2a43 | ||
|
|
61778ec3f1 | ||
|
|
9a12b779bb | ||
|
|
56d951cee9 | ||
|
|
4bbdaff5ef | ||
|
|
af9ae439a5 | ||
|
|
3c04077317 | ||
|
|
1156eb8fe9 | ||
|
|
a8ec285b8e | ||
|
|
fcf26cfd15 | ||
|
|
52712f8409 | ||
|
|
2ecb617c09 | ||
|
|
63af7bde01 | ||
|
|
1ff0b16015 | ||
|
|
3b2af7b79a | ||
|
|
02abfa8a14 | ||
|
|
a9de26eff6 | ||
|
|
6ad1abc919 | ||
|
|
ae7a5d21ee | ||
|
|
39d79e1fb1 |
@@ -372,11 +372,20 @@ FodyWeavers.xsd
|
||||
# WDK / driver build outputs
|
||||
[Dd]river/**/[Xx]64/
|
||||
[Dd]river/package/
|
||||
# WDK build.exe output trees + logs (RioGamepadXP / any ddkbuild sample)
|
||||
[Dd]river/**/objfre_*/
|
||||
[Dd]river/**/objchk_*/
|
||||
[Dd]river/**/buildfre_*.log
|
||||
[Dd]river/**/buildchk_*.log
|
||||
[Dd]river/**/build.err
|
||||
[Dd]river/**/build.wrn
|
||||
*.cer
|
||||
*.cat
|
||||
*.pvk
|
||||
# Local run-time config that shouldn't be versioned
|
||||
*.local.json
|
||||
# Firmware mash-test session logs (tools/RioSerialMonitor --mash)
|
||||
riomash-*.log
|
||||
|
||||
# Generated overlay/editor previews (regenerable, not committed)
|
||||
docs/reference/customBackground/riojoy-preview.png
|
||||
|
||||
@@ -22,6 +22,7 @@ Red Planet — talk to the RIO directly and do not use this app.)
|
||||
| [`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/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
|
||||
| _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 |
|
||||
| [`legacy/`](legacy/) | Original C++/vJoy implementation, kept as reference |
|
||||
|
||||
@@ -51,3 +52,16 @@ auto-switch), and the HID report packer that matches the driver's wire format.
|
||||
Remaining work is **on-cabinet** (real RIO serial/axis/plasma/auto-switch
|
||||
verification) plus packaging (Phase 6) and the profile editor + overlay
|
||||
generator (Phase 7). See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
|
||||
|
||||
## Testing without hardware: vRIO over a named pipe
|
||||
|
||||
The [vRIO](https://gitea.mysticmachines.com/VWE/VRIO) device emulator can stand
|
||||
in for the real board with no com0com pair: anywhere a COM port name is
|
||||
configured — a profile's `RioComPort`, the app-wide `DefaultRioComPort`, or the
|
||||
`RioSerialMonitor` `[port]` argument — the endpoint `pipe:vrio` connects to
|
||||
vRIO's `\\.\pipe\vrio` instead (vRIO must have its pipe endpoint open). Serial
|
||||
bytes and modem lines (including the DTR reset pulse on open) travel as typed
|
||||
frames over the pipe; the contract lives in
|
||||
[`src/RioJoy.Core/Serial/PipeFraming.cs`](src/RioJoy.Core/Serial/PipeFraming.cs)
|
||||
on this side and vRIO's `PipeFraming.cs` / the DOSBox-X fork's
|
||||
`serialnamedpipe.h` on the others.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
RIOJoy - cockpit interface for the VWE pods
|
||||
============================================
|
||||
|
||||
This package installs RIOJoy, the program that connects the cockpit's RIO
|
||||
hardware (joystick, throttle, pedals, 96 lighted buttons, plasma display)
|
||||
to games that don't know about the cockpit. It works on two kinds of
|
||||
Windows:
|
||||
|
||||
* Windows 10 / 11 (64-bit) - full app, appears to games as an Xbox 360
|
||||
controller (ViGEmBus driver, included)
|
||||
* Windows XP SP3 (32-bit) - full runtime + mapping editor; all
|
||||
prerequisites are included for offline
|
||||
install (.NET 4.0 + its async update)
|
||||
|
||||
The installer detects which Windows it is running on and installs the
|
||||
right pieces automatically. Everything needed is inside this package -
|
||||
no internet connection is required.
|
||||
|
||||
|
||||
WHICH FILE DO I RUN?
|
||||
--------------------
|
||||
|
||||
install.bat <-- RUN THIS on a normal, standalone computer.
|
||||
Right-click -> "Run as administrator" (on XP, log
|
||||
in as an Administrator user first). It installs
|
||||
the prerequisites and puts RIOJoy shortcuts in the
|
||||
Start Menu and on the desktop. RIOJoy does NOT
|
||||
auto-start with Windows - launch it from the
|
||||
shortcut when you want the cockpit active.
|
||||
|
||||
postinstall.bat -- Used by the arcade cabinet's launcher software
|
||||
(TeslaConsole). It runs automatically after the
|
||||
cabinet extracts this package; you do not need to
|
||||
run it by hand. (It also deletes install.bat and
|
||||
this README so the cabinet's games folder stays
|
||||
clean.)
|
||||
|
||||
|
||||
WHAT GETS INSTALLED
|
||||
-------------------
|
||||
|
||||
RIOJoy\app\ the program for Windows 10/11
|
||||
RIOJoy\app-xp\ the program for Windows XP
|
||||
RIOJoy\vendor\ third-party prerequisites (ViGEmBus for 10/11;
|
||||
.NET 4.0 + KB2468871 + the RioGamepadXP joystick
|
||||
driver for XP)
|
||||
|
||||
After installing, start RIOJoy and look for its icon in the system tray
|
||||
(near the clock). Right-click the icon to pick a profile, edit button
|
||||
mappings, or send commands to the cockpit hardware. Full deployment
|
||||
notes for cabinet operators are in RIOJoy\README-DEPLOY.txt.
|
||||
|
||||
|
||||
REMOVING RIOJOY
|
||||
---------------
|
||||
|
||||
Run RIOJoy\pre-uninstall.bat as administrator, then delete the folder
|
||||
you extracted. On a standalone computer also delete the RIOJoy shortcuts
|
||||
from the Start Menu and desktop.
|
||||
+57
-12
@@ -1,8 +1,11 @@
|
||||
<#
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build the RIOJoy cockpit deployment zip: publish the tray app self-contained,
|
||||
bundle the signed ViGEmBus installer + the post-install scripts, and zip it for
|
||||
TeslaConsole. The zip extracts directly into a single directory (C:\games\RIOJOY).
|
||||
Build the UNIVERSAL RIOJoy deployment zip (PLAN.md §8D): one archive that
|
||||
deploys on Windows 10/11 (net48 x64, ViGEmBus) AND Windows XP SP3 (net40
|
||||
x86, offline prerequisites). Two install entry points sit at the zip root:
|
||||
postinstall.bat (TeslaConsole, unattended, cleans the root after itself)
|
||||
and install.bat (standalone computers, adds shortcuts). README.txt at the
|
||||
root explains it to a human installer.
|
||||
|
||||
.PARAMETER VigemInstaller
|
||||
Path to the signed ViGEmBus installer to bundle. If omitted, looks in
|
||||
@@ -11,6 +14,12 @@
|
||||
Where to write the zip (default: dist, relative to the repo root).
|
||||
.PARAMETER Configuration
|
||||
Build configuration (default: Release).
|
||||
|
||||
.NOTES
|
||||
XP offline prerequisites are picked up from deploy\vendor\xp\ when present
|
||||
(dotNetFx40_Full_x86_x64.exe, NDP40-KB2468871-v2-x86.exe, and the
|
||||
RioGamepadXP driver once Phase 8B lands); missing ones produce warnings,
|
||||
not failures, so modern-only builds still work.
|
||||
#>
|
||||
param(
|
||||
[string]$VigemInstaller,
|
||||
@@ -47,14 +56,23 @@ try {
|
||||
$pkgDir = Join-Path $staging 'RIOJoy'
|
||||
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
|
||||
|
||||
# 1. Publish the tray app into riojoy\app (net48, framework-dependent — relies on
|
||||
# the in-box .NET Framework 4.8 present on every Windows 10/11 machine).
|
||||
# 1. Publish the tray app into RIOJoy\app (net48 x64, framework-dependent — relies
|
||||
# on the in-box .NET Framework 4.8 present on every Windows 10/11 machine).
|
||||
$appOut = Join-Path $pkgDir 'app'
|
||||
Write-Host "Publishing RioJoy.Tray ($Configuration, net48 framework-dependent)..."
|
||||
Write-Host "Publishing RioJoy.Tray ($Configuration, net48 x64 framework-dependent)..."
|
||||
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
|
||||
-c $Configuration -p:DebugType=none `
|
||||
-c $Configuration -f net48 -p:DebugType=none `
|
||||
-o $appOut | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish (net48) failed.' }
|
||||
|
||||
# 1a. Publish the Windows XP flavor into RIOJoy\app-xp (net40 x86,
|
||||
# framework-dependent on the bundled .NET 4.0).
|
||||
$appXpOut = Join-Path $pkgDir 'app-xp'
|
||||
Write-Host "Publishing RioJoy.Tray ($Configuration, net40 x86 for Windows XP)..."
|
||||
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
|
||||
-c $Configuration -f net40 -p:DebugType=none `
|
||||
-o $appXpOut | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish (net40) failed.' }
|
||||
|
||||
# 1b. Trim native SkiaSharp variants the x64 pod never uses. SkiaSharp's net48
|
||||
# loader finds the native in either the app root or an arch subfolder (both
|
||||
@@ -71,19 +89,46 @@ try {
|
||||
Get-ChildItem $appOut -Filter '*.dylib' -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
Get-ChildItem $appOut -Filter '*.so' -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
|
||||
# 2. Bundle the signed ViGEmBus installer into riojoy\vendor.
|
||||
# 2. Bundle the signed ViGEmBus installer into RIOJoy\vendor, and the XP
|
||||
# offline prerequisites into RIOJoy\vendor\xp (warn if absent).
|
||||
$vendorOut = Join-Path $pkgDir 'vendor'
|
||||
New-Item -ItemType Directory -Force -Path $vendorOut | Out-Null
|
||||
Copy-Item $VigemInstaller $vendorOut
|
||||
|
||||
# 3. Install/uninstall scripts + readme + version stamp inside RIOJoy\;
|
||||
# postinstall.bat at root, pre-uninstall.bat inside the payload folder.
|
||||
$vendorXpSrc = Join-Path $PSScriptRoot 'vendor\xp'
|
||||
$vendorXpOut = Join-Path $vendorOut 'xp'
|
||||
New-Item -ItemType Directory -Force -Path $vendorXpOut | Out-Null
|
||||
$xpWanted = @(
|
||||
@{ Name = 'dotNetFx40_Full_x86_x64.exe'; What = '.NET Framework 4.0 offline installer' },
|
||||
@{ Name = 'NDP40-KB2468871-v2-x86.exe'; What = '.NET 4.0 update KB2468871 (required by Bcl.Async)' },
|
||||
@{ Name = 'RioGamepadXP.inf'; What = 'RioGamepadXP driver INF (Phase 8B)' },
|
||||
@{ Name = 'RioGamepadXP.sys'; What = 'RioGamepadXP driver binary (Phase 8B)' },
|
||||
@{ Name = 'devcon.exe'; What = 'devcon (creates the root-enumerated driver devnode on XP)' }
|
||||
)
|
||||
foreach ($item in $xpWanted) {
|
||||
$src = Join-Path $vendorXpSrc $item.Name
|
||||
if (Test-Path $src) {
|
||||
Copy-Item $src $vendorXpOut
|
||||
Write-Host "XP vendor: $($item.Name)"
|
||||
} else {
|
||||
Write-Warning "XP vendor missing: $($item.Name) - $($item.What). XP installs will warn/skip."
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Install/uninstall scripts + readmes + version stamp. Payload scripts in
|
||||
# RIOJoy\; the two entry points (postinstall.bat, install.bat) and the
|
||||
# human README at the zip root (postinstall.bat deletes install.bat +
|
||||
# README.txt after a cabinet deploy to keep C:\games clean).
|
||||
Copy-Item (Join-Path $PSScriptRoot 'install-rio.ps1') $pkgDir
|
||||
Copy-Item (Join-Path $PSScriptRoot 'uninstall-rio.ps1') $pkgDir
|
||||
Copy-Item (Join-Path $PSScriptRoot 'pre-uninstall.bat') $pkgDir
|
||||
Copy-Item (Join-Path $PSScriptRoot 'install-core.bat') $pkgDir
|
||||
Copy-Item (Join-Path $PSScriptRoot 'make-shortcut.vbs') $pkgDir
|
||||
Copy-Item (Join-Path $PSScriptRoot 'README-DEPLOY.txt') $pkgDir
|
||||
Set-Content -Path (Join-Path $pkgDir 'VERSION.txt') -Value "RIOJoy $version" -Encoding utf8
|
||||
Copy-Item (Join-Path $PSScriptRoot 'postinstall.bat') $staging
|
||||
Copy-Item (Join-Path $PSScriptRoot 'install.bat') $staging
|
||||
Copy-Item (Join-Path $PSScriptRoot 'README.txt') $staging
|
||||
|
||||
# 4. Zip the package contents (so it extracts straight into C:\games\RIOJOY).
|
||||
$outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
@echo off
|
||||
rem ===========================================================================
|
||||
rem RIOJoy shared install core - called by postinstall.bat (cabinet /
|
||||
rem TeslaConsole) and install.bat (freestanding). NOT run directly.
|
||||
rem Detects the OS and installs the matching flavor's prerequisites:
|
||||
rem Windows XP (5.1) -> .NET 4.0 + KB2468871 from RIOJoy\vendor\xp,
|
||||
rem RioGamepadXP driver INF when bundled;
|
||||
rem app = RIOJoy\app-xp (net40 x86)
|
||||
rem Windows 10/11 -> RIOJoy\install-rio.ps1 (ViGEmBus etc.);
|
||||
rem app = RIOJoy\app (net48 x64)
|
||||
rem Pure cmd.exe on the XP path (XP has no in-box PowerShell). Idempotent.
|
||||
rem Sets RIOJOY_APPDIR for the caller (shortcut creation).
|
||||
rem ===========================================================================
|
||||
setlocal EnableExtensions
|
||||
set CORE_RC=0
|
||||
set PKGROOT=%~dp0
|
||||
rem PKGROOT = ...\RIOJoy\ (this file lives inside the payload folder)
|
||||
|
||||
ver | findstr /C:"Version 5.1" >nul
|
||||
if %errorlevel% equ 0 goto xp
|
||||
|
||||
rem --- Windows 10/11 ---------------------------------------------------------
|
||||
echo Detected modern Windows - installing the net48 x64 flavor prerequisites.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%PKGROOT%install-rio.ps1"
|
||||
set CORE_RC=%errorlevel%
|
||||
endlocal & set "RIOJOY_APPDIR=%~dp0app" & exit /b %CORE_RC%
|
||||
|
||||
:xp
|
||||
rem --- Windows XP SP3 ---------------------------------------------------------
|
||||
echo Detected Windows XP - installing the net40 x86 flavor prerequisites.
|
||||
|
||||
rem .NET Framework 4.0 (XP's ceiling; required by the app).
|
||||
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Install 2>nul | findstr /C:"0x1" >nul
|
||||
if %errorlevel% equ 0 (
|
||||
echo .NET Framework 4.0 already installed.
|
||||
) else (
|
||||
if exist "%PKGROOT%vendor\xp\dotNetFx40_Full_x86_x64.exe" (
|
||||
echo Installing .NET Framework 4.0 - this takes several minutes...
|
||||
"%PKGROOT%vendor\xp\dotNetFx40_Full_x86_x64.exe" /q /norestart
|
||||
if errorlevel 3011 echo .NET install requests a reboot - reboot before first use.
|
||||
) else (
|
||||
echo ERROR: vendor\xp\dotNetFx40_Full_x86_x64.exe is missing from the package.
|
||||
set CORE_RC=1
|
||||
goto xpdone
|
||||
)
|
||||
)
|
||||
|
||||
rem KB2468871 - required by the async runtime (Microsoft.Bcl.Async).
|
||||
rem The update no-ops quickly when already applied, so run unconditionally.
|
||||
if exist "%PKGROOT%vendor\xp\NDP40-KB2468871-v2-x86.exe" (
|
||||
echo Applying .NET 4.0 update KB2468871...
|
||||
"%PKGROOT%vendor\xp\NDP40-KB2468871-v2-x86.exe" /q /norestart
|
||||
) else (
|
||||
echo WARNING: vendor\xp\NDP40-KB2468871-v2-x86.exe not bundled - the app
|
||||
echo will not run without KB2468871. Install it before first use.
|
||||
)
|
||||
|
||||
rem RioGamepadXP virtual-joystick driver. Root-enumerated, so it must be
|
||||
rem created with devcon (rundll32/InstallHinfSection would not make the
|
||||
rem devnode). devcon is idempotent: re-running "install" updates in place.
|
||||
if exist "%PKGROOT%vendor\xp\RioGamepadXP.inf" (
|
||||
if exist "%PKGROOT%vendor\xp\devcon.exe" (
|
||||
echo Installing the RioGamepadXP virtual joystick driver...
|
||||
"%PKGROOT%vendor\xp\devcon.exe" install "%PKGROOT%vendor\xp\RioGamepadXP.inf" root\RioGamepadXP
|
||||
if errorlevel 1 echo WARNING: driver install returned an error - check Device Manager.
|
||||
) else (
|
||||
echo WARNING: vendor\xp\devcon.exe missing - cannot create the driver devnode.
|
||||
)
|
||||
) else (
|
||||
echo Note: RioGamepadXP driver not bundled - joystick output is
|
||||
echo unavailable on XP; keyboard/mouse, lamps and plasma still work.
|
||||
)
|
||||
|
||||
:xpdone
|
||||
endlocal & set "RIOJOY_APPDIR=%~dp0app-xp" & exit /b %CORE_RC%
|
||||
@@ -1,4 +1,4 @@
|
||||
<#
|
||||
<#
|
||||
.SYNOPSIS
|
||||
RIOJoy cockpit post-install. Installs the signed ViGEmBus virtual-controller
|
||||
driver (if absent). Must run elevated (postinstall.bat elevates for you).
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
@echo off
|
||||
rem ===========================================================================
|
||||
rem RIOJoy standalone install entry point - for a computer WITHOUT the
|
||||
rem TeslaConsole launcher. Extract the whole zip somewhere permanent (e.g.
|
||||
rem C:\games), then run this as administrator. Installs the prerequisites
|
||||
rem for this Windows version (XP or 10/11) via the shared install core and
|
||||
rem creates Start Menu + desktop shortcuts to the right flavor of the app.
|
||||
rem Nothing auto-starts: launch RIOJoy from the shortcut when you want it.
|
||||
rem See README.txt for details.
|
||||
rem ===========================================================================
|
||||
setlocal
|
||||
title RIOJoy install
|
||||
|
||||
rem --- elevate if we are not already administrator --------------------------
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
rem "if errorlevel" evaluates at run time (a %var% here would be stale).
|
||||
ver | findstr /C:"Version 5.1" >nul
|
||||
if not errorlevel 1 (
|
||||
echo This installer must run as an Administrator user.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Requesting administrator privileges...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
call "%~dp0RIOJoy\install-core.bat"
|
||||
set RC=%errorlevel%
|
||||
if %RC% neq 0 goto done
|
||||
|
||||
rem --- shortcuts (Start Menu + desktop) --------------------------------------
|
||||
rem RIOJOY_APPDIR is set by install-core.bat to app (modern) or app-xp (XP).
|
||||
set EXE=%RIOJOY_APPDIR%\RioJoy.Tray.exe
|
||||
if not exist "%EXE%" (
|
||||
echo ERROR: app executable not found: %EXE%
|
||||
set RC=1
|
||||
goto done
|
||||
)
|
||||
echo Creating shortcuts...
|
||||
cscript //nologo "%~dp0RIOJoy\make-shortcut.vbs" "%EXE%" "RIOJoy" "%RIOJOY_APPDIR%"
|
||||
|
||||
:done
|
||||
echo.
|
||||
if %RC% neq 0 (
|
||||
echo install FAILED with exit code %RC%.
|
||||
) else (
|
||||
echo install completed. Launch RIOJoy from the Start Menu or desktop shortcut.
|
||||
)
|
||||
pause
|
||||
exit /b %RC%
|
||||
@@ -0,0 +1,27 @@
|
||||
' make-shortcut.vbs <targetExe> <name> <workingDir>
|
||||
' Creates Start Menu (all users) and desktop shortcuts. VBScript so the same
|
||||
' helper works on Windows XP (no PowerShell) and Windows 10/11.
|
||||
Option Explicit
|
||||
Dim shell, fso, target, name, workdir, places, place, lnk
|
||||
If WScript.Arguments.Count < 3 Then
|
||||
WScript.Echo "usage: make-shortcut.vbs <targetExe> <name> <workingDir>"
|
||||
WScript.Quit 1
|
||||
End If
|
||||
target = WScript.Arguments(0)
|
||||
name = WScript.Arguments(1)
|
||||
workdir = WScript.Arguments(2)
|
||||
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
|
||||
places = Array(shell.SpecialFolders("AllUsersPrograms"), shell.SpecialFolders("Desktop"))
|
||||
For Each place In places
|
||||
If fso.FolderExists(place) Then
|
||||
Set lnk = shell.CreateShortcut(fso.BuildPath(place, name & ".lnk"))
|
||||
lnk.TargetPath = target
|
||||
lnk.WorkingDirectory = workdir
|
||||
lnk.Description = "RIOJoy cockpit interface"
|
||||
lnk.Save
|
||||
WScript.Echo " shortcut: " & fso.BuildPath(place, name & ".lnk")
|
||||
End If
|
||||
Next
|
||||
+17
-4
@@ -1,8 +1,10 @@
|
||||
@echo off
|
||||
rem ===========================================================================
|
||||
rem RIOJoy cockpit post-install entry point (run by TeslaConsole).
|
||||
rem Sits beside the 'RIOJoy' payload folder; elevates, then runs
|
||||
rem RIOJoy\install-rio.ps1.
|
||||
rem RIOJoy cockpit post-install entry point (run by TeslaConsole after the
|
||||
rem package is extracted into C:\games). Sits beside the 'RIOJoy' payload
|
||||
rem folder; elevates, runs the shared install core (which picks the XP or
|
||||
rem modern flavor), then removes the standalone-install files so only
|
||||
rem launcher-managed files stay at the C:\games root.
|
||||
rem ===========================================================================
|
||||
setlocal
|
||||
title RIOJoy post-install
|
||||
@@ -10,6 +12,12 @@ title RIOJoy post-install
|
||||
rem --- elevate if we are not already administrator --------------------------
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
rem "if errorlevel" evaluates at run time (a %var% here would be stale).
|
||||
ver | findstr /C:"Version 5.1" >nul
|
||||
if not errorlevel 1 (
|
||||
echo This installer must run as an Administrator user.
|
||||
exit /b 1
|
||||
)
|
||||
echo Requesting administrator privileges...
|
||||
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
exit /b 0
|
||||
@@ -17,9 +25,14 @@ if %errorlevel% neq 0 (
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0RIOJoy\install-rio.ps1"
|
||||
call "%~dp0RIOJoy\install-core.bat"
|
||||
set RC=%errorlevel%
|
||||
|
||||
rem --- cleanup: cabinet deploys keep the C:\games root clean ----------------
|
||||
rem (del tolerates already-missing files, so re-runs stay idempotent)
|
||||
if exist "%~dp0install.bat" del /q "%~dp0install.bat"
|
||||
if exist "%~dp0README.txt" del /q "%~dp0README.txt"
|
||||
|
||||
echo.
|
||||
if %RC% neq 0 (
|
||||
echo postinstall FAILED with exit code %RC%.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<#
|
||||
<#
|
||||
.SYNOPSIS
|
||||
RIOJoy cockpit pre-uninstall cleanup. Reverses install-rio.ps1: stops the
|
||||
running tray app, removes the logon entry, optionally uninstalls ViGEmBus,
|
||||
|
||||
+139
-2
@@ -206,6 +206,21 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr
|
||||
- Joystick output now uses the real `HidFeederJoystickSink` when the driver is
|
||||
present (verified end-to-end); `NullJoystickSink` remains only as the
|
||||
no-driver fallback.
|
||||
- **Per-profile ViGEm axis routing — code-complete ✅.** `RioProfile.AxisRouting`
|
||||
(`Output/AxisRoutingConfig`: one route per calibrated axis = pad target —
|
||||
four thumbs, two triggers, or None — + output mode; shared types with no
|
||||
ViGEm references, so net40 keeps compiling) re-routes the six axes on the
|
||||
Xbox 360 pad. Modes: `Centered` (legacy bipolar) and `UnipolarPositive`
|
||||
(calibrated 0 → thumb center — for the ratcheted unipolar throttle). Null/
|
||||
absent = the historical fixed routing, so existing profiles are untouched.
|
||||
Resolution + conversion is the pure unit-tested `AxisRouter`;
|
||||
`ViGEmJoystickSink.SetRouting` applies it thread-safely and neutralizes all
|
||||
thumbs/triggers each profile switch (no stale axes); wired in
|
||||
`RioCoordinator.Activate` beside the calibrator config. First consumer:
|
||||
`profiles/descent-d1x.json` (throttle→RightThumbY unipolar, rudder→
|
||||
RightThumbX, triggers untouched — DXX fires on the LT/RT axis-buttons).
|
||||
The HID feeder (native 6-axis report) intentionally ignores routing.
|
||||
⏳ Remaining: on-cabinet throttle/rudder feel check (first Descent flight).
|
||||
- ⏳ **Remaining:** full on-cabinet verification of the auto-switch +
|
||||
acquire/release lifecycle against real RIO hardware.
|
||||
|
||||
@@ -254,8 +269,18 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
|
||||
generates + applies the wallpaper on profile activation when
|
||||
`AppConfig.OverlayTemplatePath` is set (best-effort, off by default, never breaks
|
||||
activation). The live `SystemParametersInfo` apply changes a user setting, so it
|
||||
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
|
||||
wallpaper when going dormant.
|
||||
is gated behind config and not exercised by tests. **Restore-on-dormant — done ✅:**
|
||||
`RioCoordinator` captures the user's own wallpaper (`WallpaperApplier.GetCurrent`)
|
||||
the first time it overrides it, and puts it back (`WallpaperApplier.Restore`) on
|
||||
every `GoDormant` and on `Dispose` — so idle/native-game/exit return the desktop
|
||||
to what the user had. Capture is once-per-override (switching between cockpit
|
||||
profiles never records a cockpit wallpaper as the "previous"); only engages when
|
||||
`OverlayTemplatePath` is set. Verified 2026-07-19 on Win11 by a capture→apply→
|
||||
restore round-trip against the compiled `WallpaperApplier` — including the
|
||||
empty-wallpaper case (`GetCurrent`→`''`, apply, `Restore("")` clears back to the
|
||||
original solid-color desktop). Known gap: a hard crash between apply and restore
|
||||
leaves the cockpit wallpaper (SPIF_UPDATEINIFILE persists it) — a future
|
||||
crash-recovery could persist the saved path to config.
|
||||
- **Wallpaper maker — done ✅.** `RioJoy.Tray/Editor/WallpaperMakerForm` (tray →
|
||||
"Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline:
|
||||
it renders the profile's wallpaper live on the template base image, outlines all
|
||||
@@ -294,6 +319,118 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
|
||||
- To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs.
|
||||
live overlay (e.g. lit-button highlighting mirroring lamp state).
|
||||
|
||||
### Phase 8 — Windows XP compatibility (dual-target) — in progress
|
||||
(8A/8C/8D done ✅; remaining: 8B driver + 8E XP-VM/cabinet verification)
|
||||
Bring RIOJoy back to the original XP-era cabinets (x86, XP SP3) **without
|
||||
regressing Windows 10/11**. Strategy: one codebase, two flavors, **one
|
||||
universal deployment archive**. The mapping editor ships everywhere
|
||||
(decided); only wallpaper *generation* (SkiaSharp) stays modern-only —
|
||||
XP consumes pre-rendered wallpapers.
|
||||
|
||||
| | Windows 10/11 (unchanged) | Windows XP SP3 |
|
||||
|---|---|---|
|
||||
| TFM / arch | net48, x64 | **net40, x86** (.NET 4.0 is XP's ceiling; 4.5+ needs Vista) |
|
||||
| Virtual joystick | ViGEm → RioGamepad → none (unchanged) | **RioGamepadXP.sys** — our own thin WDM HID minidriver (same feeder contract) — full 6-axis/96-button fidelity |
|
||||
| Overlay render | SkiaSharp (generate + apply) | consume pre-rendered wallpaper only (**PNG→BMP** — XP's `SystemParametersInfo` takes BMP only) |
|
||||
| Editor | full (incl. wallpaper maker) | **mapping editor included** (decided; pure WinForms/GDI+); wallpaper maker gated (Skia) |
|
||||
| JSON | System.Text.Json → **Newtonsoft 13** | Newtonsoft 13 (STJ needs net461+; one serializer for both flavors) |
|
||||
| Install scripts | PowerShell | **.bat only** (XP has no in-box PowerShell) |
|
||||
|
||||
- **8A — Core retarget — done ✅** (`net48;net40` multi-target): de-Span the
|
||||
protocol/serial layer (≈20 uses / 11 files → `byte[]`/`ArraySegment`;
|
||||
System.Memory doesn't go below net45, and at 9600 baud Span buys nothing);
|
||||
swap System.Text.Json → Newtonsoft in `ConfigStore`/`OverlayTemplateStore`
|
||||
(both TFMs, so the config format can't drift); async on net40 via
|
||||
**Microsoft.Bcl.Async** + a `Compat/TaskCompat` shim (6 call sites:
|
||||
Task.Run/Delay/WhenAny/WhenAll → TaskEx; XP prereq: KB2468871, bundle it);
|
||||
shim the one `HashCode.Combine`; `#if`-gate the net48-only sinks
|
||||
(`ViGEmJoystickSink`, `HidFeederJoystickSink`, `Hid/`).
|
||||
*Risk fallback:* if Bcl.Async misbehaves on real XP, the receive loop
|
||||
reverts to a dedicated thread (the legacy `CommWatchProc` shape) for net40.
|
||||
- **8B — RioGamepadXP.sys — built ✅ (static; XP bring-up pending in 8E).**
|
||||
Source in [`driver/RioGamepadXP/`](../driver/RioGamepadXP/): a WDM HID
|
||||
minidriver (`HidRegisterMinidriver`, **polled mode** so there's no pending-
|
||||
IRP/cancel machinery) presenting the 6-axis/hat/96-button joystick, plus a
|
||||
named sideband control device (`\\.\RioGamepadXP`) that takes
|
||||
`IOCTL_RIO_SUBMIT_REPORT` — the **identical `Public.h` contract** as the
|
||||
modern driver (same 25-byte report, VID/PID). A dispatch wrapper routes the
|
||||
control device's CREATE/CLOSE/DEVICE_CONTROL to us and forwards the HID FDO's
|
||||
to hidclass. Builds with WDK 7.1.0 (`build.cmd` → `setenv … fre x86 WXP
|
||||
no_oacr`) to `RioGamepadXP.sys` (x86, subsystem 5.01). XP enforces no kernel
|
||||
signing, so install is unsigned; the device is root-enumerated so it needs
|
||||
**devcon** (`devcon install RioGamepadXP.inf root\RioGamepadXP`, also built
|
||||
from the WDK) rather than InstallHinfSection. The net40 feeder opens the
|
||||
driver by name (`#if NET40` branch in `HidFeederJoystickSink`); XP can't
|
||||
register a device interface on a bare control device (no PDO), the one
|
||||
contract nuance. Bundled into the package's `vendor\xp\`. Precedent: the
|
||||
original FASA `tasgame.sys` was itself an XP HID minidriver (docs/Win32RIO/,
|
||||
analyzed); ours stays thin with serial in user mode. No third-party virtual
|
||||
joystick (vJoy/PPJoy unmaintained). Acquisition order: net48 ViGEm →
|
||||
RioGamepad → Null (unchanged); net40 RioGamepadXP → Null. ⏳ **8E:** the
|
||||
driver compiles clean but is **unverified at runtime** — needs an XP target
|
||||
to confirm joy.cpl enumeration + report flow. *Staging:* the app is useful
|
||||
before the driver — milestone 1 ships keyboard/mouse + lamps + plasma
|
||||
(joystick = Null sink) if the driver isn't present.
|
||||
- **8C — Tray on net40/x86 — done ✅:** multi-target `RioJoy.Tray` (net40 drops the
|
||||
ViGEm + RioJoy.Overlay references); gate `WallpaperMakerForm` + overlay
|
||||
generation; `WallpaperApplier` converts PNG→BMP via GDI+ before
|
||||
`SystemParametersInfo` (harmless on 10/11, required on XP); profile editor
|
||||
stays (Segoe UI falls back to Tahoma). XP cabinets consume wallpapers
|
||||
pre-rendered on a modern machine (`RioProfile.WallpaperPath` travels with
|
||||
the config).
|
||||
- **8D — Packaging — done ✅ (one universal archive, two install entry
|
||||
points).** `build-package.ps1` produces a single `RIOJoy-<ver>.zip` (~75 MB
|
||||
with the offline XP redistributables) that deploys on **both** XP and 10/11;
|
||||
the RioGamepadXP driver files bundle automatically once 8B lands
|
||||
(warn/skip until then). The shared OS-detecting install logic lives in
|
||||
`RIOJoy\install-core.bat` (pure cmd on the XP path); shortcuts via
|
||||
`make-shortcut.vbs` (works on XP and 10/11 alike):
|
||||
- Layout: `RIOJoy\app\` (net48 x64) + `RIOJoy\app-xp\` (net40 x86) +
|
||||
`RIOJoy\vendor\` (ViGEmBus installer for 10/11; RioGamepadXP.sys + INF
|
||||
for XP; **.NET 4.0 Full + KB2468871 redistributables** so an offline XP
|
||||
cabinet needs nothing else — adds ~70 MB, XP can't download anymore) +
|
||||
`VERSION.txt` + README-DEPLOY (payload-internal detail doc).
|
||||
- **`README.txt`** (zip root): written for a person installing on a
|
||||
standalone computer — which Windows versions are supported, what the
|
||||
two .bat entry points are and which one to run (`install.bat` for a
|
||||
standalone machine; `postinstall.bat` is the cabinet launcher's hook),
|
||||
that all prerequisites are bundled for offline install, and where the
|
||||
app lands. Plain ASCII so XP-era Notepad renders it cleanly.
|
||||
- **`postinstall.bat`** (zip root, unattended): the TeslaConsole/launcher
|
||||
entry point, as today — detects the OS (`ver` → 5.1 = XP, 10.x = modern),
|
||||
installs the matching prereqs (ViGEmBus silently via the existing
|
||||
PowerShell on 10/11; .NET 4.0 + KB2468871 + driver INF on XP), and wires
|
||||
the matching app flavor. No prompts, idempotent. **Its final step
|
||||
deletes `install.bat` and the root `README.txt`** — on cabinet deploys
|
||||
the zip extracts into `C:\games`, and only launcher-managed files may
|
||||
stay at that root (`del` tolerating already-missing files, so re-runs
|
||||
stay idempotent).
|
||||
- **`install.bat`** (zip root, freestanding computers): same OS detection
|
||||
and prereq install, plus what a machine without the launcher needs —
|
||||
Start Menu/desktop shortcut to the right flavor's exe (still no logon
|
||||
auto-start; starting RIOJoy stays deliberate). Pure cmd.exe on the XP
|
||||
path; may call PowerShell only on the 10/11 path. Leaves the README in
|
||||
place (it's the standalone machine's documentation).
|
||||
- `pre-uninstall.bat` / uninstall mirror both scenarios. Release the one
|
||||
zip to Gitea per the established process.
|
||||
- **8E — Verification:** full suite stays net48-hosted (xUnit needs
|
||||
net452+; shared sources are what's tested) + a tiny net40 console
|
||||
self-test for the shims, run on XP. Ladder: net40/x86 binary boots on
|
||||
Win10 → XP VM with vRIO over a virtual COM pair (app milestone; the
|
||||
driver needs real/virtualized XP too — vhidmini-class drivers run fine
|
||||
in a VM) → real cabinet (joy.cpl shows 6 axes + 96 buttons, SendInput
|
||||
into a game, lamps, plasma, auto-switch yield, BMP wallpaper).
|
||||
- **All open decisions resolved:**
|
||||
1. The Xbox 360 pad (ViGEm) remains the preferred controller on
|
||||
Windows 10/11 whenever ViGEmBus is present.
|
||||
2. **No third-party virtual joystick drivers** (vJoy/PPJoy are
|
||||
unmaintained) — XP gets our own RioGamepadXP.sys.
|
||||
3. The **mapping editor ships in all instances**, XP included.
|
||||
4. **Both install scenarios** in one archive: `postinstall.bat`
|
||||
(TeslaConsole/launcher, unattended) and `install.bat` (freestanding
|
||||
computers, adds shortcuts); the single dist zip carries everything
|
||||
needed for both XP and 10/11, including offline redistributables.
|
||||
|
||||
---
|
||||
|
||||
## Open items / risks
|
||||
|
||||
@@ -91,6 +91,14 @@ from [`g_baRIOLengthsA`](../legacy/riovjoy2.cpp#L171).
|
||||
| 0x8B | KeyReleased | RIO→PC | 2 | `pad`, `index` |
|
||||
| 0x8C | TestModeChange | RIO→PC | 1 | `mode` (0 = exit) |
|
||||
|
||||
> **TestModeChange fires on every CheckRequest too** (firmware v4.2,
|
||||
> handler `$C5A6`): the board's check self-test brackets itself with
|
||||
> `0x8C` mode≠0 / mode=0, flashes the lamps, and leaves a stale
|
||||
> `04000000` frame on the 8-digit diagnostic display (cosmetic; see
|
||||
> [hardware/display-board-1408.md](hardware/display-board-1408.md)).
|
||||
> Hosts should treat `0x8C` around a check as routine, not as the
|
||||
> operator entering keypad test mode.
|
||||
|
||||
### Reset targets (ResetRequest payload)
|
||||
|
||||
`0` = general/all, `1` = throttle, `2` = left pedal, `3` = right pedal,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*++
|
||||
RioGamepadXP — shared definitions for the Windows XP virtual HID gamepad driver
|
||||
and its user-mode client (the RIOJoy net40 tray app's HID feeder).
|
||||
|
||||
This is the XP-flavor sibling of driver/RioGamepad/Public.h. The contract is
|
||||
IDENTICAL — same interface GUID, same IOCTL_RIO_SUBMIT_REPORT, same 25-byte
|
||||
report layout, same VID/PID — so RioJoy.Core's HidFeederJoystickSink drives
|
||||
either driver with only the device-open path differing.
|
||||
|
||||
The one XP difference: XP-era hidclass minidrivers cannot register a device
|
||||
interface on a bare control device (IoRegisterDeviceInterface needs a PDO), so
|
||||
the sideband control device is reachable by a fixed symbolic-link name instead
|
||||
of by interface GUID. The net40 feeder opens RIO_XP_USERMODE_PATH directly; the
|
||||
net48 feeder keeps using SetupDi + GUID_DEVINTERFACE_RIOGAMEPAD.
|
||||
|
||||
The input report is fixed-size and has no report ID:
|
||||
bytes 0..11 : 6 axes (X,Y,Z,Rx,Ry,Rz), each unsigned 16-bit little-endian
|
||||
byte 12 : low nibble = hat (0..3; 0x0F = centered/null), high nibble pad
|
||||
bytes 13..24 : 96 button bits (button N at byte 13 + (N-1)/8, bit (N-1)%8)
|
||||
--*/
|
||||
|
||||
#pragma once
|
||||
|
||||
//
|
||||
// Device interface GUID (same as the modern driver; kept for parity, and used
|
||||
// if a future XP build ever exposes an interface). {b6a3f1c2-...}
|
||||
//
|
||||
DEFINE_GUID(GUID_DEVINTERFACE_RIOGAMEPAD,
|
||||
0xb6a3f1c2, 0x7e84, 0x4d2a, 0x9c, 0x1f, 0x2a, 0x5e, 0x8d, 0x3b, 0x60, 0x71);
|
||||
|
||||
//
|
||||
// Custom IOCTL: submit one input report (input buffer = RIO_REPORT_SIZE bytes).
|
||||
// Identical code to the modern driver.
|
||||
//
|
||||
#define IOCTL_RIO_SUBMIT_REPORT \
|
||||
CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_WRITE_ACCESS)
|
||||
|
||||
//
|
||||
// HID input report size in bytes: 6*2 (axes) + 1 (hat+pad) + 12 (96 buttons).
|
||||
//
|
||||
#define RIO_REPORT_SIZE 25
|
||||
|
||||
//
|
||||
// Virtual device identity (matches the modern driver).
|
||||
//
|
||||
#define RIO_VENDOR_ID 0x1209 // pid.codes test/community VID
|
||||
#define RIO_PRODUCT_ID 0x5249 // 'R','I'
|
||||
#define RIO_VERSION 0x0100
|
||||
|
||||
//
|
||||
// Sideband control-device names. The kernel object and its DOS-device symlink;
|
||||
// the user-mode client opens RIO_XP_USERMODE_PATH.
|
||||
//
|
||||
#define RIO_XP_DEVICE_NAME L"\\Device\\RioGamepadXP"
|
||||
#define RIO_XP_SYMLINK_NAME L"\\DosDevices\\RioGamepadXP"
|
||||
#define RIO_XP_USERMODE_PATH "\\\\.\\RioGamepadXP"
|
||||
@@ -0,0 +1,56 @@
|
||||
/*++
|
||||
RioGamepad — HID report descriptor: a joystick with 6 16-bit axes (X,Y,Z,Rx,
|
||||
Ry,Rz), one 4-direction hat with null state, and 96 buttons. This mirrors the
|
||||
fidelity the legacy app drove through vJoy. The resulting input report is
|
||||
RIO_REPORT_SIZE (25) bytes; see Public.h for the byte layout.
|
||||
--*/
|
||||
|
||||
#pragma once
|
||||
|
||||
static const UCHAR g_RioReportDescriptor[] =
|
||||
{
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x04, // Usage (Joystick)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
|
||||
0x09, 0x01, // Usage (Pointer)
|
||||
0xA1, 0x00, // Collection (Physical)
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x30, // Usage (X)
|
||||
0x09, 0x31, // Usage (Y)
|
||||
0x09, 0x32, // Usage (Z)
|
||||
0x09, 0x33, // Usage (Rx)
|
||||
0x09, 0x34, // Usage (Ry)
|
||||
0x09, 0x35, // Usage (Rz)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
|
||||
0x75, 0x10, // Report Size (16)
|
||||
0x95, 0x06, // Report Count (6)
|
||||
0x81, 0x02, // Input (Data,Var,Abs)
|
||||
0xC0, // End Collection (Physical)
|
||||
|
||||
0x09, 0x39, // Usage (Hat switch)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x03, // Logical Maximum (3)
|
||||
0x35, 0x00, // Physical Minimum (0)
|
||||
0x46, 0x0E, 0x01, // Physical Maximum (270)
|
||||
0x65, 0x14, // Unit (Degrees)
|
||||
0x75, 0x04, // Report Size (4)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x81, 0x42, // Input (Data,Var,Abs,Null)
|
||||
0x65, 0x00, // Unit (None)
|
||||
0x75, 0x04, // Report Size (4)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x81, 0x03, // Input (Const,Var,Abs) ; 4-bit padding
|
||||
|
||||
0x05, 0x09, // Usage Page (Button)
|
||||
0x19, 0x01, // Usage Minimum (Button 1)
|
||||
0x29, 0x60, // Usage Maximum (Button 96)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x01, // Logical Maximum (1)
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x95, 0x60, // Report Count (96)
|
||||
0x81, 0x02, // Input (Data,Var,Abs)
|
||||
|
||||
0xC0 // End Collection (Application)
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
;
|
||||
; RioGamepadXP.inf — RIOJoy virtual HID gamepad for Windows XP (WDM HID
|
||||
; minidriver, root-enumerated). No catalog / signing: 32-bit XP does not
|
||||
; enforce kernel-mode driver signing, so this installs unsigned.
|
||||
;
|
||||
; Install (see deploy\install-core.bat):
|
||||
; devcon.exe install RioGamepadXP.inf root\RioGamepadXP
|
||||
;
|
||||
|
||||
[Version]
|
||||
Signature = "$WINDOWS NT$"
|
||||
Class = HIDClass
|
||||
ClassGuid = {745a17a0-74d3-11d0-b6fe-00a0c90f57da}
|
||||
Provider = %ManufacturerName%
|
||||
DriverVer = 07/11/2026,1.0.0.0
|
||||
|
||||
[DestinationDirs]
|
||||
DefaultDestDir = 12 ; %SystemRoot%\System32\drivers
|
||||
RioGamepadXP_CopyFiles = 12
|
||||
|
||||
[SourceDisksNames]
|
||||
1 = %DiskName%
|
||||
|
||||
[SourceDisksFiles]
|
||||
RioGamepadXP.sys = 1
|
||||
|
||||
[Manufacturer]
|
||||
%ManufacturerName% = Standard, NTx86
|
||||
|
||||
[Standard.NTx86]
|
||||
%DeviceName% = RioGamepadXP_Device, root\RioGamepadXP
|
||||
|
||||
[RioGamepadXP_Device.NT]
|
||||
CopyFiles = RioGamepadXP_CopyFiles
|
||||
|
||||
[RioGamepadXP_CopyFiles]
|
||||
RioGamepadXP.sys
|
||||
|
||||
[RioGamepadXP_Device.NT.Services]
|
||||
AddService = RioGamepadXP, 0x00000002, RioGamepadXP_Service
|
||||
|
||||
[RioGamepadXP_Service]
|
||||
DisplayName = %ServiceName%
|
||||
ServiceType = 1 ; SERVICE_KERNEL_DRIVER
|
||||
StartType = 3 ; SERVICE_DEMAND_START
|
||||
ErrorControl = 1 ; SERVICE_ERROR_NORMAL
|
||||
ServiceBinary = %12%\RioGamepadXP.sys
|
||||
|
||||
[Strings]
|
||||
ManufacturerName = "VWE"
|
||||
DiskName = "RioGamepadXP Installation Disk"
|
||||
DeviceName = "RIOJoy Virtual Gamepad (XP)"
|
||||
ServiceName = "RIOJoy Virtual Gamepad Service (XP)"
|
||||
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
rem Build RioGamepadXP.sys for Windows XP (x86, free). Requires WDK 7.1.0.
|
||||
rem Override WDK if installed elsewhere: set WDK=<path> before calling.
|
||||
if "%WDK%"=="" set WDK=C:\WinDDK\WinDDK\7600.16385.win7_wdk.100208-1538
|
||||
call %WDK%\bin\setenv.bat %WDK% fre x86 WXP no_oacr
|
||||
cd /d %~dp0
|
||||
build -cZ
|
||||
@@ -0,0 +1 @@
|
||||
!INCLUDE $(NTMAKEENV)\makefile.def
|
||||
@@ -0,0 +1,381 @@
|
||||
/*++
|
||||
RioGamepadXP — a WDM HID minidriver for Windows XP that presents a virtual
|
||||
joystick (6 axes, hat, 96 buttons) and accepts input reports from the RIOJoy
|
||||
tray app over a sideband control device. The XP-era replacement for the modern
|
||||
KMDF+VHF RioGamepad driver, exposing the same 25-byte report contract
|
||||
(see Public.h) so the user-mode feeder is unchanged apart from the open path.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
There is exactly one virtual device, so state is global (guarded by a spinlock):
|
||||
|
||||
* HID side: we register as a HID minidriver (HidRegisterMinidriver). hidclass
|
||||
creates and owns the HID FDO and drives PnP/Power; it calls our
|
||||
IRP_MJ_INTERNAL_DEVICE_CONTROL handler to fetch the HID/report descriptors,
|
||||
device attributes, and input reports. We run in POLLED mode
|
||||
(DevicesArePolled = TRUE): hidclass paces IOCTL_HID_READ_REPORT and we
|
||||
complete each synchronously with the current cached report. This avoids
|
||||
pending-IRP + cancel-routine machinery entirely (the usual crash surface in
|
||||
a virtual HID driver).
|
||||
|
||||
* Sideband: a raw control device (\Device\RioGamepadXP + a \DosDevices symlink)
|
||||
that the app opens by name. On IOCTL_RIO_SUBMIT_REPORT we copy the 25 report
|
||||
bytes into the cache. hidclass, on its next poll, hands them to the OS.
|
||||
|
||||
Dispatch ownership: HidRegisterMinidriver replaces our driver object's
|
||||
CREATE/CLOSE/DEVICE_CONTROL (and PnP/Power/etc.) entries with hidclass thunks
|
||||
that assume a HID FDO. Our control device shares the same driver object, so we
|
||||
save hidclass's pointers and reinstall thin wrappers that route the control
|
||||
device's IRPs to us and forward everything else to hidclass.
|
||||
--*/
|
||||
|
||||
#include <ntddk.h>
|
||||
#include <hidport.h>
|
||||
#include <initguid.h> // realize DEFINE_GUID storage from Public.h
|
||||
#include "Public.h"
|
||||
#include "ReportDescriptor.h"
|
||||
|
||||
//
|
||||
// Global single-instance state.
|
||||
//
|
||||
static PDEVICE_OBJECT g_ControlDevice = NULL;
|
||||
static KSPIN_LOCK g_ReportLock;
|
||||
static UCHAR g_Report[RIO_REPORT_SIZE];
|
||||
|
||||
// hidclass-installed dispatch pointers we wrap.
|
||||
static PDRIVER_DISPATCH g_HidCreate = NULL;
|
||||
static PDRIVER_DISPATCH g_HidClose = NULL;
|
||||
static PDRIVER_DISPATCH g_HidDeviceControl = NULL;
|
||||
|
||||
static UNICODE_STRING g_SymlinkName;
|
||||
|
||||
//
|
||||
// The HID descriptor that advertises our single report descriptor. bcdHID 1.11.
|
||||
//
|
||||
#include <pshpack1.h>
|
||||
typedef struct _RIO_HID_DESCRIPTOR {
|
||||
UCHAR bLength;
|
||||
UCHAR bDescriptorType;
|
||||
USHORT bcdHID;
|
||||
UCHAR bCountry;
|
||||
UCHAR bNumDescriptors;
|
||||
UCHAR bReportType;
|
||||
USHORT wReportLength;
|
||||
} RIO_HID_DESCRIPTOR;
|
||||
#include <poppack.h>
|
||||
|
||||
static const RIO_HID_DESCRIPTOR g_HidDescriptor =
|
||||
{
|
||||
sizeof(RIO_HID_DESCRIPTOR),
|
||||
HID_HID_DESCRIPTOR_TYPE, // 0x21
|
||||
0x0111, // HID 1.11
|
||||
0x00, // not localized
|
||||
1, // one report descriptor
|
||||
HID_REPORT_DESCRIPTOR_TYPE, // 0x22
|
||||
sizeof(g_RioReportDescriptor)
|
||||
};
|
||||
|
||||
//
|
||||
// Reset the cached report to the neutral rest state: axes centered (16383 =
|
||||
// 0x3FFF little-endian), hat null (0x0F), all buttons released.
|
||||
//
|
||||
static VOID
|
||||
RioResetReport(VOID)
|
||||
{
|
||||
ULONG i;
|
||||
KIRQL irql;
|
||||
|
||||
KeAcquireSpinLock(&g_ReportLock, &irql);
|
||||
RtlZeroMemory(g_Report, sizeof(g_Report));
|
||||
for (i = 0; i < 6; i++) // 6 axes, 2 bytes each, centered
|
||||
{
|
||||
g_Report[i * 2] = 0xFF;
|
||||
g_Report[i * 2 + 1] = 0x3F;
|
||||
}
|
||||
g_Report[12] = 0x0F; // hat centered/null
|
||||
KeReleaseSpinLock(&g_ReportLock, irql);
|
||||
}
|
||||
|
||||
//
|
||||
// Complete an IRP with a status and information count.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioComplete(PIRP Irp, NTSTATUS Status, ULONG_PTR Information)
|
||||
{
|
||||
Irp->IoStatus.Status = Status;
|
||||
Irp->IoStatus.Information = Information;
|
||||
IoCompleteRequest(Irp, IO_NO_INCREMENT);
|
||||
return Status;
|
||||
}
|
||||
|
||||
//
|
||||
// IRP_MJ_INTERNAL_DEVICE_CONTROL on the HID FDO: hidclass asks us for the
|
||||
// descriptors / attributes / input reports. These IOCTLs are METHOD_NEITHER;
|
||||
// hidclass supplies the output buffer at Irp->UserBuffer with the length in
|
||||
// Parameters.DeviceIoControl.OutputBufferLength.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioInternalDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
|
||||
{
|
||||
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
|
||||
NTSTATUS status = STATUS_SUCCESS;
|
||||
ULONG_PTR info = 0;
|
||||
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
|
||||
PVOID out = Irp->UserBuffer;
|
||||
|
||||
UNREFERENCED_PARAMETER(DeviceObject);
|
||||
|
||||
switch (stack->Parameters.DeviceIoControl.IoControlCode)
|
||||
{
|
||||
case IOCTL_HID_GET_DEVICE_DESCRIPTOR:
|
||||
if (outLen < sizeof(g_HidDescriptor)) { status = STATUS_BUFFER_TOO_SMALL; break; }
|
||||
RtlCopyMemory(out, &g_HidDescriptor, sizeof(g_HidDescriptor));
|
||||
info = sizeof(g_HidDescriptor);
|
||||
break;
|
||||
|
||||
case IOCTL_HID_GET_REPORT_DESCRIPTOR:
|
||||
if (outLen < sizeof(g_RioReportDescriptor)) { status = STATUS_BUFFER_TOO_SMALL; break; }
|
||||
RtlCopyMemory(out, g_RioReportDescriptor, sizeof(g_RioReportDescriptor));
|
||||
info = sizeof(g_RioReportDescriptor);
|
||||
break;
|
||||
|
||||
case IOCTL_HID_GET_DEVICE_ATTRIBUTES:
|
||||
{
|
||||
PHID_DEVICE_ATTRIBUTES attr = (PHID_DEVICE_ATTRIBUTES)out;
|
||||
if (outLen < sizeof(HID_DEVICE_ATTRIBUTES)) { status = STATUS_BUFFER_TOO_SMALL; break; }
|
||||
RtlZeroMemory(attr, sizeof(HID_DEVICE_ATTRIBUTES));
|
||||
attr->Size = sizeof(HID_DEVICE_ATTRIBUTES);
|
||||
attr->VendorID = RIO_VENDOR_ID;
|
||||
attr->ProductID = RIO_PRODUCT_ID;
|
||||
attr->VersionNumber = RIO_VERSION;
|
||||
info = sizeof(HID_DEVICE_ATTRIBUTES);
|
||||
break;
|
||||
}
|
||||
|
||||
case IOCTL_HID_READ_REPORT:
|
||||
{
|
||||
KIRQL irql;
|
||||
if (outLen < RIO_REPORT_SIZE) { status = STATUS_BUFFER_TOO_SMALL; break; }
|
||||
KeAcquireSpinLock(&g_ReportLock, &irql);
|
||||
RtlCopyMemory(out, g_Report, RIO_REPORT_SIZE);
|
||||
KeReleaseSpinLock(&g_ReportLock, irql);
|
||||
info = RIO_REPORT_SIZE;
|
||||
break;
|
||||
}
|
||||
|
||||
case IOCTL_HID_WRITE_REPORT:
|
||||
case IOCTL_HID_SET_FEATURE:
|
||||
case IOCTL_HID_GET_FEATURE:
|
||||
// No output reports / feature reports on this device.
|
||||
status = STATUS_NOT_SUPPORTED;
|
||||
break;
|
||||
|
||||
case IOCTL_HID_GET_STRING:
|
||||
// DirectInput takes the friendly name from the registry OEMName value
|
||||
// (set at install), not from a HID string, so an empty success is fine.
|
||||
status = STATUS_SUCCESS;
|
||||
info = 0;
|
||||
break;
|
||||
|
||||
default:
|
||||
status = STATUS_NOT_SUPPORTED;
|
||||
break;
|
||||
}
|
||||
|
||||
return RioComplete(Irp, status, info);
|
||||
}
|
||||
|
||||
//
|
||||
// hidclass calls this (our saved AddDevice) after it has created the HID FDO.
|
||||
// Nothing to attach — hidclass owns the stack — so just succeed.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioAddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT FunctionalDeviceObject)
|
||||
{
|
||||
UNREFERENCED_PARAMETER(DriverObject);
|
||||
UNREFERENCED_PARAMETER(FunctionalDeviceObject);
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
//
|
||||
// Sideband handlers (control device only). CREATE/CLOSE just succeed.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioControlCreateClose(PIRP Irp)
|
||||
{
|
||||
return RioComplete(Irp, STATUS_SUCCESS, 0);
|
||||
}
|
||||
|
||||
//
|
||||
// IOCTL_RIO_SUBMIT_REPORT (METHOD_BUFFERED): copy the 25 report bytes into the
|
||||
// cache; hidclass hands them out on its next poll.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioControlDeviceControl(PIRP Irp)
|
||||
{
|
||||
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
|
||||
NTSTATUS status;
|
||||
|
||||
if (stack->Parameters.DeviceIoControl.IoControlCode == IOCTL_RIO_SUBMIT_REPORT)
|
||||
{
|
||||
if (stack->Parameters.DeviceIoControl.InputBufferLength != RIO_REPORT_SIZE)
|
||||
{
|
||||
status = STATUS_INVALID_BUFFER_SIZE;
|
||||
}
|
||||
else
|
||||
{
|
||||
KIRQL irql;
|
||||
KeAcquireSpinLock(&g_ReportLock, &irql);
|
||||
RtlCopyMemory(g_Report, Irp->AssociatedIrp.SystemBuffer, RIO_REPORT_SIZE);
|
||||
KeReleaseSpinLock(&g_ReportLock, irql);
|
||||
status = STATUS_SUCCESS;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
status = STATUS_INVALID_DEVICE_REQUEST;
|
||||
}
|
||||
|
||||
return RioComplete(Irp, status, 0);
|
||||
}
|
||||
|
||||
//
|
||||
// Dispatch wrappers: route the control device's IRPs to us, forward the HID
|
||||
// FDO's IRPs to the saved hidclass handlers.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioDispatchCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp)
|
||||
{
|
||||
if (DeviceObject == g_ControlDevice)
|
||||
return RioControlCreateClose(Irp);
|
||||
return g_HidCreate(DeviceObject, Irp);
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
RioDispatchClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
|
||||
{
|
||||
if (DeviceObject == g_ControlDevice)
|
||||
return RioControlCreateClose(Irp);
|
||||
return g_HidClose(DeviceObject, Irp);
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
RioDispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
|
||||
{
|
||||
if (DeviceObject == g_ControlDevice)
|
||||
return RioControlDeviceControl(Irp);
|
||||
return g_HidDeviceControl(DeviceObject, Irp);
|
||||
}
|
||||
|
||||
//
|
||||
// Unload: drop the symlink and control device. hidclass tears down the HID FDO.
|
||||
//
|
||||
static VOID
|
||||
RioUnload(PDRIVER_OBJECT DriverObject)
|
||||
{
|
||||
UNREFERENCED_PARAMETER(DriverObject);
|
||||
|
||||
if (g_SymlinkName.Buffer != NULL)
|
||||
IoDeleteSymbolicLink(&g_SymlinkName);
|
||||
if (g_ControlDevice != NULL)
|
||||
IoDeleteDevice(g_ControlDevice);
|
||||
}
|
||||
|
||||
//
|
||||
// Create the sideband control device and its DOS-device symbolic link.
|
||||
//
|
||||
static NTSTATUS
|
||||
RioCreateControlDevice(PDRIVER_OBJECT DriverObject)
|
||||
{
|
||||
NTSTATUS status;
|
||||
UNICODE_STRING deviceName;
|
||||
|
||||
RtlInitUnicodeString(&deviceName, RIO_XP_DEVICE_NAME);
|
||||
RtlInitUnicodeString(&g_SymlinkName, RIO_XP_SYMLINK_NAME);
|
||||
|
||||
status = IoCreateDevice(
|
||||
DriverObject,
|
||||
0, // no device extension; state is global
|
||||
&deviceName,
|
||||
FILE_DEVICE_UNKNOWN,
|
||||
FILE_DEVICE_SECURE_OPEN,
|
||||
FALSE, // not exclusive
|
||||
&g_ControlDevice);
|
||||
if (!NT_SUCCESS(status))
|
||||
return status;
|
||||
|
||||
g_ControlDevice->Flags |= DO_BUFFERED_IO; // IOCTL_RIO_SUBMIT_REPORT is METHOD_BUFFERED
|
||||
|
||||
status = IoCreateSymbolicLink(&g_SymlinkName, &deviceName);
|
||||
if (!NT_SUCCESS(status))
|
||||
{
|
||||
IoDeleteDevice(g_ControlDevice);
|
||||
g_ControlDevice = NULL;
|
||||
g_SymlinkName.Buffer = NULL;
|
||||
return status;
|
||||
}
|
||||
|
||||
g_ControlDevice->Flags &= ~DO_DEVICE_INITIALIZING;
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
NTSTATUS
|
||||
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
|
||||
{
|
||||
NTSTATUS status;
|
||||
HID_MINIDRIVER_REGISTRATION reg;
|
||||
|
||||
KeInitializeSpinLock(&g_ReportLock);
|
||||
RioResetReport();
|
||||
|
||||
//
|
||||
// Our own dispatch entries. hidclass replaces the HID-related ones during
|
||||
// registration; we re-wrap CREATE/CLOSE/DEVICE_CONTROL afterwards.
|
||||
//
|
||||
DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = RioInternalDeviceControl;
|
||||
DriverObject->MajorFunction[IRP_MJ_CREATE] = RioDispatchCreate;
|
||||
DriverObject->MajorFunction[IRP_MJ_CLOSE] = RioDispatchClose;
|
||||
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = RioDispatchDeviceControl;
|
||||
DriverObject->DriverExtension->AddDevice = RioAddDevice;
|
||||
DriverObject->DriverUnload = RioUnload;
|
||||
|
||||
RtlZeroMemory(®, sizeof(reg));
|
||||
reg.Revision = HID_REVISION;
|
||||
reg.DriverObject = DriverObject;
|
||||
reg.RegistryPath = RegistryPath;
|
||||
reg.DeviceExtensionSize = 0; // per-device state is global (single instance)
|
||||
reg.DevicesArePolled = TRUE; // polled reads → no pending-IRP/cancel machinery
|
||||
|
||||
status = HidRegisterMinidriver(®);
|
||||
if (!NT_SUCCESS(status))
|
||||
return status;
|
||||
|
||||
//
|
||||
// hidclass has now overwritten CREATE/CLOSE/DEVICE_CONTROL (and PnP/Power/
|
||||
// etc.) with its own thunks. Save the ones we need to forward, then reinstall
|
||||
// our wrappers so the control device's IRPs come back to us.
|
||||
//
|
||||
g_HidCreate = DriverObject->MajorFunction[IRP_MJ_CREATE];
|
||||
g_HidClose = DriverObject->MajorFunction[IRP_MJ_CLOSE];
|
||||
g_HidDeviceControl = DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL];
|
||||
|
||||
DriverObject->MajorFunction[IRP_MJ_CREATE] = RioDispatchCreate;
|
||||
DriverObject->MajorFunction[IRP_MJ_CLOSE] = RioDispatchClose;
|
||||
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = RioDispatchDeviceControl;
|
||||
|
||||
//
|
||||
// hidclass also replaces DriverUnload; chain our teardown ahead of nothing
|
||||
// (hidclass's unload runs via its own bookkeeping). Reassert ours.
|
||||
//
|
||||
DriverObject->DriverUnload = RioUnload;
|
||||
|
||||
status = RioCreateControlDevice(DriverObject);
|
||||
if (!NT_SUCCESS(status))
|
||||
{
|
||||
// The HID device can still work without the sideband, but the app can't
|
||||
// feed it, so fail registration cleanly.
|
||||
return status;
|
||||
}
|
||||
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
TARGETNAME=RioGamepadXP
|
||||
TARGETTYPE=DRIVER
|
||||
|
||||
# Link against hidclass's minidriver import library (HidRegisterMinidriver).
|
||||
TARGETLIBS=$(DDK_LIB_PATH)\hidclass.lib
|
||||
|
||||
SOURCES=rioxp.c
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"Name": "Descent",
|
||||
"MatchExecutables": [
|
||||
"d1x-rebirth"
|
||||
],
|
||||
"PlasmaGreeting": "DESCENT",
|
||||
"WallpaperPath": null,
|
||||
"OverlayLabels": {},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Buttons": {
|
||||
"16": 32822,
|
||||
"17": 32823,
|
||||
"18": 32824,
|
||||
"19": 32825,
|
||||
"20": 32816,
|
||||
"21": 32777,
|
||||
"24": 32781,
|
||||
"28": 34991,
|
||||
"29": 34990,
|
||||
"32": 32817,
|
||||
"33": 32818,
|
||||
"34": 32819,
|
||||
"35": 32820,
|
||||
"36": 32821,
|
||||
"37": 32850,
|
||||
"56": 67,
|
||||
"57": 68,
|
||||
"58": 88,
|
||||
"59": 65,
|
||||
"60": 4101,
|
||||
"62": 4102,
|
||||
"63": 83,
|
||||
"64": 4097,
|
||||
"65": 8194,
|
||||
"66": 8192,
|
||||
"67": 8193,
|
||||
"68": 8195,
|
||||
"69": 188,
|
||||
"70": 190,
|
||||
"71": 4099,
|
||||
"80": 48,
|
||||
"81": 49,
|
||||
"82": 50,
|
||||
"83": 51,
|
||||
"84": 52,
|
||||
"85": 53,
|
||||
"86": 54,
|
||||
"87": 55,
|
||||
"88": 56,
|
||||
"89": 57,
|
||||
"90": 9,
|
||||
"91": 66,
|
||||
"92": 82,
|
||||
"95": 70,
|
||||
"96": 28672,
|
||||
"97": 28673,
|
||||
"98": 28674,
|
||||
"99": 28675,
|
||||
"100": 28676,
|
||||
"101": 28677,
|
||||
"102": 28678,
|
||||
"103": 28679,
|
||||
"104": 28680,
|
||||
"105": 28681
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# RIO board firmware
|
||||
|
||||
- **`RIOv4_2.bin`** — RIO cockpit I/O board firmware **v4.2**, dumped
|
||||
2026-07-04 from one of our own boards' EPROM: an **AMD AM27C512-150**
|
||||
(64K x 8 UV EPROM, 150ns — the image fills it exactly).
|
||||
sha256 `60a88718835c654b6135dbec7721c40ef99dca07df2ad4b57eedeb24037a5f73`.
|
||||
For the eventual patched burn: a pin-compatible Winbond W27C512
|
||||
(electrically erasable, TL866-friendly) drops straight into the socket;
|
||||
the original AMD chip gets labeled and preserved unmodified.
|
||||
|
||||
## First-look analysis (from the image alone, confirmed on hardware)
|
||||
|
||||
- MCU: **Toshiba TMP68HC11** (read off the chip; the code fingerprint
|
||||
agrees — 6800-family opcodes with writes into the 68HC11 internal
|
||||
register block at `$10xx`).
|
||||
- Memory map: image is FF up to **0xC000**; 16KB of code occupies
|
||||
`$C000-$FFFF` (EPROM mapped at the top of the HC11 address space).
|
||||
- Startup at `$C000`: `SEI; LDS #$8000; STAA $1024 (TMSK2);
|
||||
STAA $1022 (TMSK1); ...` then a long `JSR` init chain — textbook HC11
|
||||
bring-up.
|
||||
- Vector table (`$FFD6-$FFFF`, big-endian):
|
||||
- `$FFFE` RESET → `$C000`
|
||||
- **`$FFD6` SCI (serial) → `$D630`** — the entry point of the board's
|
||||
receive/protocol interrupt handler. The suspected board-side
|
||||
DISABLE_AND_DIE-style wedge (see RIO-NOTES.md: the board mirrors the
|
||||
game's PCSPAK state machine, and mash-stress leaves the reply path
|
||||
dead while the button/event path stays alive) is reachable from here.
|
||||
- `$FFE4` → `$C1B2`, `$FFE6` → `$C18E` (timer output-compares); most
|
||||
other vectors → `$DB07..$DB3D` stubs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The remaining RIO reliability issue is board-side: under button-mash
|
||||
stress the board's reply/analog state machine wedges (RX dead, TX alive;
|
||||
a button press or power cycle revives it), reproduced identically on two
|
||||
different USB serial adapters. The game-side half of the protocol was
|
||||
binary-patched for tolerance (BTL4OPT patches v2-v4); the board firmware
|
||||
is the other half. Plan (RIO-NOTES.md "Board firmware patch plan"):
|
||||
disassemble as 68HC11 from `$C000` with the vector entries as roots, find
|
||||
the SCI state machine (protocol constants FC=ACK FD=NAK FE=RESTART
|
||||
FF=IDLE, idle-reload-4 patterns), patch the early-ACK/error wedge path or
|
||||
widen its window, burn a new EPROM, keep this original safe.
|
||||
@@ -1,175 +0,0 @@
|
||||
# RIO v4.2 firmware — protocol wedge analysis
|
||||
|
||||
Reverse-engineering of `RIOv4_2.bin` (Toshiba TMP68HC11, AM27C512) to find
|
||||
the board-side cause of the "reply path wedges under stress, button-press
|
||||
revives it" fault. Disassembly by `disasm_6811.py` →
|
||||
`RIOv4_2.disasm.asm`. **Research only** — the fix below is proposed, not
|
||||
yet burned or tested (no spare EPROM on hand). Validate on hardware with
|
||||
the `RIO_TAP` mash test before trusting.
|
||||
|
||||
Addresses are CPU = file offset (EPROM at `$C000-$FFFF`; reset `$FFFE`→
|
||||
`$C000`). RAM lives at `$20xx-$31xx`.
|
||||
|
||||
## How the serial protocol is structured
|
||||
|
||||
- **SCI interrupt** (`$FFD6`→`$D630`): `JSR $D634; RTI`. `$D634` runs BOTH
|
||||
workers every interrupt: `JSR $D6EA` (RX) then `JSR $D887` (TX). So the
|
||||
transmitter is poked after every received byte, not only on TX-empty
|
||||
interrupts.
|
||||
- **RX ISR** `$D6EA`: reads SCSR/SCDR, stores the byte at `$3172`, then
|
||||
`LDX $292F; JMP $00,X` — dispatches through a **state-handler pointer**
|
||||
at `$292F`. Handlers classify bytes (`$D717`: `FE`=RESTART, `FF`=IDLE,
|
||||
`FC`/`FD`=game ACK/NAK, `$82`=analog request, table lookup at `$3144`),
|
||||
accumulate the body + checksum (`AND $7F`), and on a complete packet run
|
||||
the ACK/NAK decision at `$D81F`.
|
||||
- **TX ISR** `$D887`: if TDRE, send a pending ACK (`$316F`→`$FC`) or NAK
|
||||
(`$3170`→`$FD`), else dispatch through the TX state pointer `$2D3B`
|
||||
(`$D8C2` ring-drain → `$D90E` reply/retry machine). When idle it disarms
|
||||
the TX interrupt (SCCR2 `#$2C`, TIE off) at `$D918`; the enqueue routine
|
||||
`$D63B` re-arms it (SCCR2 `#$AC`, TIE on) at `$D664`.
|
||||
|
||||
## The wedge: an orphaned "reply-in-progress" latch (`$2521`)
|
||||
|
||||
`$2521` = "an analog reply is in progress." The analog-request handler
|
||||
gates on it:
|
||||
|
||||
```
|
||||
D74F CMPB #$82 ; analog request from the game
|
||||
D753 LDAA #$01
|
||||
D755 STAA $2520 ; arm reply generation
|
||||
D758 TST $2521 ; already replying?
|
||||
D75B BNE $D77A ; YES -> D77A: CLR $2520, drop this request
|
||||
```
|
||||
|
||||
So while `$2521` is set, **every analog request is silently dropped**.
|
||||
The latch is set when a reply is generated:
|
||||
|
||||
```
|
||||
D847 JSR $C5EC ; build the analog reply
|
||||
D84C STAA $2521 ; reply-in-progress = 1
|
||||
```
|
||||
|
||||
and is cleared in only three places: power-on init (`$C0A3`), a host
|
||||
reset/init command handler (`$C686`), and the reply **success** teardown
|
||||
(`$DA00`). The success teardown is reached at `$D9C1` when the game ACKs
|
||||
the reply, and clears the latch — but only conditionally:
|
||||
|
||||
```
|
||||
DA21 LDAA $2522 ; did the $87 reply byte actually start sending?
|
||||
DA24 CMPA #$01
|
||||
DA26 BNE $DA2E ; if not, skip the clears <-- fragile
|
||||
DA28 CLR $2521
|
||||
DA2B CLR $2522
|
||||
```
|
||||
|
||||
`$2521` is set the instant the reply is *generated* (`$D84C`), but `$2522`
|
||||
is set only once the `$87` command byte *starts transmitting* (`$D8FD`).
|
||||
|
||||
**The leak** is the retry-exhausted give-up path, which is *separate* from
|
||||
the success teardown. When the game fails to ACK a reply, `$D90E`/`$D9BE`
|
||||
retries up to 4 times, then gives up:
|
||||
|
||||
```
|
||||
D9D5 LDAB #$FE ; give up: send RESTART
|
||||
D9D7 STAB $102F ; SCDR
|
||||
D9DA INC $317A
|
||||
D9DD JMP $DA2F ; teardown -- but DA2F never touches $2521
|
||||
```
|
||||
|
||||
`$DA2F` resets the TX pointers and calls `$D5F2` (a debug-counter
|
||||
formatter that does *not* clear the latch), then returns. **`$2521` is
|
||||
left set forever.** From then on every `$82` analog request is dropped at
|
||||
`$D758` → the board is mute to analog while its RX/event path stays fully
|
||||
alive.
|
||||
|
||||
### Why a button press / new game revives it
|
||||
|
||||
The only mid-run code that clears `$2521` is the host command handler at
|
||||
`$C669-$C689` (it clears `$2520`/`$2521`/`$2522` plus a raft of state).
|
||||
That runs for a host-level reset/init command — exactly what the game
|
||||
sends at game-start / on the player's opening button actions. Mid-mission
|
||||
button-mashing sends no such command, so the leaked latch stays stuck
|
||||
until the next game-start reset. This matches the field observation
|
||||
precisely: the board goes mute under stress and only a new-game/button
|
||||
resync brings analog back.
|
||||
|
||||
### Why mash stress triggers it
|
||||
|
||||
Button-event traffic floods the link while the board is mid-analog-reply;
|
||||
the reply's ACKs collide/drop, the 4-retry budget exhausts, and the
|
||||
give-up path (`$DA2F`) fires — leaking the latch. Light traffic rarely
|
||||
exhausts the retries, so it's a stress-only fault. Two different USB
|
||||
adapters showed the identical stall because the defect is in the board,
|
||||
not the transport — consistent with this being firmware, not timing.
|
||||
|
||||
## Proposed fix (minimal, in-place; UNTESTED)
|
||||
|
||||
Clear `$2521` on *every* reply teardown, not just the `$2522`-gated
|
||||
success path. Two edits, no code-size change, 8 KB of free ROM exists at
|
||||
`$DFF0-$FFBF` for the stub:
|
||||
|
||||
1. **Give-up path** — redirect its teardown through a stub that clears the
|
||||
latch first. At `$D9DD` change `JMP $DA2F` (`7E DA 2F`) →
|
||||
`JMP $DFF0` (`7E DF F0`), and place at `$DFF0`:
|
||||
```
|
||||
DFF0 7F 25 21 CLR $2521
|
||||
DFF3 7F 25 22 CLR $2522
|
||||
DFF6 7E DA 2F JMP $DA2F
|
||||
```
|
||||
2. **Success path** — make the clear unconditional (belt-and-suspenders,
|
||||
covers an abort before `$87` is sent). Replace `$DA21-$DA2D` (13 bytes)
|
||||
in place:
|
||||
```
|
||||
DA21 7F 25 21 CLR $2521
|
||||
DA24 7F 25 22 CLR $2522
|
||||
DA27 01 01 01 01 01 01 01 (NOP x7)
|
||||
DA2E 39 RTS (unchanged)
|
||||
```
|
||||
|
||||
Rationale: `$2521` means "a reply is in progress"; any path that tears
|
||||
down reply state must release it. There is no case where you reset the
|
||||
reply machine yet want the latch to stay set, so unconditional clearing is
|
||||
safe. This is the board-side analogue of the game-side "make collisions
|
||||
harmless" patches (BTL4OPT v2-v4) — instead of widening a timing window it
|
||||
removes the latch leak entirely.
|
||||
|
||||
### Patched binary — built & statically verified (2026-07-04)
|
||||
|
||||
`make_patch.py` applies both edits to `RIOv4_2.bin` (asserting the exact
|
||||
original bytes at each site first) → **`RIOv4_2_patched.bin`**
|
||||
(sha256 `3fc8170caf60e2580641724ff995176c93c4f2e706f31487beded8233142493f`,
|
||||
23 bytes changed). Re-disassembling it (`RIOv4_2_patched.disasm.asm`) and
|
||||
diffing against the original confirms the change is confined to exactly
|
||||
three regions with no downstream desync:
|
||||
- `$D9DD` `JMP $DA2F` → `JMP $DFF0`
|
||||
- `$DFF0` new stub: `CLR $2521 ; CLR $2522 ; JMP $DA2F`
|
||||
- `$DA21` `CLR $2521 ; CLR $2522 ; NOP×7` (RTS at `$DA2E` intact)
|
||||
|
||||
Flash `RIOv4_2_patched.bin` directly to the W27C512 (DIP-28). This is
|
||||
static verification only; dynamic proof still needs the burned chip.
|
||||
|
||||
### Validation plan (when a chip is available)
|
||||
Burn the two edits to a W27C512, socket it (preserve the original AMD
|
||||
chip), then run the `RIO_TAP` two-handed 8-button mash test. Expect: no
|
||||
permanent analog mute; any collision self-recovers without a game-start
|
||||
reset. Compare dropout counts to the 2026-07-03/04 baseline taps.
|
||||
|
||||
## Firmware memory map (as decoded so far)
|
||||
|
||||
| addr | meaning |
|
||||
|---|---|
|
||||
| `$292F` | RX state-handler pointer (`JMP $00,X` dispatch) |
|
||||
| `$2D3B` | TX state-handler pointer |
|
||||
| `$2D34/$36/$38` | TX ring read/write/aux pointers (ring `$2932-$2D31`) |
|
||||
| `$2520` | reply gate (analog request pending) |
|
||||
| `$2521` | **reply-in-progress latch — the wedge** |
|
||||
| `$2522` | `$87` analog-reply-byte-sent flag |
|
||||
| `$316C/$6D` | game ACK / NAK received |
|
||||
| `$316E` | unknown-command seen |
|
||||
| `$316F/$70` | ACK / NAK pending to send (→ TX ISR) |
|
||||
| `$3172` | last received byte |
|
||||
| `$3173/$74/$75` | ACK/NAK/wait retry counters (limit 4) |
|
||||
| `$317A/$7B` | RESTART / IDLE keep-alive counters |
|
||||
| `$3184/$85` | give-up / error diagnostic counters |
|
||||
| `$3186` | RX overrun flag (set at `$D701`, **never read** — not the cause) |
|
||||
| `$102D/$2E/$2F` | SCCR2 / SCSR / SCDR (HC11 SCI) |
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,236 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recursive-descent 68HC11 disassembler for the RIO board firmware.
|
||||
|
||||
The RIO board (Toshiba TMP68HC11 + AM27C512) speaks the PCSPAK serial
|
||||
protocol to the game. This tool disassembles RIOv4_2.bin to help locate the
|
||||
receive/reply state machine and the wedge where the reply path dies under
|
||||
stress. Address == file offset for this image (EPROM occupies $C000-$FFFF,
|
||||
reset vector $FFFE -> $C000 confirms).
|
||||
|
||||
Usage: python disasm_6811.py [RIOv4_2.bin] > RIOv4_2.disasm.asm
|
||||
"""
|
||||
import sys
|
||||
|
||||
# ---- HC11 internal register block ($1000-$103F) annotations --------------
|
||||
REG = {
|
||||
0x00:"PORTA",0x02:"PIOC",0x03:"PORTC",0x04:"PORTB",0x05:"PORTCL",
|
||||
0x07:"DDRC",0x08:"PORTD",0x09:"DDRD",0x0A:"PORTE",0x0B:"CFORC",
|
||||
0x0C:"OC1M",0x0D:"OC1D",0x0E:"TCNT",0x10:"TIC1",0x12:"TIC2",
|
||||
0x14:"TIC3",0x16:"TOC1",0x18:"TOC2",0x1A:"TOC3",0x1C:"TOC4",
|
||||
0x1E:"TI4O5",0x20:"TCTL1",0x21:"TCTL2",0x22:"TMSK1",0x23:"TFLG1",
|
||||
0x24:"TMSK2",0x25:"TFLG2",0x26:"PACTL",0x27:"PACNT",0x28:"SPCR",
|
||||
0x29:"SPSR",0x2A:"SPDR",0x2B:"BAUD",0x2C:"SCCR1",0x2D:"SCCR2",
|
||||
0x2E:"SCSR",0x2F:"SCDR",0x30:"ADCTL",0x31:"ADR1",0x32:"ADR2",
|
||||
0x33:"ADR3",0x34:"ADR4",0x39:"OPTION",0x3A:"COPRST",0x3B:"PPROG",
|
||||
0x3C:"HPRIO",0x3D:"INIT",0x3E:"TEST1",0x3F:"CONFIG",
|
||||
}
|
||||
def reg_ann(addr):
|
||||
if 0x1000 <= addr <= 0x103F and (addr-0x1000) in REG:
|
||||
return " ; "+REG[addr-0x1000]
|
||||
return ""
|
||||
|
||||
# addressing modes and their extra operand byte counts
|
||||
INH,IMM8,IMM16,DIR,EXT,IDX,IDY,REL = range(8)
|
||||
BSET_DIR,BCLR_DIR,BRSET_DIR,BRCLR_DIR = range(8,12)
|
||||
BSET_IDX,BCLR_IDX,BRSET_IDX,BRCLR_IDX = range(12,16)
|
||||
MODELEN = {INH:0,IMM8:1,IMM16:2,DIR:1,EXT:2,IDX:1,IDY:1,REL:1,
|
||||
BSET_DIR:2,BCLR_DIR:2,BRSET_DIR:3,BRCLR_DIR:3,
|
||||
BSET_IDX:2,BCLR_IDX:2,BRSET_IDX:3,BRCLR_IDX:3}
|
||||
|
||||
# base page opcode table: opcode -> (mnemonic, mode)
|
||||
OP = {
|
||||
0x00:("TEST",INH),0x01:("NOP",INH),0x02:("IDIV",INH),0x03:("FDIV",INH),
|
||||
0x04:("LSRD",INH),0x05:("LSLD",INH),0x06:("TAP",INH),0x07:("TPA",INH),
|
||||
0x08:("INX",INH),0x09:("DEX",INH),0x0A:("CLV",INH),0x0B:("SEV",INH),
|
||||
0x0C:("CLC",INH),0x0D:("SEC",INH),0x0E:("CLI",INH),0x0F:("SEI",INH),
|
||||
0x10:("SBA",INH),0x11:("CBA",INH),0x12:("BRSET",BRSET_DIR),0x13:("BRCLR",BRCLR_DIR),
|
||||
0x14:("BSET",BSET_DIR),0x15:("BCLR",BCLR_DIR),0x16:("TAB",INH),0x17:("TBA",INH),
|
||||
0x19:("DAA",INH),0x1B:("ABA",INH),
|
||||
0x1C:("BSET",BSET_IDX),0x1D:("BCLR",BCLR_IDX),0x1E:("BRSET",BRSET_IDX),0x1F:("BRCLR",BRCLR_IDX),
|
||||
0x20:("BRA",REL),0x21:("BRN",REL),0x22:("BHI",REL),0x23:("BLS",REL),
|
||||
0x24:("BCC",REL),0x25:("BCS",REL),0x26:("BNE",REL),0x27:("BEQ",REL),
|
||||
0x28:("BVC",REL),0x29:("BVS",REL),0x2A:("BPL",REL),0x2B:("BMI",REL),
|
||||
0x2C:("BGE",REL),0x2D:("BLT",REL),0x2E:("BGT",REL),0x2F:("BLE",REL),
|
||||
0x30:("TSX",INH),0x31:("INS",INH),0x32:("PULA",INH),0x33:("PULB",INH),
|
||||
0x34:("DES",INH),0x35:("TXS",INH),0x36:("PSHA",INH),0x37:("PSHB",INH),
|
||||
0x38:("PULX",INH),0x39:("RTS",INH),0x3A:("ABX",INH),0x3B:("RTI",INH),
|
||||
0x3C:("PSHX",INH),0x3D:("MUL",INH),0x3E:("WAI",INH),0x3F:("SWI",INH),
|
||||
0x40:("NEGA",INH),0x43:("COMA",INH),0x44:("LSRA",INH),0x46:("RORA",INH),
|
||||
0x47:("ASRA",INH),0x48:("LSLA",INH),0x49:("ROLA",INH),0x4A:("DECA",INH),
|
||||
0x4C:("INCA",INH),0x4D:("TSTA",INH),0x4F:("CLRA",INH),
|
||||
0x50:("NEGB",INH),0x53:("COMB",INH),0x54:("LSRB",INH),0x56:("RORB",INH),
|
||||
0x57:("ASRB",INH),0x58:("LSLB",INH),0x59:("ROLB",INH),0x5A:("DECB",INH),
|
||||
0x5C:("INCB",INH),0x5D:("TSTB",INH),0x5F:("CLRB",INH),
|
||||
0x60:("NEG",IDX),0x63:("COM",IDX),0x64:("LSR",IDX),0x66:("ROR",IDX),
|
||||
0x67:("ASR",IDX),0x68:("LSL",IDX),0x69:("ROL",IDX),0x6A:("DEC",IDX),
|
||||
0x6C:("INC",IDX),0x6D:("TST",IDX),0x6E:("JMP",IDX),0x6F:("CLR",IDX),
|
||||
0x70:("NEG",EXT),0x73:("COM",EXT),0x74:("LSR",EXT),0x76:("ROR",EXT),
|
||||
0x77:("ASR",EXT),0x78:("LSL",EXT),0x79:("ROL",EXT),0x7A:("DEC",EXT),
|
||||
0x7C:("INC",EXT),0x7D:("TST",EXT),0x7E:("JMP",EXT),0x7F:("CLR",EXT),
|
||||
0x80:("SUBA",IMM8),0x81:("CMPA",IMM8),0x82:("SBCA",IMM8),0x83:("SUBD",IMM16),
|
||||
0x84:("ANDA",IMM8),0x85:("BITA",IMM8),0x86:("LDAA",IMM8),0x88:("EORA",IMM8),
|
||||
0x89:("ADCA",IMM8),0x8A:("ORAA",IMM8),0x8B:("ADDA",IMM8),0x8C:("CPX",IMM16),
|
||||
0x8D:("BSR",REL),0x8E:("LDS",IMM16),0x8F:("XGDX",INH),
|
||||
0x90:("SUBA",DIR),0x91:("CMPA",DIR),0x92:("SBCA",DIR),0x93:("SUBD",DIR),
|
||||
0x94:("ANDA",DIR),0x95:("BITA",DIR),0x96:("LDAA",DIR),0x97:("STAA",DIR),
|
||||
0x98:("EORA",DIR),0x99:("ADCA",DIR),0x9A:("ORAA",DIR),0x9B:("ADDA",DIR),
|
||||
0x9C:("CPX",DIR),0x9D:("JSR",DIR),0x9E:("LDS",DIR),0x9F:("STS",DIR),
|
||||
0xA0:("SUBA",IDX),0xA1:("CMPA",IDX),0xA2:("SBCA",IDX),0xA3:("SUBD",IDX),
|
||||
0xA4:("ANDA",IDX),0xA5:("BITA",IDX),0xA6:("LDAA",IDX),0xA7:("STAA",IDX),
|
||||
0xA8:("EORA",IDX),0xA9:("ADCA",IDX),0xAA:("ORAA",IDX),0xAB:("ADDA",IDX),
|
||||
0xAC:("CPX",IDX),0xAD:("JSR",IDX),0xAE:("LDS",IDX),0xAF:("STS",IDX),
|
||||
0xB0:("SUBA",EXT),0xB1:("CMPA",EXT),0xB2:("SBCA",EXT),0xB3:("SUBD",EXT),
|
||||
0xB4:("ANDA",EXT),0xB5:("BITA",EXT),0xB6:("LDAA",EXT),0xB7:("STAA",EXT),
|
||||
0xB8:("EORA",EXT),0xB9:("ADCA",EXT),0xBA:("ORAA",EXT),0xBB:("ADDA",EXT),
|
||||
0xBC:("CPX",EXT),0xBD:("JSR",EXT),0xBE:("LDS",EXT),0xBF:("STS",EXT),
|
||||
0xC0:("SUBB",IMM8),0xC1:("CMPB",IMM8),0xC2:("SBCB",IMM8),0xC3:("ADDD",IMM16),
|
||||
0xC4:("ANDB",IMM8),0xC5:("BITB",IMM8),0xC6:("LDAB",IMM8),0xC8:("EORB",IMM8),
|
||||
0xC9:("ADCB",IMM8),0xCA:("ORAB",IMM8),0xCB:("ADDB",IMM8),0xCC:("LDD",IMM16),
|
||||
0xCE:("LDX",IMM16),0xCF:("STOP",INH),
|
||||
0xD0:("SUBB",DIR),0xD1:("CMPB",DIR),0xD2:("SBCB",DIR),0xD3:("ADDD",DIR),
|
||||
0xD4:("ANDB",DIR),0xD5:("BITB",DIR),0xD6:("LDAB",DIR),0xD7:("STAB",DIR),
|
||||
0xD8:("EORB",DIR),0xD9:("ADCB",DIR),0xDA:("ORAB",DIR),0xDB:("ADDB",DIR),
|
||||
0xDC:("LDD",DIR),0xDD:("STD",DIR),0xDE:("LDX",DIR),0xDF:("STX",DIR),
|
||||
0xE0:("SUBB",IDX),0xE1:("CMPB",IDX),0xE2:("SBCB",IDX),0xE3:("ADDD",IDX),
|
||||
0xE4:("ANDB",IDX),0xE5:("BITB",IDX),0xE6:("LDAB",IDX),0xE7:("STAB",IDX),
|
||||
0xE8:("EORB",IDX),0xE9:("ADCB",IDX),0xEA:("ORAB",IDX),0xEB:("ADDB",IDX),
|
||||
0xEC:("LDD",IDX),0xED:("STD",IDX),0xEE:("LDX",IDX),0xEF:("STX",IDX),
|
||||
0xF0:("SUBB",EXT),0xF1:("CMPB",EXT),0xF2:("SBCB",EXT),0xF3:("ADDD",EXT),
|
||||
0xF4:("ANDB",EXT),0xF5:("BITB",EXT),0xF6:("LDAB",EXT),0xF7:("STAB",EXT),
|
||||
0xF8:("EORB",EXT),0xF9:("ADCB",EXT),0xFA:("ORAB",EXT),0xFB:("ADDB",EXT),
|
||||
0xFC:("LDD",EXT),0xFD:("STD",EXT),0xFE:("LDX",EXT),0xFF:("STX",EXT),
|
||||
}
|
||||
# page 2 ($18): Y-register/Y-indexed forms
|
||||
OP18 = {
|
||||
0x08:("INY",INH),0x09:("DEY",INH),0x1C:("BSET",BSET_IDX),0x1D:("BCLR",BCLR_IDX),
|
||||
0x1E:("BRSET",BRSET_IDX),0x1F:("BRCLR",BRCLR_IDX),0x30:("TSY",INH),0x35:("TYS",INH),
|
||||
0x38:("PULY",INH),0x3A:("ABY",INH),0x3C:("PSHY",INH),0x60:("NEG",IDY),
|
||||
0x63:("COM",IDY),0x64:("LSR",IDY),0x66:("ROR",IDY),0x67:("ASR",IDY),
|
||||
0x68:("LSL",IDY),0x69:("ROL",IDY),0x6A:("DEC",IDY),0x6C:("INC",IDY),
|
||||
0x6D:("TST",IDY),0x6E:("JMP",IDY),0x6F:("CLR",IDY),0x8C:("CPY",IMM16),
|
||||
0x8F:("XGDY",INH),0x9C:("CPY",DIR),0xA0:("SUBA",IDY),0xA1:("CMPA",IDY),
|
||||
0xA2:("SBCA",IDY),0xA3:("SUBD",IDY),0xA4:("ANDA",IDY),0xA5:("BITA",IDY),
|
||||
0xA6:("LDAA",IDY),0xA7:("STAA",IDY),0xA8:("EORA",IDY),0xA9:("ADCA",IDY),
|
||||
0xAA:("ORAA",IDY),0xAB:("ADDA",IDY),0xAC:("CPY",IDY),0xAD:("JSR",IDY),
|
||||
0xAE:("LDS",IDY),0xAF:("STS",IDY),0xBC:("CPY",EXT),0xCE:("LDY",IMM16),
|
||||
0xDE:("LDY",DIR),0xDF:("STY",DIR),0xE0:("SUBB",IDY),0xE1:("CMPB",IDY),
|
||||
0xE2:("SBCB",IDY),0xE3:("ADDD",IDY),0xE4:("ANDB",IDY),0xE5:("BITB",IDY),
|
||||
0xE6:("LDAB",IDY),0xE7:("STAB",IDY),0xE8:("EORB",IDY),0xE9:("ADCB",IDY),
|
||||
0xEA:("ORAB",IDY),0xEB:("ADDB",IDY),0xEC:("LDD",IDY),0xED:("STD",IDY),
|
||||
0xEE:("LDY",IDY),0xEF:("STY",IDY),0xBE:("LDY",EXT),0xBF:("STY",EXT),
|
||||
}
|
||||
OP1A = {0x83:("CPD",IMM16),0x93:("CPD",DIR),0xA3:("CPD",IDX),0xB3:("CPD",EXT),
|
||||
0xAC:("CPY",IDX),0xEE:("LDY",IDX),0xEF:("STY",IDX)}
|
||||
OPCD = {0xA3:("CPD",IDY),0xAC:("CPX",IDY),0xEE:("LDX",IDY),0xEF:("STX",IDY)}
|
||||
|
||||
def u16(d,a): return (d[a]<<8)|d[a+1]
|
||||
|
||||
class Insn:
|
||||
__slots__=("addr","end","mnem","mode","opbytes","txt","target","flow")
|
||||
def __init__(s,**k):
|
||||
for n,v in k.items(): setattr(s,n,v)
|
||||
|
||||
def decode(d,a):
|
||||
"""Decode one instruction at address a. Returns Insn or None (illegal)."""
|
||||
start=a; op=d[a]; a+=1; pfx=None; table=OP
|
||||
if op==0x18: pfx=0x18; table=OP18; op=d[a]; a+=1
|
||||
elif op==0x1A: pfx=0x1A; table=OP1A; op=d[a]; a+=1
|
||||
elif op==0xCD: pfx=0xCD; table=OPCD; op=d[a]; a+=1
|
||||
ent=table.get(op)
|
||||
if ent is None: return None
|
||||
mnem,mode=ent; n=MODELEN[mode]; ops=d[a:a+n]; a+=n
|
||||
idxreg="Y" if (mode in (IDY,) or pfx in (0x18,) and mode in (IDX,)) else "X"
|
||||
if pfx==0xCD: idxreg="Y"
|
||||
if pfx==0x1A and mode==IDY: idxreg="Y"
|
||||
tgt=None; flow="seq"; ann=""
|
||||
if mode==INH: txt=mnem
|
||||
elif mode==IMM8: txt=f"{mnem} #${ops[0]:02X}"
|
||||
elif mode==IMM16:
|
||||
v=(ops[0]<<8)|ops[1]; txt=f"{mnem} #${v:04X}"
|
||||
elif mode==DIR:
|
||||
txt=f"{mnem} ${ops[0]:02X}"; ann=reg_ann(ops[0])
|
||||
elif mode==EXT:
|
||||
v=(ops[0]<<8)|ops[1]; txt=f"{mnem} ${v:04X}"; ann=reg_ann(v)
|
||||
if mnem=="JSR": tgt=v; flow="call"
|
||||
elif mnem=="JMP": tgt=v; flow="jump"
|
||||
elif mode==IDX or mode==IDY:
|
||||
txt=f"{mnem} ${ops[0]:02X},{idxreg}"
|
||||
elif mode==REL:
|
||||
rel=ops[0]-256 if ops[0]>127 else ops[0]; tgt=a+rel
|
||||
txt=f"{mnem} ${tgt:04X}"
|
||||
if mnem=="BRA": flow="jump"
|
||||
elif mnem=="BSR": flow="call"
|
||||
else: flow="branch"
|
||||
elif mode in (BSET_DIR,BCLR_DIR):
|
||||
txt=f"{mnem} ${ops[0]:02X},#${ops[1]:02X}"; ann=reg_ann(ops[0])
|
||||
elif mode in (BSET_IDX,BCLR_IDX):
|
||||
txt=f"{mnem} ${ops[0]:02X},{idxreg},#${ops[1]:02X}"
|
||||
elif mode in (BRSET_DIR,BRCLR_DIR):
|
||||
rel=ops[2]-256 if ops[2]>127 else ops[2]; tgt=a+rel
|
||||
txt=f"{mnem} ${ops[0]:02X},#${ops[1]:02X},${tgt:04X}"; ann=reg_ann(ops[0]); flow="branch"
|
||||
elif mode in (BRSET_IDX,BRCLR_IDX):
|
||||
rel=ops[2]-256 if ops[2]>127 else ops[2]; tgt=a+rel
|
||||
txt=f"{mnem} ${ops[0]:02X},{idxreg},#${ops[1]:02X},${tgt:04X}"; flow="branch"
|
||||
else: txt=mnem
|
||||
if mnem in ("RTS","RTI","JMP","BRA","STOP","WAI") and flow not in ("call","branch"):
|
||||
if mnem in ("RTS","RTI","STOP"): flow="end"
|
||||
ob=bytes([d[i] for i in range(start,a)])
|
||||
return Insn(addr=start,end=a,mnem=mnem,mode=mode,opbytes=ob,
|
||||
txt=txt+ann,target=tgt,flow=flow)
|
||||
|
||||
def main():
|
||||
path=sys.argv[1] if len(sys.argv)>1 else "RIOv4_2.bin"
|
||||
d=open(path,"rb").read()
|
||||
LO,HI=0xC000,0x10000
|
||||
# entry points: reset + all IRQ vectors
|
||||
entries=set()
|
||||
for va in range(0xFFD6,0x10000,2):
|
||||
entries.add(u16(d,va))
|
||||
# The RX/TX protocol runs as a pointer state machine: handlers are stored
|
||||
# into dispatch-pointer RAM vars and reached via `JMP $00,X`, which a
|
||||
# recursive tracer can't follow. Seed every handler by scanning for
|
||||
# `LDX #imm16 ; STX <dispatchvar>` (CE iw FF pp) and taking imm16.
|
||||
DISPATCH={0x292F,0x2927,0x2929,0x292B,0x2D3B,0x2D34,0x2D36,0x2D38}
|
||||
for a in range(LO,HI-5):
|
||||
if d[a]==0xCE and d[a+3]==0xFF:
|
||||
iw=(d[a+1]<<8)|d[a+2]; pp=(d[a+4]<<8)|d[a+5]
|
||||
if pp in DISPATCH and LO<=iw<HI:
|
||||
entries.add(iw)
|
||||
insns={}; labels=set(); calls=set()
|
||||
work=[e for e in entries if LO<=e<HI]
|
||||
for e in work: labels.add(e)
|
||||
seen=set()
|
||||
while work:
|
||||
a=work.pop()
|
||||
while LO<=a<HI and a not in insns:
|
||||
ins=decode(d,a)
|
||||
if ins is None: break
|
||||
insns[a]=ins
|
||||
if ins.target is not None and LO<=ins.target<HI:
|
||||
labels.add(ins.target)
|
||||
if ins.flow in ("call",): calls.add(ins.target)
|
||||
if ins.flow in ("call","branch","jump"):
|
||||
if ins.target not in insns: work.append(ins.target)
|
||||
if ins.flow in ("end","jump"): break
|
||||
a=ins.end
|
||||
# emit listing
|
||||
out=[]
|
||||
a=LO
|
||||
while a<HI:
|
||||
if a in insns:
|
||||
ins=insns[a]
|
||||
lbl=f"L{a:04X}:" if a in labels else ""
|
||||
mark=" <<<CALLED" if a in calls else ""
|
||||
hexb=" ".join(f"{b:02X}" for b in ins.opbytes)
|
||||
out.append(f"{a:04X} {hexb:<20} {lbl:<8}{ins.txt}{mark}")
|
||||
a=ins.end
|
||||
else:
|
||||
# data byte
|
||||
out.append(f"{a:04X} {d[a]:02X} .byte ${d[a]:02X}")
|
||||
a+=1
|
||||
sys.stdout.write("\n".join(out)+"\n")
|
||||
sys.stderr.write(f"decoded {len(insns)} insns, {len(labels)} labels, "
|
||||
f"{len(calls)} call targets\n")
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply the RIO v4.2 reply-wedge fix to RIOv4_2.bin -> RIOv4_2_patched.bin.
|
||||
|
||||
Fix (see RIOv4_2-ANALYSIS.md): clear the reply-in-progress latch $2521 on
|
||||
EVERY reply teardown, not just the $2522-gated success path.
|
||||
|
||||
1. Give-up path: redirect $D9DD `JMP $DA2F` to a stub at free ROM $DFF0
|
||||
that clears $2521/$2522 then continues to $DA2F.
|
||||
2. Success path: make $DA00's clear of $2521/$2522 unconditional.
|
||||
|
||||
Each edit asserts the exact original bytes first, so a wrong assumption
|
||||
aborts instead of corrupting the image. Address == file offset.
|
||||
"""
|
||||
import sys, hashlib
|
||||
|
||||
SRC = sys.argv[1] if len(sys.argv) > 1 else "RIOv4_2.bin"
|
||||
DST = sys.argv[2] if len(sys.argv) > 2 else "RIOv4_2_patched.bin"
|
||||
|
||||
d = bytearray(open(SRC, "rb").read())
|
||||
assert len(d) == 0x10000, f"expected 64KB image, got {len(d)}"
|
||||
orig_sha = hashlib.sha256(d).hexdigest()
|
||||
assert orig_sha == "60a88718835c654b6135dbec7721c40ef99dca07df2ad4b57eedeb24037a5f73", \
|
||||
f"unexpected source image {orig_sha}"
|
||||
|
||||
def patch(addr, expect, new):
|
||||
got = bytes(d[addr:addr+len(expect)])
|
||||
assert got == bytes(expect), (
|
||||
f"@${addr:04X}: expected {got.hex()} to be {bytes(expect).hex()}")
|
||||
assert len(new) == len(expect), "length mismatch"
|
||||
d[addr:addr+len(new)] = bytes(new)
|
||||
|
||||
# --- edit 1: give-up path redirect ---------------------------------------
|
||||
# $D9DD 7E DA 2F JMP $DA2F -> 7E DF F0 JMP $DFF0
|
||||
patch(0xD9DD, [0x7E, 0xDA, 0x2F], [0x7E, 0xDF, 0xF0])
|
||||
|
||||
# stub at $DFF0 (was erased $FF): CLR $2521; CLR $2522; JMP $DA2F
|
||||
patch(0xDFF0, [0xFF]*8,
|
||||
[0x7F, 0x25, 0x21, # CLR $2521
|
||||
0x7F, 0x25, 0x22, # CLR $2522
|
||||
0x7E, 0xDA]) # JMP $DA2F (hi + first target byte)
|
||||
patch(0xDFF8, [0xFF], [0x2F]) # JMP low byte
|
||||
|
||||
# --- edit 2: success teardown, unconditional clear -----------------------
|
||||
# $DA21 B6 25 22 LDAA $2522
|
||||
# $DA24 81 01 CMPA #$01
|
||||
# $DA26 26 06 BNE $DA2E
|
||||
# $DA28 7F 25 21 CLR $2521
|
||||
# $DA2B 7F 25 22 CLR $2522 (13 bytes $DA21-$DA2D; $DA2E RTS untouched)
|
||||
# -> CLR $2521 ; CLR $2522 ; NOP x7
|
||||
patch(0xDA21,
|
||||
[0xB6,0x25,0x22, 0x81,0x01, 0x26,0x06, 0x7F,0x25,0x21, 0x7F,0x25,0x22],
|
||||
[0x7F,0x25,0x21, 0x7F,0x25,0x22, 0x01,0x01,0x01,0x01,0x01,0x01,0x01])
|
||||
assert d[0xDA2E] == 0x39, "RTS at $DA2E must be intact"
|
||||
|
||||
open(DST, "wb").write(d)
|
||||
new_sha = hashlib.sha256(d).hexdigest()
|
||||
# byte-diff report
|
||||
diffs = [(a, orig, d[a]) for a, orig in
|
||||
enumerate(open(SRC,"rb").read()) if d[a] != orig]
|
||||
print(f"source : {SRC} sha256 {orig_sha}")
|
||||
print(f"patched: {DST} sha256 {new_sha}")
|
||||
print(f"{len(diffs)} bytes changed:")
|
||||
for a, o, n in diffs:
|
||||
print(f" ${a:04X}: {o:02X} -> {n:02X}")
|
||||
@@ -10,8 +10,12 @@ namespace RioJoy.Core.Calibration;
|
||||
/// across <see cref="Update"/> calls exactly as the legacy globals did.
|
||||
///
|
||||
/// <para>Final outputs are clamped to <c>0..<see cref="AxisOutputs.Max"/></c> — the
|
||||
/// documented axis range — which also guards a legacy quirk where a value pinned
|
||||
/// at its observed extreme could compound across polls (see ⚠️ below).</para>
|
||||
/// documented axis range. One legacy quirk is deliberately fixed rather than
|
||||
/// ported: with the lever exactly at the tracked start (the detent), the legacy
|
||||
/// held the previous throttle value, which the ×32 rescale compounded into a
|
||||
/// full-throttle pin — on the very first poll at rest, and for a poll when a
|
||||
/// release landed exactly on the start. The detent now zeroes like the rest of
|
||||
/// the deadzone (see the note in <see cref="Throttle"/>).</para>
|
||||
/// </summary>
|
||||
public sealed class AxisCalibrator
|
||||
{
|
||||
@@ -28,8 +32,11 @@ public sealed class AxisCalibrator
|
||||
private int _leftPedalStart = int.MaxValue;
|
||||
private int _rightPedalStart = int.MaxValue;
|
||||
|
||||
// Last computed (pre-clamp) outputs — persist across calls like the legacy globals.
|
||||
private int _throttleLast = AxisOutputs.Center;
|
||||
// Last computed (pre-clamp) outputs — persist across calls like the legacy
|
||||
// globals. The throttle starts at 0, its calibrated rest value (the same value
|
||||
// ResetThrottle/ResetAll restore): the legacy Center init fed the detent-hold
|
||||
// quirk (see Throttle) and pinned Z at full on the first poll at rest.
|
||||
private int _throttleLast;
|
||||
private int _leftPedalLast = AxisOutputs.Center;
|
||||
private int _rightPedalLast = AxisOutputs.Center;
|
||||
private int _joystickXLast = AxisOutputs.Center;
|
||||
@@ -98,17 +105,20 @@ public sealed class AxisCalibrator
|
||||
if (lT > 800)
|
||||
lT = 800;
|
||||
|
||||
if (lT != 0)
|
||||
{
|
||||
lT = lT * 1000 / 800;
|
||||
if (lT is > -DeadzoneThrottle and < DeadzoneThrottle)
|
||||
_throttleLast = 0;
|
||||
else if (_throttleResult > 0) // back
|
||||
_throttleLast = 900 + (lT * _throttleResult / 10);
|
||||
else // front
|
||||
_throttleLast = lT * _throttleResult;
|
||||
}
|
||||
// lT == 0 leaves _throttleLast unchanged (legacy behavior).
|
||||
// Deliberate divergence from the legacy port: lT == 0 (lever exactly at
|
||||
// the tracked start, i.e. the detent — including samples that raise the
|
||||
// running max) zeroes the output like the rest of the ±50 deadzone.
|
||||
// The legacy left _throttleLast unchanged here, and the ×32 rescale
|
||||
// below then compounded the held value — pinning Z at full throttle on
|
||||
// the first poll at rest and spiking full for a poll when a release
|
||||
// landed exactly on the start.
|
||||
lT = lT * 1000 / 800;
|
||||
if (lT is > -DeadzoneThrottle and < DeadzoneThrottle)
|
||||
_throttleLast = 0;
|
||||
else if (_throttleResult > 0) // back
|
||||
_throttleLast = 900 + (lT * _throttleResult / 10);
|
||||
else // front
|
||||
_throttleLast = lT * _throttleResult;
|
||||
}
|
||||
|
||||
_throttleLast = Math.Abs(_throttleLast * 32);
|
||||
|
||||
@@ -10,10 +10,12 @@ namespace System.Collections.Generic
|
||||
/// <summary>
|
||||
/// Polyfill for CollectionExtensions.GetValueOrDefault (netstandard2.1+),
|
||||
/// absent on net48. Returns the value for <paramref name="key"/> or
|
||||
/// default(TValue) when the key is missing.
|
||||
/// default(TValue) when the key is missing. Takes IDictionary rather than
|
||||
/// IReadOnlyDictionary: net40 (Windows XP flavor) predates the IReadOnly*
|
||||
/// interfaces, so shared code uses the classic ones throughout.
|
||||
/// </summary>
|
||||
public static TValue? GetValueOrDefault<TKey, TValue>(
|
||||
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
|
||||
this IDictionary<TKey, TValue> dictionary, TKey key)
|
||||
{
|
||||
if (dictionary is null) throw new ArgumentNullException(nameof(dictionary));
|
||||
return dictionary.TryGetValue(key, out var value) ? value : default;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace RioJoy.Core.Compat;
|
||||
|
||||
/// <summary>
|
||||
/// net48/net40 bridge for the handful of Task-era statics the code uses. On
|
||||
/// net40 (the Windows XP flavor) these come from Microsoft.Bcl.Async's
|
||||
/// <c>TaskEx</c>, and <c>SemaphoreSlim.WaitAsync</c> doesn't exist at all —
|
||||
/// there the wait blocks briefly instead, which at 9600 baud (tiny writes,
|
||||
/// rare contention) costs nothing measurable.
|
||||
/// </summary>
|
||||
internal static class TaskCompat
|
||||
{
|
||||
#if NET40
|
||||
public static Task Run(Action action) => TaskEx.Run(action);
|
||||
|
||||
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||
TaskEx.Delay(delay, cancellationToken);
|
||||
|
||||
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => TaskEx.WhenAny(tasks);
|
||||
|
||||
public static Task WhenAll(IEnumerable<Task> tasks) => TaskEx.WhenAll(tasks);
|
||||
|
||||
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken)
|
||||
{
|
||||
semaphore.Wait(cancellationToken); // net40: no WaitAsync; block (see class doc)
|
||||
return TaskEx.FromResult(true);
|
||||
}
|
||||
#else
|
||||
public static Task Run(Action action) => Task.Run(action);
|
||||
|
||||
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
|
||||
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => Task.WhenAny(tasks);
|
||||
|
||||
public static Task WhenAll(IEnumerable<Task> tasks) => Task.WhenAll(tasks);
|
||||
|
||||
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken) =>
|
||||
semaphore.WaitAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed record PanelGroup(
|
||||
bool LampCapable,
|
||||
int OriginCol,
|
||||
int OriginRow,
|
||||
IReadOnlyList<int?> Addresses);
|
||||
IList<int?> Addresses);
|
||||
|
||||
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
|
||||
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
|
||||
@@ -66,7 +66,7 @@ public static class CockpitPanel
|
||||
}
|
||||
|
||||
/// <summary>All panel groups, positioned for rendering.</summary>
|
||||
public static IReadOnlyList<PanelGroup> Groups { get; } = new[]
|
||||
public static IList<PanelGroup> Groups { get; } = new[]
|
||||
{
|
||||
// Upper MFD row.
|
||||
new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
|
||||
@@ -90,7 +90,7 @@ public static class CockpitPanel
|
||||
};
|
||||
|
||||
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
|
||||
public static IReadOnlyList<PanelButton> Buttons()
|
||||
public static IList<PanelButton> Buttons()
|
||||
{
|
||||
var buttons = new List<PanelButton>();
|
||||
foreach (PanelGroup g in Groups)
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed record SheetCell(int Row, int Col, string Text, int? Address, Shee
|
||||
/// </summary>
|
||||
public static class SheetLayout
|
||||
{
|
||||
public static IReadOnlyList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
|
||||
public static IList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
|
||||
|
||||
/// <summary>
|
||||
/// The RIO address a cell's text encodes (two hex digits in 0x00–0x6F), or null.
|
||||
@@ -66,7 +66,7 @@ public static class SheetLayout
|
||||
/// longer than <paramref name="maxTextLength"/> is dropped as a merged-cell
|
||||
/// artifact; the clean export has none.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
|
||||
public static IList<SheetCell> Parse(string csv, int maxTextLength = 40)
|
||||
{
|
||||
if (csv is null) throw new ArgumentNullException(nameof(csv));
|
||||
|
||||
|
||||
@@ -43,10 +43,7 @@ public sealed class RioHidReport
|
||||
SetHat(RioHat.Centered);
|
||||
}
|
||||
|
||||
/// <summary>The current report bytes (length <see cref="Size"/>).</summary>
|
||||
public ReadOnlySpan<byte> Bytes => _buffer;
|
||||
|
||||
/// <summary>Copy of the current report bytes.</summary>
|
||||
/// <summary>Copy of the current report bytes (length <see cref="Size"/>).</summary>
|
||||
public byte[] ToArray() => (byte[])_buffer.Clone();
|
||||
|
||||
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
|
||||
|
||||
@@ -13,7 +13,7 @@ public readonly record struct KeyName(string Name, byte Value);
|
||||
public static class KeyCatalog
|
||||
{
|
||||
/// <summary>All catalogued keys, in menu order (letters, digits, F-keys, …).</summary>
|
||||
public static IReadOnlyList<KeyName> Entries { get; } = Build();
|
||||
public static IList<KeyName> Entries { get; } = Build();
|
||||
|
||||
private static readonly Dictionary<byte, string> ByValue =
|
||||
Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class RioInputMap
|
||||
public RioInputMap() { }
|
||||
|
||||
/// <summary>Create a map from raw 16-bit words indexed by address.</summary>
|
||||
public RioInputMap(IReadOnlyDictionary<int, ushort> entries)
|
||||
public RioInputMap(IDictionary<int, ushort> entries)
|
||||
{
|
||||
if (entries is null) throw new ArgumentNullException(nameof(entries));
|
||||
foreach ((int address, ushort raw) in entries)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
|
||||
namespace RioJoy.Core.Output;
|
||||
|
||||
/// <summary>
|
||||
/// A resolved axis write: which pad control to drive and the converted value.
|
||||
/// <see cref="Value"/> is in the thumb range (<c>-32768..32767</c>) for thumb
|
||||
/// targets, the trigger range (<c>0..255</c>) for trigger targets, and 0 for
|
||||
/// <see cref="PadTarget.None"/> (nothing is written).
|
||||
/// </summary>
|
||||
public readonly struct ResolvedAxis
|
||||
{
|
||||
public PadTarget Target { get; }
|
||||
|
||||
public int Value { get; }
|
||||
|
||||
public ResolvedAxis(PadTarget target, int value)
|
||||
{
|
||||
Target = target;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Target}:{Value}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure route-resolution + value-conversion for the ViGEm sink — no ViGEm types,
|
||||
/// so the decision logic is unit-testable without the bus driver.
|
||||
/// <see cref="ViGEmJoystickSink"/> translates the result into ViGEm calls.
|
||||
/// </summary>
|
||||
public static class AxisRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve where the calibrated <paramref name="value"/> (<c>0..32766</c>,
|
||||
/// center 16383) of <paramref name="axis"/> lands under
|
||||
/// <paramref name="config"/>, and convert it for that target.
|
||||
/// </summary>
|
||||
public static ResolvedAxis Resolve(AxisRoutingConfig config, JoyAxis axis, int value)
|
||||
{
|
||||
if (config is null) throw new ArgumentNullException(nameof(config));
|
||||
|
||||
AxisRoute route = config.RouteFor(axis);
|
||||
return route.Target switch
|
||||
{
|
||||
PadTarget.None => new ResolvedAxis(PadTarget.None, 0),
|
||||
PadTarget.LeftTrigger or PadTarget.RightTrigger =>
|
||||
new ResolvedAxis(route.Target, ToTrigger(value)),
|
||||
_ => new ResolvedAxis(
|
||||
route.Target,
|
||||
route.Mode == AxisOutputMode.UnipolarPositive
|
||||
? ToUnipolarThumb(value)
|
||||
: ToCenteredThumb(value)),
|
||||
};
|
||||
}
|
||||
|
||||
// RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767 (legacy math).
|
||||
private static int ToCenteredThumb(int value) =>
|
||||
Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, (int)short.MinValue, short.MaxValue);
|
||||
|
||||
// Unipolar RIO axis (rest = 0) -> upper thumb half: 0 -> center 0, 32766 -> max.
|
||||
private static int ToUnipolarThumb(int value) =>
|
||||
Compat.Net48Math.Clamp(value, 0, (int)short.MaxValue);
|
||||
|
||||
// RIO axis 0..32766 -> Xbox trigger byte 0..255 (legacy math).
|
||||
private static int ToTrigger(int value) =>
|
||||
Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
|
||||
namespace RioJoy.Core.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Where a calibrated axis lands on the virtual Xbox 360 pad. Our own enum (no
|
||||
/// ViGEm types — these are shared config types, serialized into profiles and
|
||||
/// compiled for net40 too); <see cref="ViGEmJoystickSink"/> translates to the
|
||||
/// ViGEm equivalents. <see cref="None"/> = the axis is not emitted at all.
|
||||
/// </summary>
|
||||
public enum PadTarget
|
||||
{
|
||||
LeftThumbX,
|
||||
LeftThumbY,
|
||||
RightThumbX,
|
||||
RightThumbY,
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a calibrated axis value (<c>0..32766</c>, center 16383) converts to a
|
||||
/// thumb-stick value. Meaningless for trigger targets (triggers always use the
|
||||
/// legacy <c>value*255/32766</c> byte conversion).
|
||||
/// </summary>
|
||||
public enum AxisOutputMode
|
||||
{
|
||||
/// <summary>Legacy bipolar mapping: <c>(value - 16383) * 2</c> → center 16383 = thumb 0.</summary>
|
||||
Centered,
|
||||
|
||||
/// <summary>
|
||||
/// Unipolar mapping for axes whose calibrated rest is 0 (the ratcheted
|
||||
/// throttle): <c>clamp(value, 0, 32767)</c> → calibrated 0 = thumb center 0,
|
||||
/// 32766 = thumb max. Only the upper half of the thumb range is used.
|
||||
/// </summary>
|
||||
UnipolarPositive,
|
||||
}
|
||||
|
||||
/// <summary>One axis route: which pad control it drives, and how the value converts.</summary>
|
||||
public sealed record AxisRoute
|
||||
{
|
||||
public PadTarget Target { get; init; } = PadTarget.None;
|
||||
|
||||
public AxisOutputMode Mode { get; init; } = AxisOutputMode.Centered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-profile routing of the six calibrated axes onto the ViGEm Xbox 360 pad
|
||||
/// (<see cref="RioJoy.Core.Profiles.RioProfile.AxisRouting"/>; null there = this
|
||||
/// default). The defaults reproduce the historical hardcoded sink routing
|
||||
/// exactly — X→LeftThumbX, Y→LeftThumbY, Rx→RightThumbX, Ry→RightThumbY,
|
||||
/// Z→LeftTrigger, Rz→RightTrigger, all <see cref="AxisOutputMode.Centered"/> —
|
||||
/// so existing profiles behave identically. Two axes routed to the same target
|
||||
/// are not arbitrated: the last <c>SetAxis</c> write wins.
|
||||
/// </summary>
|
||||
public sealed record AxisRoutingConfig
|
||||
{
|
||||
public AxisRoute X { get; init; } = new() { Target = PadTarget.LeftThumbX };
|
||||
|
||||
public AxisRoute Y { get; init; } = new() { Target = PadTarget.LeftThumbY };
|
||||
|
||||
public AxisRoute Z { get; init; } = new() { Target = PadTarget.LeftTrigger };
|
||||
|
||||
public AxisRoute Rx { get; init; } = new() { Target = PadTarget.RightThumbX };
|
||||
|
||||
public AxisRoute Ry { get; init; } = new() { Target = PadTarget.RightThumbY };
|
||||
|
||||
public AxisRoute Rz { get; init; } = new() { Target = PadTarget.RightTrigger };
|
||||
|
||||
/// <summary>The route for <paramref name="axis"/>.</summary>
|
||||
public AxisRoute RouteFor(JoyAxis axis) => axis switch
|
||||
{
|
||||
JoyAxis.X => X,
|
||||
JoyAxis.Y => Y,
|
||||
JoyAxis.Z => Z,
|
||||
JoyAxis.Rx => Rx,
|
||||
JoyAxis.Ry => Ry,
|
||||
JoyAxis.Rz => Rz,
|
||||
_ => new AxisRoute(), // unknown axis → None (not emitted)
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,15 @@ public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
|
||||
public static bool TryCreate(out HidFeederJoystickSink? sink)
|
||||
{
|
||||
sink = null;
|
||||
#if NET40
|
||||
// Windows XP: RioGamepadXP is a HID minidriver whose sideband control
|
||||
// device can't carry a device interface (no PDO), so it's opened by its
|
||||
// fixed symbolic-link name rather than via SetupDi + interface GUID.
|
||||
// Contract is otherwise identical (see driver/RioGamepadXP/Public.h).
|
||||
string? path = @"\\.\RioGamepadXP";
|
||||
#else
|
||||
string? path = FindDevicePath();
|
||||
#endif
|
||||
if (path is null)
|
||||
return false;
|
||||
|
||||
@@ -114,7 +122,7 @@ public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
|
||||
try
|
||||
{
|
||||
var ifData = new SP_DEVICE_INTERFACE_DATA();
|
||||
ifData.cbSize = Marshal.SizeOf<SP_DEVICE_INTERFACE_DATA>();
|
||||
ifData.cbSize = Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA));
|
||||
|
||||
if (!SetupDiEnumDeviceInterfaces(devInfo, IntPtr.Zero, ref guid, 0, ref ifData))
|
||||
return null;
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class SendInputSink : IInputSink
|
||||
}
|
||||
|
||||
private static void Send(INPUT input) =>
|
||||
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
|
||||
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
|
||||
|
||||
// --- Win32 interop --------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if !NET40 // ViGEmBus is Win10+ only; the XP flavor uses RioGamepadXP (PLAN.md §8B)
|
||||
using Nefarius.ViGEm.Client;
|
||||
using Nefarius.ViGEm.Client.Targets;
|
||||
using Nefarius.ViGEm.Client.Targets.Xbox360;
|
||||
@@ -26,7 +27,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
|
||||
};
|
||||
|
||||
/// <summary>Display names for the mappable buttons, aligned with <see cref="ButtonMap"/>.</summary>
|
||||
public static IReadOnlyList<string> ButtonNames { get; } = new[]
|
||||
public static IList<string> ButtonNames { get; } = new[]
|
||||
{
|
||||
"A", "B", "X", "Y", "LB", "RB", "Back", "Start", "L3", "R3", "Guide",
|
||||
};
|
||||
@@ -37,6 +38,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
|
||||
private readonly ViGEmClient _client;
|
||||
private readonly IXbox360Controller _pad;
|
||||
private readonly object _gate = new();
|
||||
private AxisRoutingConfig _routing = new();
|
||||
|
||||
private ViGEmJoystickSink(ViGEmClient client, IXbox360Controller pad)
|
||||
{
|
||||
@@ -44,6 +46,28 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
|
||||
_pad = pad;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a per-profile axis routing (<see langword="null"/> = the default
|
||||
/// legacy routing) and neutralize the pad's axis state — all four thumb axes
|
||||
/// to 0 and both triggers to 0 in one report — so values written under the
|
||||
/// previous routing cannot persist across a profile switch. Thread-safe.
|
||||
/// Two axes routed to the same target are not arbitrated: last writer wins.
|
||||
/// </summary>
|
||||
public void SetRouting(AxisRoutingConfig? routing)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_routing = routing ?? new AxisRoutingConfig();
|
||||
_pad.SetAxisValue(Xbox360Axis.LeftThumbX, 0);
|
||||
_pad.SetAxisValue(Xbox360Axis.LeftThumbY, 0);
|
||||
_pad.SetAxisValue(Xbox360Axis.RightThumbX, 0);
|
||||
_pad.SetAxisValue(Xbox360Axis.RightThumbY, 0);
|
||||
_pad.SetSliderValue(Xbox360Slider.LeftTrigger, 0);
|
||||
_pad.SetSliderValue(Xbox360Slider.RightTrigger, 0);
|
||||
_pad.SubmitReport();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to connect to ViGEmBus and create a virtual Xbox 360 controller. Returns
|
||||
/// <see langword="false"/> if ViGEmBus is not installed, so callers can fall back
|
||||
@@ -93,35 +117,31 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Routing + conversion live in the pure AxisRouter (unit-tested without the
|
||||
// bus); this method only translates the resolved target into ViGEm calls.
|
||||
public void SetAxis(JoyAxis axis, int value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
switch (axis)
|
||||
ResolvedAxis resolved = AxisRouter.Resolve(_routing, axis, value);
|
||||
switch (resolved.Target)
|
||||
{
|
||||
case JoyAxis.X: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, ToThumb(value)); break;
|
||||
case JoyAxis.Y: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, ToThumb(value)); break;
|
||||
case JoyAxis.Rx: _pad.SetAxisValue(Xbox360Axis.RightThumbX, ToThumb(value)); break;
|
||||
case JoyAxis.Ry: _pad.SetAxisValue(Xbox360Axis.RightThumbY, ToThumb(value)); break;
|
||||
case JoyAxis.Z: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, ToTrigger(value)); break;
|
||||
case JoyAxis.Rz: _pad.SetSliderValue(Xbox360Slider.RightTrigger, ToTrigger(value)); break;
|
||||
default: return;
|
||||
case PadTarget.LeftThumbX: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, (short)resolved.Value); break;
|
||||
case PadTarget.LeftThumbY: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, (short)resolved.Value); break;
|
||||
case PadTarget.RightThumbX: _pad.SetAxisValue(Xbox360Axis.RightThumbX, (short)resolved.Value); break;
|
||||
case PadTarget.RightThumbY: _pad.SetAxisValue(Xbox360Axis.RightThumbY, (short)resolved.Value); break;
|
||||
case PadTarget.LeftTrigger: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, (byte)resolved.Value); break;
|
||||
case PadTarget.RightTrigger: _pad.SetSliderValue(Xbox360Slider.RightTrigger, (byte)resolved.Value); break;
|
||||
default: return; // PadTarget.None — the axis is not emitted
|
||||
}
|
||||
_pad.SubmitReport();
|
||||
}
|
||||
}
|
||||
|
||||
// RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767.
|
||||
private static short ToThumb(int value) =>
|
||||
(short)RioJoy.Core.Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue);
|
||||
|
||||
// RIO axis 0..32766 -> Xbox trigger byte 0..255.
|
||||
private static byte ToTrigger(int value) =>
|
||||
(byte)RioJoy.Core.Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ }
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -18,8 +18,8 @@ public static class GoobieDataImporter
|
||||
{
|
||||
/// <summary>A parsed data file: the field names and one label map per row.</summary>
|
||||
public sealed record Sheet(
|
||||
IReadOnlyList<string> Fields,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
|
||||
IList<string> Fields,
|
||||
IList<IDictionary<string, string>> Rows);
|
||||
|
||||
public static Sheet Load(string path) => Parse(File.ReadAllText(path));
|
||||
|
||||
@@ -33,7 +33,7 @@ public static class GoobieDataImporter
|
||||
throw new FormatException("No lists found; expected a header list of field names.");
|
||||
|
||||
List<string> fields = lists[0];
|
||||
var rows = new List<IReadOnlyDictionary<string, string>>();
|
||||
var rows = new List<IDictionary<string, string>>();
|
||||
|
||||
for (int i = 1; i < lists.Count; i++)
|
||||
{
|
||||
@@ -51,7 +51,7 @@ public static class GoobieDataImporter
|
||||
/// Convenience: the label map for a single row (default the first), with empty
|
||||
/// values dropped — ready to hand to <see cref="OverlayLayoutEngine.Layout"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
|
||||
public static IDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
|
||||
{
|
||||
if (sheet is null) throw new ArgumentNullException(nameof(sheet));
|
||||
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class OverlayHitTester
|
||||
/// A region's rectangle is its stored cell geometry; label rotation does not
|
||||
/// affect hit-testing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
|
||||
public static IList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
|
||||
{
|
||||
if (template is null) throw new ArgumentNullException(nameof(template));
|
||||
|
||||
@@ -33,7 +33,7 @@ public static class OverlayHitTester
|
||||
/// </summary>
|
||||
public static OverlayRegion? NextAt(OverlayTemplate template, double x, double y, string? currentName)
|
||||
{
|
||||
IReadOnlyList<OverlayRegion> hits = RegionsAt(template, x, y);
|
||||
IList<OverlayRegion> hits = RegionsAt(template, x, y);
|
||||
if (hits.Count == 0)
|
||||
return null;
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ public sealed class OverlayLayoutEngine
|
||||
/// in <paramref name="labels"/> (or with empty text) are skipped — matching the
|
||||
/// legacy behavior where a blank field produced no visible text.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlacedLabel> Layout(
|
||||
public IList<PlacedLabel> Layout(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
IDictionary<string, string> labels,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
if (template is null) throw new ArgumentNullException(nameof(template));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace RioJoy.Core.Overlay;
|
||||
|
||||
@@ -11,24 +11,24 @@ namespace RioJoy.Core.Overlay;
|
||||
/// </summary>
|
||||
public static class OverlayTemplateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
private static readonly JsonSerializerSettings Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Converters = { new StringEnumConverter() },
|
||||
};
|
||||
|
||||
public static string Serialize(OverlayTemplate template)
|
||||
{
|
||||
if (template is null) throw new ArgumentNullException(nameof(template));
|
||||
return JsonSerializer.Serialize(template, Options);
|
||||
return JsonConvert.SerializeObject(template, Options);
|
||||
}
|
||||
|
||||
public static OverlayTemplate Deserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
|
||||
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
|
||||
?? throw new JsonException("Overlay template JSON deserialized to null.");
|
||||
return JsonConvert.DeserializeObject<OverlayTemplate>(json, Options)
|
||||
?? throw new JsonSerializationException("Overlay template JSON deserialized to null.");
|
||||
}
|
||||
|
||||
public static void Save(OverlayTemplate template, string path)
|
||||
|
||||
@@ -48,5 +48,5 @@ public sealed class PlasmaDisplay
|
||||
}
|
||||
|
||||
private Task WriteAsync(byte[] data, CancellationToken ct) =>
|
||||
_transport.WriteAsync(data, ct).AsTask();
|
||||
_transport.WriteAsync(data, ct);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,28 @@ namespace RioJoy.Core.Profiles;
|
||||
/// </summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
/// <summary>Default RIO COM port when a profile doesn't specify one.</summary>
|
||||
/// <summary>
|
||||
/// Default RIO endpoint when a profile doesn't specify one: a COM port
|
||||
/// name ("COM1"), or "pipe:vrio" to reach the vRIO emulator over its
|
||||
/// named pipe (no com0com pair; see RioTransportFactory).
|
||||
/// </summary>
|
||||
public string DefaultRioComPort { get; set; } = "COM1";
|
||||
|
||||
/// <summary>
|
||||
/// RIO link baud rate. 9600 = stock/wedge-patched firmware (native-game
|
||||
/// compatible). 31250 = the FastRIO chip (TeslaRel410
|
||||
/// restoration/rio-firmware/RIOv4_3_fastrio.bin) — needs an FTDI-class
|
||||
/// adapter; classic 16550 UARTs cannot make this rate.
|
||||
/// </summary>
|
||||
public int RioBaudRate { get; set; } = 9600;
|
||||
|
||||
/// <summary>
|
||||
/// Analog poll interval in milliseconds (legacy cadence 55 → ~18 Hz).
|
||||
/// The FastRIO chip sustains ~20-25 ms (~30+ Hz effective; bench
|
||||
/// 2026-07-19). Leave at 55 for stock-speed boards.
|
||||
/// </summary>
|
||||
public int AnalogPollMs { get; set; } = 55;
|
||||
|
||||
/// <summary>Default plasma COM port when a profile doesn't specify one.</summary>
|
||||
public string? DefaultPlasmaComPort { get; set; } = "COM2";
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ public readonly struct SwitchDecision : IEquatable<SwitchDecision>
|
||||
|
||||
public bool Equals(SwitchDecision other) => Mode == other.Mode && ReferenceEquals(Profile, other.Profile);
|
||||
public override bool Equals(object? obj) => obj is SwitchDecision d && Equals(d);
|
||||
public override int GetHashCode() => HashCode.Combine(Mode, Profile);
|
||||
// Manual combine: HashCode.Combine needs Microsoft.Bcl.HashCode, which has
|
||||
// no net40 build (Windows XP flavor).
|
||||
public override int GetHashCode() =>
|
||||
((int)Mode * 397) ^ (Profile?.GetHashCode() ?? 0);
|
||||
public override string ToString() => Mode == SwitchMode.Activate ? $"Activate({Profile?.Name})" : Mode.ToString();
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class AutoSwitchWatcher
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
await Compat.TaskCompat.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
|
||||
/// SimpleIni single-file config with a profile library (see docs/PLAN.md).
|
||||
/// Newtonsoft rather than System.Text.Json so the net40 (Windows XP) flavor
|
||||
/// shares one serializer — and one on-disk format — with net48 (PLAN.md §8A).
|
||||
/// Conventions match the original STJ settings: indented, PascalCase names,
|
||||
/// enums as strings, nulls skipped — existing config.json files parse as-is.
|
||||
/// </summary>
|
||||
public static class ConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
private static readonly JsonSerializerSettings Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Converters = { new StringEnumConverter() },
|
||||
};
|
||||
|
||||
public static string Serialize(AppConfig config)
|
||||
{
|
||||
if (config is null) throw new ArgumentNullException(nameof(config));
|
||||
return JsonSerializer.Serialize(config, Options);
|
||||
return JsonConvert.SerializeObject(config, Options);
|
||||
}
|
||||
|
||||
public static AppConfig Deserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
|
||||
return JsonSerializer.Deserialize<AppConfig>(json, Options)
|
||||
?? throw new JsonException("Config JSON deserialized to null.");
|
||||
return JsonConvert.DeserializeObject<AppConfig>(json, Options)
|
||||
?? throw new JsonSerializationException("Config JSON deserialized to null.");
|
||||
}
|
||||
|
||||
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
|
||||
@@ -48,4 +52,48 @@ public static class ConfigStore
|
||||
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
|
||||
return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize a single-profile document (the shape shipped in a repo's
|
||||
/// <c>profiles/*.json</c>) with the same serializer settings as the config.
|
||||
/// </summary>
|
||||
public static RioProfile DeserializeProfile(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
|
||||
return JsonConvert.DeserializeObject<RioProfile>(json, Options)
|
||||
?? throw new JsonSerializationException("Profile JSON deserialized to null.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge the single-profile file at <paramref name="profilePath"/> into the
|
||||
/// config at <paramref name="configPath"/> (created with defaults if absent).
|
||||
/// A profile with the same name (case-insensitive, the
|
||||
/// <see cref="AppConfig.FindProfile"/> convention) is replaced in place;
|
||||
/// otherwise the profile is appended. All other config content is preserved.
|
||||
/// </summary>
|
||||
public static ProfileImportResult ImportProfile(string configPath, string profilePath)
|
||||
{
|
||||
string profileJson = File.ReadAllText(profilePath);
|
||||
RioProfile profile = DeserializeProfile(profileJson);
|
||||
// Name keys the merge, and RioProfile defaults it to "Unnamed" — so a
|
||||
// document that never states one must be rejected, not merged under the
|
||||
// class default.
|
||||
if (Newtonsoft.Json.Linq.JObject.Parse(profileJson).Value<string>("Name") is not { Length: > 0 })
|
||||
throw new JsonSerializationException($"Profile file '{profilePath}' has no Name.");
|
||||
|
||||
AppConfig config = Load(configPath);
|
||||
int existing = config.Profiles.FindIndex(
|
||||
p => string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase));
|
||||
bool replaced = existing >= 0;
|
||||
if (replaced)
|
||||
config.Profiles[existing] = profile;
|
||||
else
|
||||
config.Profiles.Add(profile);
|
||||
|
||||
Save(config, configPath);
|
||||
return new ProfileImportResult(profile.Name!, replaced);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="ConfigStore.ImportProfile"/>.</summary>
|
||||
public sealed record ProfileImportResult(string Name, bool Replaced);
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class IniFile
|
||||
}
|
||||
|
||||
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
|
||||
public IReadOnlyDictionary<string, string> Section(string name) =>
|
||||
public IDictionary<string, string> Section(string name) =>
|
||||
_sections.TryGetValue(name, out Dictionary<string, string>? s)
|
||||
? s
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Output;
|
||||
|
||||
namespace RioJoy.Core.Profiles;
|
||||
|
||||
@@ -14,7 +15,7 @@ public sealed class RioProfile
|
||||
/// <summary>Display name (unique within a library).</summary>
|
||||
public string Name { get; set; } = "Unnamed";
|
||||
|
||||
/// <summary>RIO serial port (e.g. "COM3"); null = use the app default.</summary>
|
||||
/// <summary>RIO endpoint ("COM3", or "pipe:vrio" for the emulator); null = use the app default.</summary>
|
||||
public string? RioComPort { get; set; }
|
||||
|
||||
/// <summary>Plasma/VFD serial port; null = use the app default or none.</summary>
|
||||
@@ -29,6 +30,14 @@ public sealed class RioProfile
|
||||
/// <summary>Axis calibration / invert options.</summary>
|
||||
public AxisCalibrationConfig Calibration { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// How the six calibrated axes route onto the ViGEm Xbox 360 pad; null =
|
||||
/// the default <see cref="AxisRoutingConfig"/> (the historical fixed
|
||||
/// routing). Ignored by the RioGamepad HID feeder, whose native 6-axis
|
||||
/// report needs no routing.
|
||||
/// </summary>
|
||||
public AxisRoutingConfig? AxisRouting { get; set; }
|
||||
|
||||
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
|
||||
public string? PlasmaGreeting { get; set; }
|
||||
|
||||
|
||||
@@ -44,8 +44,9 @@ public readonly struct AnalogReport
|
||||
/// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an
|
||||
/// "invalid sample" sentinel — the legacy code ignores such replies.
|
||||
/// </summary>
|
||||
public static bool TryParse(ReadOnlySpan<byte> payload, out AnalogReport report)
|
||||
public static bool TryParse(byte[] payload, out AnalogReport report)
|
||||
{
|
||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||
if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply))
|
||||
throw new ArgumentException(
|
||||
$"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.",
|
||||
|
||||
@@ -13,8 +13,9 @@ public static class PacketBuilder
|
||||
/// <paramref name="payload"/>. The payload length must match the command's
|
||||
/// entry in the length table.
|
||||
/// </summary>
|
||||
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload)
|
||||
public static byte[] Build(RioCommand command, byte[] payload)
|
||||
{
|
||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
@@ -23,13 +24,15 @@ public static class PacketBuilder
|
||||
|
||||
var packet = new byte[1 + expected + 1];
|
||||
packet[0] = (byte)command;
|
||||
payload.CopyTo(packet.AsSpan(1));
|
||||
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
|
||||
Array.Copy(payload, 0, packet, 1, expected);
|
||||
packet[packet.Length - 1] = RioChecksum.Compute(packet, 0, 1 + expected);
|
||||
return packet;
|
||||
}
|
||||
|
||||
/// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary>
|
||||
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty);
|
||||
public static byte[] Build(RioCommand command) => Build(command, EmptyPayload);
|
||||
|
||||
private static readonly byte[] EmptyPayload = new byte[0];
|
||||
|
||||
// --- Convenience builders for the PC → RIO commands ---------------------
|
||||
|
||||
@@ -40,12 +43,12 @@ public static class PacketBuilder
|
||||
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
|
||||
|
||||
public static byte[] ResetRequest(RioResetTarget target) =>
|
||||
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target });
|
||||
Build(RioCommand.ResetRequest, new[] { (byte)target });
|
||||
|
||||
/// <summary>
|
||||
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
|
||||
/// 7-bit lamp-state value (see <see cref="RioLampState"/>).
|
||||
/// </summary>
|
||||
public static byte[] LampRequest(byte lampNumber, byte state) =>
|
||||
Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state });
|
||||
Build(RioCommand.LampRequest, new[] { lampNumber, state });
|
||||
}
|
||||
|
||||
@@ -92,15 +92,17 @@ public sealed class PacketParser
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
|
||||
/// each event produced, in order.
|
||||
/// Feed the first <paramref name="count"/> received bytes of
|
||||
/// <paramref name="data"/>, invoking <paramref name="onEvent"/> for each
|
||||
/// event produced, in order.
|
||||
/// </summary>
|
||||
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
|
||||
public void Feed(byte[] data, int count, Action<RioRxEvent> onEvent)
|
||||
{
|
||||
if (data is null) throw new ArgumentNullException(nameof(data));
|
||||
if (onEvent is null) throw new ArgumentNullException(nameof(onEvent));
|
||||
foreach (byte ch in data)
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (Feed(ch, out RioRxEvent ev))
|
||||
if (Feed(data[i], out RioRxEvent ev))
|
||||
onEvent(ev);
|
||||
}
|
||||
}
|
||||
@@ -110,11 +112,12 @@ public sealed class PacketParser
|
||||
// _buffer = [command][payload…][checksum]; _count includes all of them.
|
||||
int bodyLen = _count - 1; // command + payload (exclude checksum)
|
||||
byte receivedChecksum = _buffer[bodyLen];
|
||||
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
|
||||
byte computed = RioChecksum.Compute(_buffer, 0, bodyLen);
|
||||
bool checksumValid = computed == receivedChecksum;
|
||||
|
||||
var command = (RioCommand)_buffer[0];
|
||||
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
|
||||
var payload = new byte[bodyLen - 1]; // copy out; buffer is reused
|
||||
Array.Copy(_buffer, 1, payload, 0, payload.Length);
|
||||
var packet = new RioPacket(command, payload);
|
||||
|
||||
Reset();
|
||||
|
||||
@@ -9,15 +9,22 @@ namespace RioJoy.Core.Protocol;
|
||||
public static class RioChecksum
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
|
||||
/// (the command byte followed by its payload bytes — not the checksum byte).
|
||||
/// Compute the 7-bit checksum over the command byte followed by its payload
|
||||
/// bytes (not the checksum byte): <paramref name="count"/> bytes of
|
||||
/// <paramref name="commandAndPayload"/> starting at <paramref name="offset"/>.
|
||||
/// </summary>
|
||||
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
|
||||
public static byte Compute(byte[] commandAndPayload, int offset, int count)
|
||||
{
|
||||
if (commandAndPayload is null) throw new ArgumentNullException(nameof(commandAndPayload));
|
||||
|
||||
byte sum = 0;
|
||||
foreach (byte b in commandAndPayload)
|
||||
sum += (byte)(b & 0x7F);
|
||||
for (int i = offset; i < offset + count; i++)
|
||||
sum += (byte)(commandAndPayload[i] & 0x7F);
|
||||
|
||||
return (byte)(sum & 0x7F);
|
||||
}
|
||||
|
||||
/// <summary>Compute the checksum over all of <paramref name="commandAndPayload"/>.</summary>
|
||||
public static byte Compute(byte[] commandAndPayload) =>
|
||||
Compute(commandAndPayload, 0, commandAndPayload?.Length ?? 0);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ public readonly struct RioPacket
|
||||
public RioCommand Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The payload bytes (high bit always clear). Length matches
|
||||
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
|
||||
/// The payload bytes (high bit always clear; treat as read-only). Length
|
||||
/// matches <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> Payload { get; }
|
||||
public byte[] Payload { get; }
|
||||
|
||||
public RioPacket(RioCommand command, ReadOnlyMemory<byte> payload)
|
||||
public RioPacket(RioCommand command, byte[] payload)
|
||||
{
|
||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
@@ -30,7 +31,7 @@ public readonly struct RioPacket
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty);
|
||||
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
|
||||
var hex = BitConverter.ToString(Payload).Replace("-", string.Empty);
|
||||
return Payload.Length == 0 ? Command.ToString() : $"{Command} [{hex}]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public readonly struct VersionInfo
|
||||
}
|
||||
|
||||
/// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary>
|
||||
public static VersionInfo Parse(ReadOnlySpan<byte> payload)
|
||||
public static VersionInfo Parse(byte[] payload)
|
||||
{
|
||||
Require(payload, RioCommand.VersionReply);
|
||||
return new VersionInfo(payload[0], payload[1]);
|
||||
@@ -24,8 +24,9 @@ public readonly struct VersionInfo
|
||||
|
||||
public override string ToString() => $"{Major}.{Minor}";
|
||||
|
||||
internal static void Require(ReadOnlySpan<byte> payload, RioCommand command)
|
||||
internal static void Require(byte[] payload, RioCommand command)
|
||||
{
|
||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
@@ -51,7 +52,7 @@ public readonly struct CheckStatus
|
||||
}
|
||||
|
||||
/// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary>
|
||||
public static CheckStatus Parse(ReadOnlySpan<byte> payload)
|
||||
public static CheckStatus Parse(byte[] payload)
|
||||
{
|
||||
VersionInfo.Require(payload, RioCommand.CheckReply);
|
||||
return new CheckStatus((RioStatusType)payload[0], payload[1]);
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- net48 test branch. Uses Win32 P/Invoke (SendInput, SystemParametersInfo)
|
||||
and System.IO.Ports. Span/records/init/Index are polyfilled (System.Memory
|
||||
+ PolySharp); System.Text.Json comes from NuGet (not in the net48 BCL). -->
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- Two flavors (PLAN.md §Phase 8): net48 = Windows 10/11 (x64, ViGEm +
|
||||
RioGamepad); net40 = Windows XP SP3 (x86 cabinets — .NET 4.0 is XP's
|
||||
ceiling). Modern language features come from PolySharp on both; net40
|
||||
async machinery comes from Microsoft.Bcl.Async (TaskEx via
|
||||
Compat/TaskCompat). Win32 P/Invoke (SendInput, DeviceIoControl) and
|
||||
in-box System.IO.Ports work on both. -->
|
||||
<TargetFrameworks>net48;net40</TargetFrameworks>
|
||||
<Platforms>x64</Platforms>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- net48 pins x64 (matches the driver + tray exe); net40 stays AnyCPU and
|
||||
the XP tray exe pins x86. -->
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
|
||||
<!-- net40 has no System.Net.Http assembly for the implicit global using. -->
|
||||
<Using Remove="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
|
||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
|
||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
|
||||
<PackageReference Include="Microsoft.Bcl.Async" Version="1.0.168" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -144,7 +144,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
||||
private void OnPacket(RioPacket packet)
|
||||
{
|
||||
EnsureLampsInitialized();
|
||||
ReadOnlySpan<byte> p = packet.Payload.Span;
|
||||
byte[] p = packet.Payload;
|
||||
switch (packet.Command)
|
||||
{
|
||||
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
|
||||
@@ -185,7 +185,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
||||
ButtonActivity?.Invoke(address, pressed);
|
||||
}
|
||||
|
||||
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
|
||||
private static bool IsKeypad(byte[] p) => p[0] is 0 or 1 && p[1] <= 0x0F;
|
||||
|
||||
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
|
||||
void IRioCommandSink.Execute(RioCommandCode command)
|
||||
|
||||
@@ -15,8 +15,13 @@ public interface IRioTransport : IDisposable
|
||||
/// Read available bytes into <paramref name="buffer"/>. Returns the number of
|
||||
/// bytes read; a return of 0 indicates the transport has closed.
|
||||
/// </summary>
|
||||
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
|
||||
/// <remarks>
|
||||
/// Plain <c>byte[]</c>/<see cref="Task"/> rather than Memory/ValueTask: the
|
||||
/// link runs at 9600 baud, and the net40 (Windows XP) flavor has neither
|
||||
/// System.Memory nor ValueTask (see docs/PLAN.md §Phase 8).
|
||||
/// </remarks>
|
||||
Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Write all of <paramref name="data"/> to the transport.</summary>
|
||||
ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken);
|
||||
Task WriteAsync(byte[] data, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IRioTransport"/> over a local named pipe, for driving the vRIO
|
||||
/// device emulator without com0com. vRIO serves <c>\\.\pipe\vrio</c> for the
|
||||
/// whole app lifetime (the device is always present); we connect as the
|
||||
/// client — the same role the DOSBox-X fork's <c>namedpipe</c> serial backend
|
||||
/// plays. Framing per <see cref="PipeFraming"/>: writes wrap in data frames,
|
||||
/// reads unwrap them, and modem lines travel in-band as lines frames.
|
||||
///
|
||||
/// <para>On connect this replays <see cref="SerialPortTransport"/>'s board
|
||||
/// reset over the pipe: a lines frame asserting DTR, the
|
||||
/// <see cref="SerialPortTransport.DtrPulse"/> hold, then a lines frame
|
||||
/// releasing it (RTS stays low throughout, matching the COM path's
|
||||
/// RtsEnable default). The in-band frames keep the pulse's exact position
|
||||
/// in the byte stream, which is why the contract multiplexes control onto
|
||||
/// the data pipe instead of using a second one.</para>
|
||||
///
|
||||
/// <para>The peer's lines frames (vRIO asserts DTR+RTS on connect — "board
|
||||
/// present") are decoded and tracked but nothing consumes them: the COM path
|
||||
/// runs Handshake.None and never reads DSR/CTS either. A peer disconnect or
|
||||
/// framing violation surfaces as a 0-byte read — the transport-closed signal
|
||||
/// <see cref="RioSerialLink"/> already understands, same as a yanked
|
||||
/// adapter.</para>
|
||||
///
|
||||
/// <para>Writes are not paced: host→RIO traffic is tiny stop-and-wait
|
||||
/// commands, and the com0com path never emulated baud timing on this
|
||||
/// direction either. (vRIO paces its own RIO→host TX, where the analog
|
||||
/// stream would otherwise burst.)</para>
|
||||
/// </summary>
|
||||
public sealed class NamedPipeTransport : IRioTransport
|
||||
{
|
||||
/// <summary>How long to wait for the pipe server before giving up.</summary>
|
||||
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly NamedPipeClientStream _pipe;
|
||||
private readonly string _pipeName;
|
||||
private readonly PipeFrameDecoder _decoder = new();
|
||||
|
||||
// Decoded data bytes not yet handed to a reader (a pipe read may carry
|
||||
// more payload than the caller's buffer holds). Only the receive loop
|
||||
// touches these — IRioTransport has a single reader by contract.
|
||||
private readonly Queue<byte> _decoded = new();
|
||||
private readonly byte[] _raw = new byte[512];
|
||||
private volatile bool _closed;
|
||||
private byte _peerLines;
|
||||
|
||||
/// <param name="pipeName">Pipe name without the <c>\\.\pipe\</c> prefix, e.g. "vrio".</param>
|
||||
/// <param name="connectTimeout">Server wait; default <see cref="DefaultConnectTimeout"/>.</param>
|
||||
public NamedPipeTransport(string pipeName, TimeSpan? connectTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pipeName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(pipeName));
|
||||
_pipeName = pipeName;
|
||||
|
||||
_decoder.Data += (buffer, count) =>
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
_decoded.Enqueue(buffer[i]);
|
||||
};
|
||||
_decoder.Lines += lines => _peerLines = lines;
|
||||
|
||||
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
try
|
||||
{
|
||||
_pipe.Connect((int)(connectTimeout ?? DefaultConnectTimeout).TotalMilliseconds);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_pipe.Dispose();
|
||||
throw new TimeoutException($@"No pipe server at \\.\pipe\{pipeName} — is vRIO running?");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pipe.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
// DTR reset pulse, in-band: assert, hold, release (port of the COM
|
||||
// path's SETDTR/CLRDTR — see SerialPortTransport and PROTOCOL.md §1).
|
||||
//
|
||||
// Queued as overlapped writes, never awaited here: the pipe's buffers
|
||||
// default to 0 bytes, so a synchronous write blocks until the peer
|
||||
// reads it — and vRIO's own on-connect lines frame is written before
|
||||
// it starts reading, a write-first/write-first deadlock if we blocked
|
||||
// too. Pipe writes complete in issue order, so the assert→release
|
||||
// edge keeps its exact position in the byte stream; both frames
|
||||
// drain once the two readers are up. (When vRIO is already reading —
|
||||
// the steady case — the 50 ms hold arrives in real time too; during
|
||||
// the startup rendezvous the device may see a shorter pulse, which
|
||||
// still carries both edges.)
|
||||
try
|
||||
{
|
||||
byte[] assert = PipeFraming.EncodeLines(PipeFraming.LineDtr);
|
||||
Observe(_pipe.WriteAsync(assert, 0, assert.Length, CancellationToken.None));
|
||||
Thread.Sleep(SerialPortTransport.DtrPulse);
|
||||
byte[] release = PipeFraming.EncodeLines(0);
|
||||
Observe(_pipe.WriteAsync(release, 0, release.Length, CancellationToken.None));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pipe.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Swallow a queued write's eventual fault (e.g. the peer vanished before
|
||||
// draining it) — on net40 an unobserved task exception kills the process.
|
||||
private static void Observe(Task task) =>
|
||||
task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.ExecuteSynchronously);
|
||||
|
||||
public string Description => $@"\\.\pipe\{_pipeName}";
|
||||
|
||||
/// <summary>The peer's last lines frame (bit0 its DTR → our DSR, bit1 its RTS → our CTS).</summary>
|
||||
public byte PeerLines => _peerLines;
|
||||
|
||||
/// <summary>Why the peer's stream was rejected, or null (diagnostic; see <see cref="PipeFrameDecoder.Violation"/>).</summary>
|
||||
public string? Violation => _decoder.Violation;
|
||||
|
||||
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (_decoded.Count == 0)
|
||||
{
|
||||
if (_closed)
|
||||
return 0;
|
||||
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = await _pipe.ReadAsync(_raw, 0, _raw.Length, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (_closed && ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
return 0; // Dispose broke the pending read — normal teardown
|
||||
}
|
||||
|
||||
if (n == 0)
|
||||
{
|
||||
_closed = true; // server went away — unplugged cable
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!_decoder.Feed(_raw, n))
|
||||
{
|
||||
_closed = true; // framing violation: not line noise on a pipe, drop the link
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int count = Math.Min(buffer.Length, _decoded.Count);
|
||||
for (int i = 0; i < count; i++)
|
||||
buffer[i] = _decoded.Dequeue();
|
||||
return count;
|
||||
}
|
||||
|
||||
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
|
||||
{
|
||||
// Empty input frames to an empty array; the 0-byte write is a no-op.
|
||||
byte[] framed = PipeFraming.EncodeData(data);
|
||||
return _pipe.WriteAsync(framed, 0, framed.Length, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Closing the handle aborts a pending overlapped read (the receive
|
||||
// loop's read ignores cancellation, same as the COM path — teardown
|
||||
// relies on this). Pipes have no FlushFileBuffers hang to guard
|
||||
// against, so no grace-period dance like SerialPortTransport's.
|
||||
_closed = true;
|
||||
try { _pipe.Dispose(); }
|
||||
catch (IOException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// The typed-frame contract for serial-over-named-pipe, shared with vRIO
|
||||
/// (<c>VRio.Core/Device/PipeFraming.cs</c>) and the DOSBox-X fork's
|
||||
/// <c>namedpipe</c> serial backend (<c>serialnamedpipe.h</c> is the contract's
|
||||
/// source of truth on that side). A pipe is a plain byte stream, so serial
|
||||
/// data and modem control lines are multiplexed as typed frames:
|
||||
///
|
||||
/// <code>
|
||||
/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||
/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
|
||||
/// bit1 RTS); the receiver applies the
|
||||
/// null-modem cross: peer DTR → local DSR,
|
||||
/// peer RTS → local CTS
|
||||
/// </code>
|
||||
///
|
||||
/// Each side sends one lines frame immediately on connect; until it arrives
|
||||
/// the peer's lines are assumed low, and a disconnect drops them low again.
|
||||
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
|
||||
/// bytes — so the receiver drops the connection instead of trying to resync.
|
||||
/// </summary>
|
||||
public static class PipeFraming
|
||||
{
|
||||
public const byte DataType = 0x00;
|
||||
public const byte LinesType = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
|
||||
public const byte LineDtr = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
|
||||
public const byte LineRts = 0x02;
|
||||
|
||||
/// <summary>Largest payload one data frame can carry (u8 length).</summary>
|
||||
public const int MaxDataPayload = byte.MaxValue;
|
||||
|
||||
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
|
||||
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
|
||||
|
||||
/// <summary>
|
||||
/// Wrap <paramref name="data"/> in data frames, chunking payloads longer
|
||||
/// than <see cref="MaxDataPayload"/>. Empty input yields an empty array
|
||||
/// (the contract forbids zero-length data frames).
|
||||
/// </summary>
|
||||
public static byte[] EncodeData(byte[] data)
|
||||
{
|
||||
if (data is null) throw new ArgumentNullException(nameof(data));
|
||||
if (data.Length == 0)
|
||||
return new byte[0]; // net40 has no Array.Empty
|
||||
|
||||
int chunks = (data.Length + MaxDataPayload - 1) / MaxDataPayload;
|
||||
var framed = new byte[data.Length + chunks * 2];
|
||||
int src = 0, dst = 0;
|
||||
while (src < data.Length)
|
||||
{
|
||||
int len = Math.Min(MaxDataPayload, data.Length - src);
|
||||
framed[dst++] = DataType;
|
||||
framed[dst++] = (byte)len;
|
||||
Array.Copy(data, src, framed, dst, len);
|
||||
src += len;
|
||||
dst += len;
|
||||
}
|
||||
return framed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
|
||||
/// get one <see cref="Data"/> event per complete data frame and one
|
||||
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
|
||||
/// at any byte boundary. A malformed stream (unknown type, zero-length data
|
||||
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
|
||||
/// <see cref="Violation"/> says why — drop the connection and
|
||||
/// <see cref="Reset"/> before the next one.
|
||||
/// </summary>
|
||||
public sealed class PipeFrameDecoder
|
||||
{
|
||||
private enum State { Type, Length, Payload, Lines }
|
||||
|
||||
private readonly byte[] _payload = new byte[byte.MaxValue];
|
||||
private State _state;
|
||||
private int _fill, _length;
|
||||
|
||||
/// <summary>
|
||||
/// A complete data frame's payload as (buffer, count). The buffer is
|
||||
/// reused across frames — consume it synchronously.
|
||||
/// </summary>
|
||||
public event Action<byte[], int>? Data;
|
||||
|
||||
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
|
||||
public event Action<byte>? Lines;
|
||||
|
||||
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
|
||||
public string? Violation { get; private set; }
|
||||
|
||||
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_state = State.Type;
|
||||
_fill = _length = 0;
|
||||
Violation = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consume <paramref name="count"/> received bytes from
|
||||
/// <paramref name="buffer"/>. Returns false when the stream violates the
|
||||
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
|
||||
/// keeps returning false until <see cref="Reset"/>.
|
||||
/// </summary>
|
||||
public bool Feed(byte[] buffer, int count)
|
||||
{
|
||||
if (Violation is not null)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte b = buffer[i];
|
||||
switch (_state)
|
||||
{
|
||||
case State.Type when b == PipeFraming.DataType:
|
||||
_state = State.Length;
|
||||
break;
|
||||
|
||||
case State.Type when b == PipeFraming.LinesType:
|
||||
_state = State.Lines;
|
||||
break;
|
||||
|
||||
case State.Type:
|
||||
Violation = $"unknown frame type 0x{b:X2}";
|
||||
return false;
|
||||
|
||||
case State.Length when b == 0:
|
||||
Violation = "zero-length data frame";
|
||||
return false;
|
||||
|
||||
case State.Length:
|
||||
_length = b;
|
||||
_fill = 0;
|
||||
_state = State.Payload;
|
||||
break;
|
||||
|
||||
case State.Payload:
|
||||
_payload[_fill++] = b;
|
||||
if (_fill == _length)
|
||||
{
|
||||
_state = State.Type;
|
||||
Data?.Invoke(_payload, _length);
|
||||
}
|
||||
break;
|
||||
|
||||
case State.Lines:
|
||||
_state = State.Type;
|
||||
Lines?.Invoke(b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,26 @@ public sealed class RioSerialLink
|
||||
// Time since the last accepted AnalogReply, for the recovery watchdog.
|
||||
private readonly Stopwatch _sinceAnalog = new();
|
||||
|
||||
// Stop-and-wait command state: one command in flight at a time
|
||||
// (_commandGate), resolved by the board's ACK/NAK or by AckTimeout.
|
||||
// Control-byte replies (our ACKs) bypass the gate — the board never
|
||||
// ACK/NAKs those, and they must not queue behind a pending command.
|
||||
private readonly SemaphoreSlim _commandGate = new(1, 1);
|
||||
private readonly object _pendingGate = new();
|
||||
private PendingCommand? _pending;
|
||||
private long _retransmits;
|
||||
|
||||
// The in-flight command: its command byte gates which inbound events may
|
||||
// resolve it (a reply only resolves the MATCHING request — an analog reply
|
||||
// still in USB transit from the previous poll must never "confirm" a lamp
|
||||
// command that follows it; bench 2026-07-19).
|
||||
private sealed class PendingCommand
|
||||
{
|
||||
public PendingCommand(byte command) => Command = command;
|
||||
public byte Command { get; }
|
||||
public TaskCompletionSource<bool> Tcs { get; } = new();
|
||||
}
|
||||
|
||||
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
@@ -50,6 +70,9 @@ public sealed class RioSerialLink
|
||||
/// <summary>The transport's description, surfaced for status/logging.</summary>
|
||||
public string Description => _transport.Description;
|
||||
|
||||
/// <summary>Total command retransmits (NAK- or timeout-triggered) since creation.</summary>
|
||||
public long Retransmits => Interlocked.Read(ref _retransmits);
|
||||
|
||||
/// <summary>
|
||||
/// Run the receive loop and (if enabled) the analog poll loop until
|
||||
/// <paramref name="cancellationToken"/> fires or the transport closes.
|
||||
@@ -69,19 +92,78 @@ public sealed class RioSerialLink
|
||||
{
|
||||
// If any running loop ends (transport closed / error / cancellation),
|
||||
// tear the others down too.
|
||||
await Task.WhenAny(loops).ConfigureAwait(false);
|
||||
await Compat.TaskCompat.WhenAny(loops).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
linked.Cancel();
|
||||
await Task.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
|
||||
await Compat.TaskCompat.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
|
||||
public async Task SendAsync(ReadOnlyMemory<byte> packet, CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.
|
||||
/// Command packets use stop-and-wait: the call completes once the board
|
||||
/// ACKs, or after <see cref="RioSerialLinkOptions.CommandRetransmitLimit"/>
|
||||
/// retransmits (NAK- or timeout-triggered) go unacknowledged — the command
|
||||
/// is then dropped (all commands are idempotent state-setters, and the
|
||||
/// caller's next update supersedes it). Control bytes bypass the wait.
|
||||
/// </summary>
|
||||
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (packet is null) throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
// Control-byte replies (len 1) are never ACK/NAK'd by the board, and a
|
||||
// limit of 0 selects the legacy fire-and-forget behavior.
|
||||
if (packet.Length <= 1 || _options.CommandRetransmitLimit <= 0)
|
||||
{
|
||||
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Compat.TaskCompat.WaitAsync(_commandGate, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
var pending = new PendingCommand(packet[0]);
|
||||
lock (_pendingGate) _pending = pending;
|
||||
|
||||
await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Task winner = await Compat.TaskCompat.WhenAny(new[]
|
||||
{
|
||||
pending.Tcs.Task,
|
||||
Compat.TaskCompat.Delay(_options.AckTimeout, cancellationToken),
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (winner == pending.Tcs.Task && pending.Tcs.Task.Result)
|
||||
return; // delivered (ACK, or the request's own reply)
|
||||
|
||||
// NAK'd or timed out (a shred so complete the board never NAK'd).
|
||||
if (attempt >= _options.CommandRetransmitLimit)
|
||||
{
|
||||
// Budget spent — drop (idempotent; the next update supersedes).
|
||||
// Brief settle so a late straggler response can't land on the
|
||||
// NEXT command's wait (strays with nothing pending are ignored).
|
||||
lock (_pendingGate) _pending = null;
|
||||
await Compat.TaskCompat.Delay(TimeSpan.FromMilliseconds(10), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
Interlocked.Increment(ref _retransmits);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_pendingGate) _pending = null;
|
||||
_commandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteAsync(byte[] packet, CancellationToken cancellationToken)
|
||||
{
|
||||
await Compat.TaskCompat.WaitAsync(_writeLock, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||
@@ -141,6 +223,17 @@ public sealed class RioSerialLink
|
||||
break;
|
||||
|
||||
case RioRxEventKind.ControlByte:
|
||||
if (ev.Byte is (byte)RioControl.Ack or (byte)RioControl.Nak)
|
||||
{
|
||||
// Resolve the in-flight command (stop-and-wait). Safe to be
|
||||
// type-blind here: the board's TX ISR sends pending ACK/NAK
|
||||
// ahead of reply data, so a command's ACK cannot arrive
|
||||
// after its successor starts (unlike replies, see
|
||||
// DispatchTyped).
|
||||
PendingCommand? pending;
|
||||
lock (_pendingGate) pending = _pending;
|
||||
pending?.Tcs.TrySetResult(ev.Byte == (byte)RioControl.Ack);
|
||||
}
|
||||
ControlReceived?.Invoke(ev.Byte);
|
||||
break;
|
||||
|
||||
@@ -152,10 +245,29 @@ public sealed class RioSerialLink
|
||||
|
||||
private void DispatchTyped(RioPacket packet)
|
||||
{
|
||||
// A reply proves the MATCHING request landed, whether or not the board
|
||||
// also sent an explicit ACK — but only the matching one: a reply still
|
||||
// in transit from an earlier poll must never confirm an unrelated
|
||||
// command (that type-blindness was the residual lamp-glitch hole).
|
||||
RioCommand? resolves = packet.Command switch
|
||||
{
|
||||
RioCommand.AnalogReply => RioCommand.AnalogRequest,
|
||||
RioCommand.VersionReply => RioCommand.VersionRequest,
|
||||
RioCommand.CheckReply => RioCommand.CheckRequest,
|
||||
_ => null,
|
||||
};
|
||||
if (resolves is not null)
|
||||
{
|
||||
PendingCommand? pending;
|
||||
lock (_pendingGate) pending = _pending;
|
||||
if (pending is not null && pending.Command == (byte)resolves.Value)
|
||||
pending.Tcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
switch (packet.Command)
|
||||
{
|
||||
case RioCommand.AnalogReply:
|
||||
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report))
|
||||
if (AnalogReport.TryParse(packet.Payload, out AnalogReport report))
|
||||
{
|
||||
_sinceAnalog.Restart();
|
||||
AnalogReceived?.Invoke(report);
|
||||
@@ -163,11 +275,11 @@ public sealed class RioSerialLink
|
||||
break;
|
||||
|
||||
case RioCommand.VersionReply:
|
||||
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span));
|
||||
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload));
|
||||
break;
|
||||
|
||||
case RioCommand.CheckReply:
|
||||
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span));
|
||||
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -191,7 +303,7 @@ public sealed class RioSerialLink
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
|
||||
await Compat.TaskCompat.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
|
||||
|
||||
await RequestAnalogAsync(ct).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -29,4 +29,25 @@ public sealed record RioSerialLinkOptions
|
||||
|
||||
/// <summary>Read buffer size for the receive loop.</summary>
|
||||
public int ReadBufferSize { get; init; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// How many times a command packet is retransmitted when the board NAKs it
|
||||
/// or the ACK never arrives (0 = fire-and-forget, the pre-2026-07 behavior).
|
||||
/// Commands use stop-and-wait: ONE command in flight, resolved by
|
||||
/// ACK / NAK / <see cref="AckTimeout"/> before the next is written. The
|
||||
/// first (NAK-race) design resent "the most recent" packet and misfired
|
||||
/// under mash bursts — by the time a NAK crossed USB, a newer command was
|
||||
/// already the latest, so the corrupted write stayed lost (bench
|
||||
/// 2026-07-19); with one-in-flight the attribution is exact, and the
|
||||
/// timeout also catches corruptions so complete the board never NAKs.
|
||||
/// </summary>
|
||||
public int CommandRetransmitLimit { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// How long to wait for the board's ACK/NAK of a command before treating it
|
||||
/// as lost (stop-and-wait; see <see cref="CommandRetransmitLimit"/>). Board
|
||||
/// turnaround is ~1-4 ms; 50 ms is generous without stalling the 55 ms
|
||||
/// analog poll cadence on a healthy link.
|
||||
/// </summary>
|
||||
public TimeSpan AckTimeout { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the right <see cref="IRioTransport"/> for an endpoint string. A COM
|
||||
/// port name ("COM3") opens a <see cref="SerialPortTransport"/>; a
|
||||
/// <c>pipe:name</c> endpoint ("pipe:vrio") opens a
|
||||
/// <see cref="NamedPipeTransport"/> to <c>\\.\pipe\name</c> — the same
|
||||
/// endpoint syntax vRIO's own connection picker uses, so a profile can point
|
||||
/// at the emulator with <c>RioComPort = "pipe:vrio"</c> and no com0com pair.
|
||||
/// </summary>
|
||||
public static class RioTransportFactory
|
||||
{
|
||||
/// <summary>Endpoint prefix selecting the named-pipe transport.</summary>
|
||||
public const string PipeScheme = "pipe:";
|
||||
|
||||
/// <summary>True when <paramref name="endpoint"/> names a pipe rather than a COM port.</summary>
|
||||
public static bool IsPipe(string endpoint) =>
|
||||
endpoint is not null
|
||||
&& endpoint.StartsWith(PipeScheme, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Open <paramref name="endpoint"/>. <paramref name="baudRate"/> applies
|
||||
/// to COM ports only — a pipe has no wire rate (the peer paces itself).
|
||||
/// </summary>
|
||||
public static IRioTransport Open(string endpoint, int baudRate = SerialPortTransport.BaudRate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(endpoint));
|
||||
|
||||
return IsPipe(endpoint)
|
||||
? new NamedPipeTransport(endpoint.Substring(PipeScheme.Length))
|
||||
: (IRioTransport)new SerialPortTransport(endpoint, baudRate);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace RioJoy.Core.Serial;
|
||||
/// </summary>
|
||||
public sealed class SerialPortTransport : IRioTransport
|
||||
{
|
||||
/// <summary>RIO link bit rate.</summary>
|
||||
/// <summary>Default RIO link bit rate (stock v4.2 firmware).</summary>
|
||||
public const int BaudRate = 9600;
|
||||
|
||||
/// <summary>DTR reset-pulse hold time on open.</summary>
|
||||
@@ -23,11 +23,18 @@ public sealed class SerialPortTransport : IRioTransport
|
||||
private readonly SerialPort _port;
|
||||
private readonly Stream _stream;
|
||||
|
||||
public SerialPortTransport(string portName)
|
||||
/// <param name="portName">COM port name, e.g. "COM1".</param>
|
||||
/// <param name="baudRate">
|
||||
/// Link rate; default 9600 (stock firmware). The 31250-baud FastRIO
|
||||
/// firmware (TeslaRel410 restoration/rio-firmware) needs an adapter that can make
|
||||
/// non-standard rates exactly — FTDI-class USB serial can, classic
|
||||
/// 16550 UARTs cannot.
|
||||
/// </param>
|
||||
public SerialPortTransport(string portName, int baudRate = BaudRate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(portName));
|
||||
|
||||
_port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
|
||||
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
Handshake = Handshake.None,
|
||||
// The framing state machine handles timing; keep reads non-throwing.
|
||||
@@ -45,23 +52,13 @@ public sealed class SerialPortTransport : IRioTransport
|
||||
_stream = _port.BaseStream;
|
||||
}
|
||||
|
||||
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
|
||||
public string Description => $"{_port.PortName} @ {_port.BaudRate} 8N1";
|
||||
|
||||
// net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge
|
||||
// through a pooled array and copy into/out of the caller's Memory<byte>.
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tmp = new byte[buffer.Length];
|
||||
int read = await _stream.ReadAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
|
||||
new ReadOnlySpan<byte>(tmp, 0, read).CopyTo(buffer.Span);
|
||||
return read;
|
||||
}
|
||||
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
|
||||
_stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
|
||||
|
||||
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tmp = data.ToArray();
|
||||
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
public Task WriteAsync(byte[] data, CancellationToken cancellationToken) =>
|
||||
_stream.WriteAsync(data, 0, data.Length, cancellationToken);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -84,7 +81,7 @@ public sealed class SerialPortTransport : IRioTransport
|
||||
// still releases the handle.
|
||||
}
|
||||
|
||||
Task close = Task.Run(() =>
|
||||
Task close = Compat.TaskCompat.Run(() =>
|
||||
{
|
||||
try { _port.Dispose(); }
|
||||
catch { /* best-effort release */ }
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class ProfileWallpaperGenerator
|
||||
public string Generate(
|
||||
OverlayTemplate template,
|
||||
string templateDirectory,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
IDictionary<string, string> labels,
|
||||
string outputPath,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class SkiaOverlayRenderer
|
||||
/// </summary>
|
||||
public SKBitmap Render(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
IDictionary<string, string> labels,
|
||||
SKBitmap baseImage,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
@@ -95,7 +95,7 @@ public sealed class SkiaOverlayRenderer
|
||||
/// <summary>Render and encode to PNG bytes.</summary>
|
||||
public byte[] RenderToPng(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
IDictionary<string, string> labels,
|
||||
SKBitmap baseImage,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
@@ -110,7 +110,7 @@ public sealed class SkiaOverlayRenderer
|
||||
/// </summary>
|
||||
public void RenderToFile(
|
||||
OverlayTemplate template,
|
||||
IReadOnlyDictionary<string, string> labels,
|
||||
IDictionary<string, string> labels,
|
||||
string outputPath,
|
||||
OverlayLayoutOptions? options = null)
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace RioJoy.Tray.Editor;
|
||||
/// </summary>
|
||||
internal sealed class PanelCanvas : Control
|
||||
{
|
||||
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
|
||||
private static readonly IList<PanelButton> AllButtons = CockpitPanel.Buttons();
|
||||
|
||||
public PanelCanvas()
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class PanelView
|
||||
public const int CalOriginCol = 2;
|
||||
public const int CalOriginRow = 9;
|
||||
|
||||
public static IReadOnlyList<CalibrationCell> CalibrationCells { get; } = new[]
|
||||
public static IList<CalibrationCell> CalibrationCells { get; } = new[]
|
||||
{
|
||||
new CalibrationCell(JoyStickSetting.InvertX, "Inv X", 2, 10),
|
||||
new CalibrationCell(JoyStickSetting.InvertY, "Inv Y", 2, 11),
|
||||
@@ -51,7 +51,7 @@ public static class PanelView
|
||||
new CalibrationCell(JoyStickSetting.EnableZR, "ZR mix", 2, 16),
|
||||
};
|
||||
|
||||
public static Size GridSize(IReadOnlyList<PanelButton> buttons)
|
||||
public static Size GridSize(IList<PanelButton> buttons)
|
||||
{
|
||||
int maxCol = 0, maxRow = 0;
|
||||
foreach (PanelButton b in buttons)
|
||||
@@ -69,8 +69,8 @@ public static class PanelView
|
||||
|
||||
public static void Paint(
|
||||
Graphics g,
|
||||
IReadOnlyList<PanelButton> buttons,
|
||||
IReadOnlyList<PanelGroup> groups,
|
||||
IList<PanelButton> buttons,
|
||||
IList<PanelGroup> groups,
|
||||
Func<int, string?> label,
|
||||
Func<int, string?> function,
|
||||
Func<int, bool> lit,
|
||||
@@ -225,7 +225,7 @@ public static class PanelView
|
||||
}
|
||||
}
|
||||
|
||||
public static PanelButton? HitTest(IReadOnlyList<PanelButton> buttons, Point p)
|
||||
public static PanelButton? HitTest(IList<PanelButton> buttons, Point p)
|
||||
{
|
||||
foreach (PanelButton b in buttons)
|
||||
if (Cell(b.Col, b.Row).Contains(p))
|
||||
|
||||
@@ -261,9 +261,15 @@ public sealed class ProfileEditorForm : Form
|
||||
_valueCombo.Items.Add(new ValueItem(k.Name, k.Value));
|
||||
break;
|
||||
case RioRouteKind.Joystick:
|
||||
#if NET40
|
||||
// XP: RioGamepadXP exposes the full 96-button HID layout.
|
||||
for (int btn = 1; btn <= RioJoy.Core.Hid.RioHidReport.ButtonCount; btn++)
|
||||
_valueCombo.Items.Add(new ValueItem($"Button {btn}", (byte)btn));
|
||||
#else
|
||||
// The signed ViGEmBus Xbox 360 pad exposes 11 buttons; show their names.
|
||||
for (int btn = 1; btn <= ViGEmJoystickSink.MappableButtonCount; btn++)
|
||||
_valueCombo.Items.Add(new ValueItem($"Button {btn} ({ViGEmJoystickSink.ButtonNames[btn - 1]})", (byte)btn));
|
||||
#endif
|
||||
break;
|
||||
case RioRouteKind.Hat:
|
||||
foreach (RioHat h in new[] { RioHat.Up, RioHat.Right, RioHat.Down, RioHat.Left })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using RioJoy.Core.Profiles;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
internal static class Program
|
||||
@@ -12,18 +14,63 @@ internal static class Program
|
||||
/// Entry point. RIOJoy runs as a background tray application with no main
|
||||
/// window: an ApplicationContext owns the NotifyIcon and the runtime, so the
|
||||
/// message loop stays alive while the only UI is the tray icon and its menu.
|
||||
///
|
||||
/// <para><c>--import-profile <profile.json></c> merges a single-profile
|
||||
/// document (a repo's <c>profiles/*.json</c>) into this user's config and
|
||||
/// exits without starting the tray. Output goes to stdout/stderr, which a
|
||||
/// GUI-subsystem exe only delivers when redirected — check the exit code
|
||||
/// (0 ok, 1 failed, 2 usage, 3 tray running) when scripting it.</para>
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
if (args.Length >= 1 && string.Equals(args[0], "--import-profile", StringComparison.OrdinalIgnoreCase))
|
||||
return ImportProfile(args);
|
||||
|
||||
using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew);
|
||||
if (!createdNew)
|
||||
return; // 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();
|
||||
// do the equivalent setup directly.
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new TrayApplicationContext());
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ImportProfile(string[] args)
|
||||
{
|
||||
if (args.Length != 2)
|
||||
{
|
||||
Console.Error.WriteLine("usage: RioJoy.Tray --import-profile <profile.json>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// A running tray holds the config in memory and rewrites it on its own
|
||||
// saves, which would silently discard this import — refuse instead.
|
||||
// (OpenExisting + catch rather than TryOpenExisting: net40 lacks the Try form.)
|
||||
try
|
||||
{
|
||||
using Mutex running = Mutex.OpenExisting(SingleInstanceMutex);
|
||||
Console.Error.WriteLine("RIOJoy is running in this session - quit it from the tray menu, import, then relaunch.");
|
||||
return 3;
|
||||
}
|
||||
catch (WaitHandleCannotBeOpenedException)
|
||||
{
|
||||
// not running — proceed
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ProfileImportResult result = ConfigStore.ImportProfile(TrayApplicationContext.ConfigPath, args[1]);
|
||||
Console.WriteLine($"{(result.Replaced ? "Replaced" : "Added")} profile '{result.Name}' in {TrayApplicationContext.ConfigPath}");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"import failed: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Overlay;
|
||||
using RioJoy.Core.Profiles;
|
||||
using RioJoy.Core.Serial;
|
||||
using RioJoy.Overlay;
|
||||
#if !NET40
|
||||
using RioJoy.Overlay; // SkiaSharp wallpaper generation — modern flavor only
|
||||
#endif
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
@@ -42,10 +44,16 @@ public sealed class RioCoordinator : IDisposable
|
||||
private IDisposable? _joystick;
|
||||
private string? _activeProfileName;
|
||||
|
||||
// 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
|
||||
// restore). "" is a valid captured value (the user had no wallpaper).
|
||||
private string? _savedWallpaper;
|
||||
|
||||
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
|
||||
_transportFactory = transportFactory
|
||||
?? (port => RioTransportFactory.Open(port, _config().RioBaudRate));
|
||||
}
|
||||
|
||||
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
|
||||
@@ -149,12 +157,14 @@ public sealed class RioCoordinator : IDisposable
|
||||
// feeder, then a no-op when neither driver is present.
|
||||
private IJoystickSink CreateJoystickSink(out string note)
|
||||
{
|
||||
#if !NET40 // ViGEmBus is Win10+; on XP the HID feeder drives RioGamepadXP.sys
|
||||
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
|
||||
{
|
||||
_joystick = vigem;
|
||||
note = string.Empty;
|
||||
return vigem!;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
|
||||
{
|
||||
@@ -189,6 +199,13 @@ public sealed class RioCoordinator : IDisposable
|
||||
IJoystickSink joystick;
|
||||
string note;
|
||||
IJoystickSink realJoystick = CreateJoystickSink(out string joystickNote);
|
||||
#if !NET40
|
||||
// Per-profile ViGEm axis routing, applied the same way the profile's
|
||||
// Calibration reaches the AxisCalibrator below (null = default legacy
|
||||
// routing). Also neutralizes the pad so nothing persists across a switch.
|
||||
if (realJoystick is ViGEmJoystickSink vigemPad)
|
||||
vigemPad.SetRouting(profile.AxisRouting);
|
||||
#endif
|
||||
if (!routeInput)
|
||||
{
|
||||
// Keyboard/mouse + joystick are gated off while editing so the RIO can
|
||||
@@ -207,7 +224,11 @@ public sealed class RioCoordinator : IDisposable
|
||||
}
|
||||
|
||||
_transport = _transportFactory(port);
|
||||
_link = new RioSerialLink(_transport);
|
||||
_link = new RioSerialLink(_transport, new RioSerialLinkOptions
|
||||
{
|
||||
AnalogPollInterval = TimeSpan.FromMilliseconds(
|
||||
Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config
|
||||
});
|
||||
_runtime = new RioRuntime(
|
||||
_link,
|
||||
profile.ToInputMap(),
|
||||
@@ -241,6 +262,21 @@ public sealed class RioCoordinator : IDisposable
|
||||
/// </summary>
|
||||
private void ApplyWallpaper(RioProfile profile)
|
||||
{
|
||||
#if NET40
|
||||
// XP flavor: no SkiaSharp, so no generation — apply the profile's
|
||||
// pre-rendered wallpaper (authored on a modern machine) if it exists.
|
||||
if (string.IsNullOrWhiteSpace(profile.WallpaperPath) || !File.Exists(profile.WallpaperPath))
|
||||
return;
|
||||
try
|
||||
{
|
||||
CaptureWallpaperOnce();
|
||||
WallpaperApplier.Apply(profile.WallpaperPath!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
||||
}
|
||||
#else
|
||||
string? templatePath = _config().OverlayTemplatePath;
|
||||
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
|
||||
return;
|
||||
@@ -257,12 +293,40 @@ public sealed class RioCoordinator : IDisposable
|
||||
|
||||
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
|
||||
profile.WallpaperPath = outPath;
|
||||
CaptureWallpaperOnce();
|
||||
WallpaperApplier.Apply(outPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remember the user's current desktop wallpaper the first time we override it,
|
||||
/// so it can be restored when we go dormant. No-op once captured (so switching
|
||||
/// between cockpit profiles never records a cockpit wallpaper as the "previous").
|
||||
/// </summary>
|
||||
private void CaptureWallpaperOnce()
|
||||
{
|
||||
if (_savedWallpaper != null)
|
||||
return;
|
||||
try { _savedWallpaper = WallpaperApplier.GetCurrent(); }
|
||||
catch { _savedWallpaper = string.Empty; } // best-effort; empty just clears on restore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Put the user's pre-activation wallpaper back, if we overrode it. Called when
|
||||
/// going dormant and on shutdown; best-effort and idempotent.
|
||||
/// </summary>
|
||||
private void RestoreWallpaper()
|
||||
{
|
||||
if (_savedWallpaper == null)
|
||||
return; // never overrode — leave the desktop alone
|
||||
try { WallpaperApplier.Restore(_savedWallpaper); }
|
||||
catch { /* best-effort — never block going dormant */ }
|
||||
_savedWallpaper = null;
|
||||
}
|
||||
|
||||
private static string SafeFileName(string name)
|
||||
@@ -275,6 +339,7 @@ public sealed class RioCoordinator : IDisposable
|
||||
private void GoDormant(string status)
|
||||
{
|
||||
Teardown();
|
||||
RestoreWallpaper(); // put the user's own desktop back
|
||||
SetStatus(status);
|
||||
}
|
||||
|
||||
@@ -306,5 +371,9 @@ public sealed class RioCoordinator : IDisposable
|
||||
StatusChanged?.Invoke(status);
|
||||
}
|
||||
|
||||
public void Dispose() => Teardown();
|
||||
public void Dispose()
|
||||
{
|
||||
Teardown();
|
||||
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- net48 = Windows 10/11 (x64, full app incl. wallpaper maker/SkiaSharp).
|
||||
net40 = Windows XP SP3 (x86): full runtime + mapping editor; wallpaper
|
||||
GENERATION is excluded (SkiaSharp has no XP support) — XP applies
|
||||
pre-rendered wallpapers only. See docs/PLAN.md §Phase 8. -->
|
||||
<TargetFrameworks>net48;net40</TargetFrameworks>
|
||||
<Platforms>x64</Platforms>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -12,12 +15,39 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net40'">
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<!-- The SDK defaults AutoGenerateBindingRedirects=true for .NET Framework
|
||||
exes, which makes RAR walk the dependency closure of the NuGet-resolved
|
||||
Microsoft.Bcl.Async assemblies; those were compiled against Microsoft.Bcl
|
||||
facades v1.5.11.0 while the graph ships v2.6.8.0, and RAR classifies the
|
||||
missing 1.5.11.0 facades as unresolvable framework assemblies and drops
|
||||
the references (MSB3268 → CS0234). Turning generation off skips that
|
||||
walk; the redirects the XP runtime still needs are hand-authored in
|
||||
app.net40.config instead. -->
|
||||
<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
|
||||
<AppConfig>app.net40.config</AppConfig>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
|
||||
<!-- net40 has no System.Net.Http assembly for the implicit global using. -->
|
||||
<Using Remove="System.Net.Http" />
|
||||
<!-- Wallpaper maker needs the SkiaSharp renderer (RioJoy.Overlay) — modern only. -->
|
||||
<Compile Remove="Editor\WallpaperMakerForm.cs" />
|
||||
<Compile Remove="Editor\WallpaperCanvas.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" Condition="'$(TargetFramework)' == 'net48'" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace RioJoy.Tray;
|
||||
/// </summary>
|
||||
internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private static readonly string ConfigPath =
|
||||
// Internal so Program's --import-profile writes the same store the tray reads.
|
||||
internal static readonly string ConfigPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RIOJoy", "config.json");
|
||||
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
|
||||
@@ -74,10 +75,12 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
RebuildEditMenu(editMenu);
|
||||
menu.Items.Add(editMenu);
|
||||
|
||||
#if !NET40 // wallpaper maker needs SkiaSharp — modern flavor only (XP applies pre-rendered)
|
||||
var wallpaperMenu = new ToolStripMenuItem("Wallpaper maker");
|
||||
wallpaperMenu.DropDownOpening += (_, _) => RebuildWallpaperMenu(wallpaperMenu);
|
||||
RebuildWallpaperMenu(wallpaperMenu);
|
||||
menu.Items.Add(wallpaperMenu);
|
||||
#endif
|
||||
|
||||
menu.Items.Add("Import .ini…", null, (_, _) => ImportIniProfiles());
|
||||
|
||||
@@ -155,6 +158,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
editMenu.DropDownItems.Add(new ToolStripMenuItem("New profile…", null, (_, _) => OpenEditor(NewProfile())));
|
||||
}
|
||||
|
||||
#if !NET40
|
||||
// Rebuild the "Wallpaper maker" submenu: one entry per profile.
|
||||
private void RebuildWallpaperMenu(ToolStripMenuItem wallpaperMenu)
|
||||
{
|
||||
@@ -205,6 +209,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
"RIOJoy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Create, register and persist a fresh empty profile with a unique name.
|
||||
private RioProfile NewProfile()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace RioJoy.Tray;
|
||||
|
||||
@@ -16,8 +17,10 @@ namespace RioJoy.Tray;
|
||||
public static class WallpaperApplier
|
||||
{
|
||||
private const int SPI_SETDESKWALLPAPER = 0x0014;
|
||||
private const int SPI_GETDESKWALLPAPER = 0x0073;
|
||||
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
|
||||
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
|
||||
private const int MaxPath = 260;
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
|
||||
@@ -30,11 +33,56 @@ public static class WallpaperApplier
|
||||
if (!File.Exists(full))
|
||||
throw new FileNotFoundException("Wallpaper image not found.", full);
|
||||
|
||||
#if NET40
|
||||
// Windows XP's SPI_SETDESKWALLPAPER accepts only BMP. Wallpapers are
|
||||
// authored as PNG on a modern machine, so convert beside the source on
|
||||
// apply (idempotent: reuses an up-to-date .bmp).
|
||||
if (!full.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string bmp = Path.ChangeExtension(full, ".bmp");
|
||||
if (!File.Exists(bmp) || File.GetLastWriteTimeUtc(bmp) < File.GetLastWriteTimeUtc(full))
|
||||
{
|
||||
using var image = System.Drawing.Image.FromFile(full);
|
||||
image.Save(bmp, System.Drawing.Imaging.ImageFormat.Bmp);
|
||||
}
|
||||
full = bmp;
|
||||
}
|
||||
#endif
|
||||
|
||||
return SystemParametersInfo(
|
||||
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the desktop wallpaper Windows currently has set (empty string when none
|
||||
/// is set — a solid-color desktop). Capture this before <see cref="Apply"/>
|
||||
/// overrides it, so <see cref="Restore"/> can put it back when going dormant.
|
||||
/// </summary>
|
||||
public static string GetCurrent()
|
||||
{
|
||||
var buffer = new StringBuilder(MaxPath);
|
||||
bool ok = SystemParametersInfo(SPI_GETDESKWALLPAPER, buffer.Capacity, buffer, 0);
|
||||
return ok ? buffer.ToString() : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a wallpaper previously read by <see cref="GetCurrent"/>. Unlike
|
||||
/// <see cref="Apply"/> this does not require the file to exist and accepts an
|
||||
/// empty path (which clears the wallpaper to the solid desktop color) — the
|
||||
/// value came straight from Windows, so it is passed back verbatim. Returns the
|
||||
/// API result; callers treat it as best-effort.
|
||||
/// </summary>
|
||||
public static bool Restore(string? previous)
|
||||
{
|
||||
return SystemParametersInfo(
|
||||
SPI_SETDESKWALLPAPER, 0, previous ?? string.Empty, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SystemParametersInfo(int uAction, int uParam, StringBuilder lpvParam, int fuWinIni);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- XP (net40) flavor only, selected via <AppConfig> in the csproj. The
|
||||
Microsoft.Bcl.Async assemblies were compiled against the Microsoft.Bcl
|
||||
facades v1.5.11.0, but the package graph ships v2.6.8.0; XP's CLR 4.0 has
|
||||
no framework unification for these (4.5+ does), so without the redirects
|
||||
the first TaskEx call throws FileLoadException. Build-time redirect
|
||||
generation is off (see AutoGenerateBindingRedirects in the csproj), so the
|
||||
redirects live here statically. The SDK adds the <startup> section. -->
|
||||
<configuration>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Threading.Tasks" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.IO" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.6.8.0" newVersion="2.6.8.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -128,11 +128,39 @@ public class AxisCalibratorTests
|
||||
Assert.Equal(AxisOutputs.Center, cal.Update(Report(x: 0)).X);
|
||||
}
|
||||
|
||||
// --- Detent regression (legacy quirk deliberately fixed) -----------------
|
||||
|
||||
[Fact]
|
||||
public void Outputs_AreClampedToAxisRange()
|
||||
public void Throttle_FirstPollAtRest_IsZero_NotPinned()
|
||||
{
|
||||
// The init-state throttle quirk yields a value far over range on the first
|
||||
// poll at rest; the documented 0..Max clamp guards it.
|
||||
Assert.Equal(AxisOutputs.Max, new AxisCalibrator().Update(Report(throttle: 0)).Z);
|
||||
// Regression: the legacy Center init + detent-hold compounded through the
|
||||
// ×32 rescale and pinned Z at Max on the very first poll at rest — full
|
||||
// reverse on a Centered thumb route, full thrust on a UnipolarPositive one
|
||||
// (Descent). Rest must read 0 with no reset and stay there.
|
||||
var cal = new AxisCalibrator();
|
||||
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z);
|
||||
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throttle_ReleaseExactlyOntoStart_ReturnsToZero_NoSpike()
|
||||
{
|
||||
// Regression: a release landing exactly on the tracked start (lT == 0)
|
||||
// used to hold the last moving value, which the ×32 rescale turned into a
|
||||
// one-poll full-throttle spike until jitter broke it.
|
||||
var cal = new AxisCalibrator();
|
||||
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // seed start at rest
|
||||
Assert.Equal(16000, cal.Update(Report(throttle: -400)).Z); // pushed halfway
|
||||
Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // release onto start
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throttle_SampleAboveRunningMax_ReadsAsRest()
|
||||
{
|
||||
// Samples above the tracked start re-base the auto-range (lT == 0 again)
|
||||
// and must read as rest, not re-enter the legacy hold path.
|
||||
var cal = new AxisCalibrator();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class CockpitPanelTests
|
||||
public void Keypad_PhysicalLayout_MapsDigitsToAddresses()
|
||||
{
|
||||
// Internal keypad: top-left "1" -> 0x51, bottom-middle "0" -> 0x50, "C" -> 0x5C.
|
||||
IReadOnlyList<PanelButton> all = CockpitPanel.Buttons();
|
||||
IList<PanelButton> all = CockpitPanel.Buttons();
|
||||
PanelGroup pad = CockpitPanel.Groups.Single(g => g.Title == "Internal Keypad");
|
||||
|
||||
Assert.Equal(0x51, pad.Addresses[0]); // row0 col0 = "1"
|
||||
|
||||
@@ -47,7 +47,7 @@ public class SheetLayoutTests
|
||||
"\"\",\"IsLit\",\"\",\"\",\"0F\"\n" +
|
||||
"\"\",\"\",\"\",\"\",\"HOME\"";
|
||||
|
||||
IReadOnlyList<SheetCell> cells = SheetLayout.Parse(csv);
|
||||
IList<SheetCell> cells = SheetLayout.Parse(csv);
|
||||
|
||||
SheetCell addr = Assert.Single(cells, c => c.Text == "0F");
|
||||
Assert.Equal(0x0F, addr.Address);
|
||||
@@ -64,7 +64,7 @@ public class SheetLayoutTests
|
||||
[Fact]
|
||||
public void Parse_RealSheet_HasFullAddressMap()
|
||||
{
|
||||
IReadOnlyList<SheetCell> cells = SheetLayout.Load(
|
||||
IList<SheetCell> cells = SheetLayout.Load(
|
||||
TestRepo.CustomBackground("config-sheet.csv"));
|
||||
|
||||
// Every RIO address 0x00..0x6F that exists should appear exactly once as a cell.
|
||||
|
||||
@@ -29,12 +29,12 @@ public class RioHidReportTests
|
||||
{
|
||||
var report = new RioHidReport();
|
||||
report.SetAxis(JoyAxis.Z, 0x1234);
|
||||
Assert.Equal(0x34, report.Bytes[4]);
|
||||
Assert.Equal(0x12, report.Bytes[5]);
|
||||
Assert.Equal(0x34, report.ToArray()[4]);
|
||||
Assert.Equal(0x12, report.ToArray()[5]);
|
||||
|
||||
report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767
|
||||
Assert.Equal(0xFF, report.Bytes[4]);
|
||||
Assert.Equal(0x7F, report.Bytes[5]);
|
||||
Assert.Equal(0xFF, report.ToArray()[4]);
|
||||
Assert.Equal(0x7F, report.ToArray()[5]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -47,7 +47,7 @@ public class RioHidReportTests
|
||||
{
|
||||
var report = new RioHidReport();
|
||||
report.SetHat(hat);
|
||||
Assert.Equal(expected, report.Bytes[12] & 0x0F);
|
||||
Assert.Equal(expected, report.ToArray()[12] & 0x0F);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -59,10 +59,10 @@ public class RioHidReportTests
|
||||
{
|
||||
var report = new RioHidReport();
|
||||
report.SetButton(button, pressed: true);
|
||||
Assert.Equal(mask, report.Bytes[byteIndex]);
|
||||
Assert.Equal(mask, report.ToArray()[byteIndex]);
|
||||
|
||||
report.SetButton(button, pressed: false);
|
||||
Assert.Equal(0, report.Bytes[byteIndex]);
|
||||
Assert.Equal(0, report.ToArray()[byteIndex]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -80,6 +80,6 @@ public class RioHidReportTests
|
||||
report.SetButton(1, true);
|
||||
report.SetButton(2, true);
|
||||
report.SetButton(1, false);
|
||||
Assert.Equal(0x02, report.Bytes[13]); // only button 2 remains
|
||||
Assert.Equal(0x02, report.ToArray()[13]); // only button 2 remains
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Output;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Output;
|
||||
|
||||
public class AxisRouterTests
|
||||
{
|
||||
// --- Default routing reproduces the legacy hardcoded sink exactly --------
|
||||
|
||||
[Theory]
|
||||
[InlineData(JoyAxis.X, PadTarget.LeftThumbX)]
|
||||
[InlineData(JoyAxis.Y, PadTarget.LeftThumbY)]
|
||||
[InlineData(JoyAxis.Rx, PadTarget.RightThumbX)]
|
||||
[InlineData(JoyAxis.Ry, PadTarget.RightThumbY)]
|
||||
public void Default_ThumbAxes_UseLegacyCenteredMath(JoyAxis axis, PadTarget expected)
|
||||
{
|
||||
var config = new AxisRoutingConfig();
|
||||
|
||||
// Legacy ToThumb: (value - 16383) * 2, clamped to the short range.
|
||||
ResolvedAxis min = AxisRouter.Resolve(config, axis, 0);
|
||||
Assert.Equal(expected, min.Target);
|
||||
Assert.Equal(-32766, min.Value);
|
||||
|
||||
Assert.Equal(0, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
|
||||
Assert.Equal(32766, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(JoyAxis.Z, PadTarget.LeftTrigger)]
|
||||
[InlineData(JoyAxis.Rz, PadTarget.RightTrigger)]
|
||||
public void Default_TriggerAxes_UseLegacyTriggerMath(JoyAxis axis, PadTarget expected)
|
||||
{
|
||||
var config = new AxisRoutingConfig();
|
||||
|
||||
// Legacy ToTrigger: value * 255 / 32766, clamped to the byte range.
|
||||
ResolvedAxis rest = AxisRouter.Resolve(config, axis, 0);
|
||||
Assert.Equal(expected, rest.Target);
|
||||
Assert.Equal(0, rest.Value);
|
||||
|
||||
Assert.Equal(127, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
|
||||
Assert.Equal(255, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_CenteredThumb_ClampsToShortRange()
|
||||
{
|
||||
var config = new AxisRoutingConfig();
|
||||
|
||||
// (40000 - 16383) * 2 = 47234 → clamped to short.MaxValue.
|
||||
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.X, 40000).Value);
|
||||
// (-2000 - 16383) * 2 = -36766 → clamped to short.MinValue.
|
||||
Assert.Equal(-32768, AxisRouter.Resolve(config, JoyAxis.X, -2000).Value);
|
||||
}
|
||||
|
||||
// --- UnipolarPositive: clamp(value, 0, 32767) ----------------------------
|
||||
|
||||
[Fact]
|
||||
public void UnipolarPositive_MapsCalibratedZeroToThumbCenter()
|
||||
{
|
||||
var config = new AxisRoutingConfig
|
||||
{
|
||||
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
|
||||
};
|
||||
|
||||
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, 0).Value); // detent → center
|
||||
Assert.Equal(16383, AxisRouter.Resolve(config, JoyAxis.Z, 16383).Value); // half → half up
|
||||
Assert.Equal(32766, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value); // full → max (32766 of 32767)
|
||||
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, -5).Value); // clamps below
|
||||
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.Z, 40000).Value); // clamps above
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnipolarPositive_OnTriggerTarget_IsIgnored_TriggerMathApplies()
|
||||
{
|
||||
// Mode is meaningless for trigger targets — they keep the trigger byte math.
|
||||
var config = new AxisRoutingConfig
|
||||
{
|
||||
Z = new AxisRoute { Target = PadTarget.LeftTrigger, Mode = AxisOutputMode.UnipolarPositive },
|
||||
};
|
||||
|
||||
Assert.Equal(255, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value);
|
||||
}
|
||||
|
||||
// --- None: the axis is not emitted ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NoneTarget_EmitsNothing()
|
||||
{
|
||||
var config = new AxisRoutingConfig
|
||||
{
|
||||
Rx = new AxisRoute { Target = PadTarget.None },
|
||||
};
|
||||
|
||||
ResolvedAxis r = AxisRouter.Resolve(config, JoyAxis.Rx, 12345);
|
||||
Assert.Equal(PadTarget.None, r.Target);
|
||||
Assert.Equal(0, r.Value);
|
||||
}
|
||||
|
||||
// --- The Descent shape ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DescentConfig_ThrottleToRightThumbY_TriggersUntargeted()
|
||||
{
|
||||
// profiles/descent-d1x.json: DXX's stock GameController defaults fire on
|
||||
// the LT/RT axis-buttons, so no axis may land on a trigger.
|
||||
var config = new AxisRoutingConfig
|
||||
{
|
||||
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
|
||||
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
|
||||
Rx = new AxisRoute { Target = PadTarget.None },
|
||||
Ry = new AxisRoute { Target = PadTarget.None },
|
||||
};
|
||||
|
||||
ResolvedAxis z = AxisRouter.Resolve(config, JoyAxis.Z, 16383);
|
||||
Assert.Equal(PadTarget.RightThumbY, z.Target);
|
||||
Assert.Equal(16383, z.Value); // unipolar: half throttle → half deflection up
|
||||
|
||||
ResolvedAxis rz = AxisRouter.Resolve(config, JoyAxis.Rz, AxisOutputs.Center);
|
||||
Assert.Equal(PadTarget.RightThumbX, rz.Target);
|
||||
Assert.Equal(0, rz.Value); // rudder stays bipolar: center → thumb 0
|
||||
|
||||
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
|
||||
{
|
||||
PadTarget target = AxisRouter.Resolve(config, axis, AxisOutputs.Center).Target;
|
||||
Assert.NotEqual(PadTarget.LeftTrigger, target);
|
||||
Assert.NotEqual(PadTarget.RightTrigger, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public class GoobieDataImporterTests
|
||||
[Fact]
|
||||
public void LabelsForRow_DropsEmptyValues()
|
||||
{
|
||||
IReadOnlyDictionary<string, string> labels =
|
||||
IDictionary<string, string> labels =
|
||||
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Parse(Sample));
|
||||
|
||||
Assert.True(labels.ContainsKey("b-00"));
|
||||
|
||||
@@ -21,7 +21,7 @@ public class OverlayHitTesterTests
|
||||
[Fact]
|
||||
public void RegionsAt_ReturnsSmallestFirst()
|
||||
{
|
||||
IReadOnlyList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
|
||||
IList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
|
||||
|
||||
Assert.Equal(new[] { "small", "big" }, hits.Select(r => r.Name));
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ public class OverlayLayoutEngineTests
|
||||
["b-99"] = "ORPHAN", // no such region -> ignored
|
||||
};
|
||||
|
||||
IReadOnlyList<PlacedLabel> placed = Engine.Layout(template, labels);
|
||||
IList<PlacedLabel> placed = Engine.Layout(template, labels);
|
||||
|
||||
Assert.Equal(2, placed.Count);
|
||||
Assert.Equal("b-00", placed[0].RegionName);
|
||||
|
||||
@@ -35,7 +35,7 @@ public class OverlayRenderIntegrationTests
|
||||
Assert.Equal(template.Width, baseImg.Width);
|
||||
Assert.Equal(template.Height, baseImg.Height);
|
||||
|
||||
IReadOnlyDictionary<string, string> labels =
|
||||
IDictionary<string, string> labels =
|
||||
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data")));
|
||||
|
||||
using SKBitmap rendered = new SkiaOverlayRenderer().Render(template, labels, baseImg);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Profiles;
|
||||
using Xunit;
|
||||
|
||||
@@ -45,6 +46,179 @@ public class ConfigStoreTests
|
||||
Assert.Equal(new[] { "doom.exe" }, p.MatchExecutables);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrips_AxisRouting_WithStringEnums()
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Profiles =
|
||||
{
|
||||
new RioProfile
|
||||
{
|
||||
Name = "Descent",
|
||||
AxisRouting = new AxisRoutingConfig
|
||||
{
|
||||
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
|
||||
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
|
||||
Rx = new AxisRoute { Target = PadTarget.None },
|
||||
Ry = new AxisRoute { Target = PadTarget.None },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
string json = ConfigStore.Serialize(config);
|
||||
Assert.Contains("\"RightThumbY\"", json); // enums serialize as strings
|
||||
Assert.Contains("\"UnipolarPositive\"", json); // (house StringEnumConverter convention)
|
||||
|
||||
RioProfile back = Assert.Single(ConfigStore.Deserialize(json).Profiles);
|
||||
Assert.Equal(config.Profiles[0].AxisRouting, back.AxisRouting); // record value equality
|
||||
Assert.Equal(PadTarget.LeftThumbX, back.AxisRouting!.X.Target); // untouched routes keep defaults
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AxisRouting_Unset_StaysNull_AndOffJson()
|
||||
{
|
||||
// null = default routing; NullValueHandling.Ignore keeps it out of the JSON,
|
||||
// so pre-existing profiles on disk are byte-compatible.
|
||||
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
|
||||
Assert.DoesNotContain("AxisRouting", json);
|
||||
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
|
||||
{
|
||||
// Guards the shipped fixture itself: Newtonsoft's default
|
||||
// MissingMemberHandling.Ignore silently drops a misspelled property or axis
|
||||
// name, which would fall back to the default routing and put the throttle
|
||||
// back on LeftTrigger — the stock fire button in Descent's SDL
|
||||
// GameController mapping. So parse the real file, not a C#-built config.
|
||||
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
|
||||
|
||||
// The shipped file is a single-profile document users paste into the
|
||||
// config's Profiles list; wrap it so it flows through the exact
|
||||
// ConfigStore serializer settings.
|
||||
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
|
||||
|
||||
Assert.Equal("Descent", p.Name);
|
||||
Assert.True(p.Calibration.EnableZR); // pedal-differential rudder feeds Rz
|
||||
|
||||
Assert.NotNull(p.AxisRouting);
|
||||
AxisRoutingConfig routing = p.AxisRouting!;
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbX }, routing.X);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbY }, routing.Y);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, routing.Z);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbX }, routing.Rz);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Rx);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Ry);
|
||||
|
||||
// No axis may resolve onto a trigger — triggers are Descent's fire buttons.
|
||||
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
|
||||
{
|
||||
PadTarget target = AxisRouter.Resolve(routing, axis, AxisOutputs.Center).Target;
|
||||
Assert.NotEqual(PadTarget.LeftTrigger, target);
|
||||
Assert.NotEqual(PadTarget.RightTrigger, target);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_MatchesDxxRebirthReferenceCopy()
|
||||
{
|
||||
// The dxx-rebirth docs keep a reference copy of the Descent profile that
|
||||
// must stay byte-identical to ours. The sibling checkout is a cabinet-
|
||||
// machine convention, not a repo guarantee, so no-op quietly when the
|
||||
// second repo is not checked out next to this one.
|
||||
string reference = Path.GetFullPath(Path.Combine(
|
||||
TestRepo.Root(), "..", "dxx-rebirth", "docs", "reference", "descent-riojoy-profile.json"));
|
||||
if (!File.Exists(reference))
|
||||
return;
|
||||
|
||||
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
|
||||
Assert.Equal(File.ReadAllBytes(shipped), File.ReadAllBytes(reference));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportProfile_ShippedDescentFile_IntoFreshConfig()
|
||||
{
|
||||
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
|
||||
try
|
||||
{
|
||||
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
|
||||
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
|
||||
|
||||
Assert.Equal("Descent", result.Name);
|
||||
Assert.False(result.Replaced);
|
||||
|
||||
RioProfile p = Assert.Single(ConfigStore.Load(configPath).Profiles);
|
||||
Assert.Equal("Descent", p.Name);
|
||||
// The routing must survive the import round-trip intact — this is the
|
||||
// exact payload a cabinet/bench install writes.
|
||||
Assert.NotNull(p.AxisRouting);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, p.AxisRouting!.Z);
|
||||
}
|
||||
finally
|
||||
{
|
||||
string? dir = Path.GetDirectoryName(configPath);
|
||||
if (dir is not null && Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportProfile_ReplacesSameName_PreservesEverythingElse()
|
||||
{
|
||||
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
|
||||
try
|
||||
{
|
||||
ConfigStore.Save(new AppConfig
|
||||
{
|
||||
DefaultRioComPort = "COM7",
|
||||
NeutralProfileName = "Desktop",
|
||||
Profiles =
|
||||
{
|
||||
new RioProfile { Name = "Desktop" },
|
||||
new RioProfile { Name = "descent", PlasmaGreeting = "STALE" }, // case-insensitive match
|
||||
},
|
||||
}, configPath);
|
||||
|
||||
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
|
||||
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
|
||||
Assert.True(result.Replaced);
|
||||
|
||||
AppConfig back = ConfigStore.Load(configPath);
|
||||
Assert.Equal("COM7", back.DefaultRioComPort); // untouched settings survive
|
||||
Assert.Equal("Desktop", back.NeutralProfileName);
|
||||
Assert.Equal(2, back.Profiles.Count); // replaced in place, not appended
|
||||
RioProfile descent = Assert.IsType<RioProfile>(back.FindProfile("Descent"));
|
||||
Assert.Equal("DESCENT", descent.PlasmaGreeting); // stale profile fully overwritten
|
||||
}
|
||||
finally
|
||||
{
|
||||
string? dir = Path.GetDirectoryName(configPath);
|
||||
if (dir is not null && Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportProfile_NamelessProfile_Throws()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
string profilePath = Path.Combine(dir, "nameless.json");
|
||||
File.WriteAllText(profilePath, "{\"PlasmaGreeting\":\"X\"}");
|
||||
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
|
||||
() => ConfigStore.ImportProfile(Path.Combine(dir, "config.json"), profilePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsDefaults()
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ public class PacketBuilderTests
|
||||
byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload);
|
||||
|
||||
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
|
||||
Assert.Equal(payload, packet.AsSpan(1, packet.Length - 2).ToArray());
|
||||
Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]);
|
||||
Assert.Equal(payload, packet.Skip(1).Take(packet.Length - 2).ToArray());
|
||||
Assert.Equal(RioChecksum.Compute(packet, 0, packet.Length - 1), packet[packet.Length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class PacketParserTests
|
||||
{
|
||||
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data)
|
||||
private static List<RioRxEvent> FeedAll(PacketParser parser, byte[] data)
|
||||
{
|
||||
var events = new List<RioRxEvent>();
|
||||
foreach (byte b in data)
|
||||
|
||||
@@ -8,7 +8,7 @@ public class RioChecksumTests
|
||||
[Fact]
|
||||
public void Empty_IsZero()
|
||||
{
|
||||
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty));
|
||||
Assert.Equal(0, RioChecksum.Compute(new byte[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
|
||||
<!-- Channels 8.0's net462 binary references Unsafe 6.0.0.0 but doesn't
|
||||
declare it, so the graph would otherwise resolve 4.5.3 (assembly
|
||||
4.0.4.1) and the test host dies with FileLoadException. Pin the
|
||||
assembly Channels was built against; app.config redirects the older
|
||||
requests (System.Memory / Tasks.Extensions) up to it. -->
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.0.0" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -23,13 +23,13 @@ internal sealed class FakeTransport : IRioTransport
|
||||
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
|
||||
public void CompleteIncoming() => _incoming.Writer.TryComplete();
|
||||
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (_incoming.Reader.TryRead(out byte[]? chunk))
|
||||
{
|
||||
chunk.AsSpan().CopyTo(buffer.Span);
|
||||
Array.Copy(chunk, buffer, chunk.Length);
|
||||
return chunk.Length;
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,10 @@ internal sealed class FakeTransport : IRioTransport
|
||||
return 0; // completed
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
|
||||
{
|
||||
_writes.Writer.TryWrite(data.ToArray());
|
||||
return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task
|
||||
_writes.Writer.TryWrite((byte[])data.Clone());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.IO.Pipes;
|
||||
using RioJoy.Core.Serial;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
public class PipeFramingTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncodeData_WrapsInOneFrame()
|
||||
{
|
||||
byte[] framed = PipeFraming.EncodeData(new byte[] { 0x81, 0x01, 0xFC });
|
||||
Assert.Equal(new byte[] { 0x00, 0x03, 0x81, 0x01, 0xFC }, framed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeData_ChunksPayloadsOver255()
|
||||
{
|
||||
var data = new byte[300];
|
||||
for (int i = 0; i < data.Length; i++) data[i] = (byte)i;
|
||||
|
||||
byte[] framed = PipeFraming.EncodeData(data);
|
||||
|
||||
// 255-byte frame + 45-byte frame, payloads contiguous.
|
||||
Assert.Equal(300 + 4, framed.Length);
|
||||
Assert.Equal(PipeFraming.DataType, framed[0]);
|
||||
Assert.Equal(255, framed[1]);
|
||||
Assert.Equal(PipeFraming.DataType, framed[2 + 255]);
|
||||
Assert.Equal(45, framed[2 + 255 + 1]);
|
||||
Assert.Equal(data.Take(255), framed.Skip(2).Take(255));
|
||||
Assert.Equal(data.Skip(255), framed.Skip(2 + 255 + 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeData_EmptyYieldsNoFrames()
|
||||
{
|
||||
Assert.Empty(PipeFraming.EncodeData(new byte[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decoder_SurvivesAnySplitAcrossReads()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
var data = new List<byte>();
|
||||
var lines = new List<byte>();
|
||||
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
||||
decoder.Lines += lines.Add;
|
||||
|
||||
// A lines frame, a 2-byte data frame, a 1-byte data frame — fed one byte at a time.
|
||||
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
|
||||
foreach (byte b in stream)
|
||||
Assert.True(decoder.Feed(new[] { b }, 1));
|
||||
|
||||
Assert.Equal(new byte[] { 0x03 }, lines);
|
||||
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decoder_UnknownFrameType_Poisons()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
|
||||
Assert.False(decoder.Feed(new byte[] { 0xFF }, 1));
|
||||
Assert.Contains("0xFF", decoder.Violation);
|
||||
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); // stays poisoned
|
||||
|
||||
decoder.Reset();
|
||||
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
||||
Assert.Null(decoder.Violation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decoder_ZeroLengthDataFrame_Poisons()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
|
||||
Assert.Contains("zero-length", decoder.Violation);
|
||||
}
|
||||
}
|
||||
|
||||
public class RioTransportFactoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("pipe:vrio", true)]
|
||||
[InlineData("PIPE:vrio", true)]
|
||||
[InlineData("COM3", false)]
|
||||
[InlineData("com1", false)]
|
||||
public void IsPipe_RecognizesTheScheme(string endpoint, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RioTransportFactory.IsPipe(endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests against a real in-process <see cref="NamedPipeServerStream"/>
|
||||
/// standing in for vRIO (which serves \\.\pipe\vrio the same way).
|
||||
/// </summary>
|
||||
public class NamedPipeTransportTests : IDisposable
|
||||
{
|
||||
private readonly string _pipeName = $"riojoy-test-{Guid.NewGuid():N}";
|
||||
private readonly NamedPipeServerStream _server;
|
||||
|
||||
public NamedPipeTransportTests()
|
||||
{
|
||||
_server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _server.Dispose(); }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Accept the client and construct the transport concurrently (the ctor blocks through the DTR pulse).</summary>
|
||||
private async Task<NamedPipeTransport> ConnectAsync()
|
||||
{
|
||||
Task accept = _server.WaitForConnectionAsync();
|
||||
Task<NamedPipeTransport> client = Task.Run(() => new NamedPipeTransport(_pipeName));
|
||||
await accept.WithTimeout();
|
||||
return await client.WithTimeout();
|
||||
}
|
||||
|
||||
private async Task<byte[]> ServerReadAsync(int count)
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
int fill = 0;
|
||||
while (fill < count)
|
||||
{
|
||||
int n = await _server.ReadAsync(buffer, fill, count - fill).WithTimeout();
|
||||
Assert.True(n > 0, "server: pipe closed before the expected bytes arrived");
|
||||
fill += n;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_SendsTheDtrResetPulse()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
// Assert (DTR high), hold, release — the in-band SETDTR/CLRDTR port.
|
||||
Assert.Equal(new byte[] { 0x01, PipeFraming.LineDtr }, await ServerReadAsync(2));
|
||||
Assert.Equal(new byte[] { 0x01, 0x00 }, await ServerReadAsync(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_UnwrapsDataFrames_AndSwallowsLinesFrames()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
// vRIO's on-connect lines frame (board present), then a data frame —
|
||||
// split at an awkward boundary to exercise the incremental decoder.
|
||||
// Issued unawaited: the pipe's 0-byte buffers make a write complete
|
||||
// only when the peer reads it (see the transport's ctor comment).
|
||||
Task w1 = _server.WriteAsync(new byte[] { 0x01, 0x03, 0x00, 0x03, 0x81 }, 0, 5);
|
||||
Task w2 = _server.WriteAsync(new byte[] { 0x01, 0xFC }, 0, 2);
|
||||
|
||||
var buffer = new byte[16];
|
||||
var got = new List<byte>();
|
||||
while (got.Count < 3)
|
||||
{
|
||||
int n = await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout();
|
||||
Assert.True(n > 0);
|
||||
got.AddRange(buffer.Take(n));
|
||||
}
|
||||
|
||||
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, got);
|
||||
Assert.Equal((byte)(PipeFraming.LineDtr | PipeFraming.LineRts), transport.PeerLines);
|
||||
await w1.WithTimeout();
|
||||
await w2.WithTimeout();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_SmallBuffer_DrainsAcrossCalls()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
Task write = _server.WriteAsync(new byte[] { 0x00, 0x04, 0x10, 0x20, 0x30, 0x40 }, 0, 6);
|
||||
|
||||
var buffer = new byte[3];
|
||||
Assert.Equal(3, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
|
||||
Assert.Equal(new byte[] { 0x10, 0x20, 0x30 }, buffer);
|
||||
Assert.Equal(1, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
|
||||
Assert.Equal(0x40, buffer[0]);
|
||||
await write.WithTimeout();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_WrapsInADataFrame()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
await ServerReadAsync(4); // discard the DTR pulse frames
|
||||
|
||||
// Unawaited until the server drains it (0-byte pipe buffers).
|
||||
Task write = transport.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new byte[] { 0x00, 0x02, 0x81, 0x01 }, await ServerReadAsync(4));
|
||||
await write.WithTimeout();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerGone_ReadReturnsZero()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
_server.Dispose();
|
||||
|
||||
var buffer = new byte[16];
|
||||
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
|
||||
// And it keeps saying closed rather than reading a dead pipe.
|
||||
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProtocolViolation_ReadReturnsZero()
|
||||
{
|
||||
using NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
Task write = _server.WriteAsync(new byte[] { 0xFF }, 0, 1);
|
||||
|
||||
var buffer = new byte[16];
|
||||
Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
|
||||
Assert.Contains("0xFF", transport.Violation);
|
||||
await write.WithTimeout();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_UnblocksAPendingRead()
|
||||
{
|
||||
NamedPipeTransport transport = await ConnectAsync();
|
||||
|
||||
Task<int> pending = transport.ReadAsync(new byte[16], CancellationToken.None);
|
||||
Assert.False(pending.IsCompleted);
|
||||
|
||||
transport.Dispose();
|
||||
|
||||
Assert.Equal(0, await pending.WithTimeout());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoServer_ConstructorTimesOutWithAClearMessage()
|
||||
{
|
||||
var ex = Assert.Throws<TimeoutException>(() =>
|
||||
new NamedPipeTransport($"riojoy-nobody-{Guid.NewGuid():N}", TimeSpan.FromMilliseconds(200)));
|
||||
Assert.Contains("vRIO", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Factory_OpensPipeEndpoints()
|
||||
{
|
||||
Task accept = _server.WaitForConnectionAsync();
|
||||
Task<IRioTransport> client = Task.Run(() => RioTransportFactory.Open($"pipe:{_pipeName}"));
|
||||
await accept.WithTimeout();
|
||||
|
||||
using IRioTransport transport = await client.WithTimeout();
|
||||
Assert.Equal($@"\\.\pipe\{_pipeName}", transport.Description);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TaskTimeoutExtensions
|
||||
{
|
||||
/// <summary>Await with a test-failure deadline, so a hung pipe fails fast instead of stalling the run.</summary>
|
||||
public static async Task<T> WithTimeout<T>(this Task<T> task, int seconds = 5)
|
||||
{
|
||||
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
|
||||
return await task;
|
||||
}
|
||||
|
||||
public static async Task WithTimeout(this Task task, int seconds = 5)
|
||||
{
|
||||
Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
|
||||
await task;
|
||||
}
|
||||
}
|
||||
@@ -141,4 +141,211 @@ public class RioSerialLinkTests
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
// --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------
|
||||
|
||||
private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
|
||||
{
|
||||
AutoPollAnalog = false,
|
||||
CommandRetransmitLimit = limit,
|
||||
AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Nak_RetransmitsTheSamePacket()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
||||
Task send = link.SendAsync(lamp);
|
||||
|
||||
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
|
||||
Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
|
||||
fake.Enqueue((byte)RioControl.Ack); // second copy lands
|
||||
|
||||
await send.WaitAsync(Timeout);
|
||||
Assert.Equal(1, link.Retransmits);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_RetransmitsWhenBoardNeverResponds()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
||||
Task send = link.SendAsync(lamp);
|
||||
|
||||
// Original + 2 timeout-driven retries, then the command is dropped.
|
||||
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||
Assert.Equal(lamp, await fake.NextWriteAsync());
|
||||
await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
|
||||
Assert.Equal(2, link.Retransmits);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ack_CompletesWithoutRetransmit()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
||||
await fake.NextWriteAsync();
|
||||
fake.Enqueue((byte)RioControl.Ack);
|
||||
|
||||
await send.WaitAsync(Timeout);
|
||||
Assert.Equal(0, link.Retransmits);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commands_AreSerialized_SecondWaitsForFirst()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
|
||||
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
|
||||
Task sendA = link.SendAsync(first);
|
||||
Task sendB = link.SendAsync(second);
|
||||
|
||||
// Only the first is on the wire until it resolves.
|
||||
Assert.Equal(first, await fake.NextWriteAsync());
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
fake.Enqueue((byte)RioControl.Ack);
|
||||
Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
|
||||
fake.Enqueue((byte)RioControl.Ack);
|
||||
|
||||
await sendA.WaitAsync(Timeout);
|
||||
await sendB.WaitAsync(Timeout);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
// An analog request answered by the reply alone (no ACK byte) must not
|
||||
// burn the ACK timeout — the reply proves the request landed.
|
||||
Task send = link.RequestAnalogAsync();
|
||||
await fake.NextWriteAsync();
|
||||
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
||||
|
||||
await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
|
||||
Assert.Equal(0, link.Retransmits);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalogReply_DoesNotResolve_PendingLampCommand()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait(limit: 1, timeoutMs: 250));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
// Lamp command in flight; a straggler analog reply (from an earlier
|
||||
// poll) arrives. It must NOT confirm the lamp — the lamp keeps waiting
|
||||
// and is resolved only by its own ACK.
|
||||
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
||||
await fake.NextWriteAsync();
|
||||
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
||||
|
||||
await Task.Delay(100);
|
||||
Assert.False(send.IsCompleted, "stray analog reply must not resolve a lamp command");
|
||||
|
||||
fake.Enqueue((byte)RioControl.Ack);
|
||||
await send.WaitAsync(Timeout);
|
||||
Assert.Equal(0, link.Retransmits);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
// Inbound button packet -> our 1-byte ACK reply (bypasses the command
|
||||
// gate). A stray NAK afterwards must not resend anything.
|
||||
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
||||
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
|
||||
|
||||
fake.Enqueue((byte)RioControl.Nak);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||
Assert.Equal(0, link.Retransmits);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retransmit_Disabled_IsFireAndForget()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, StopAndWait(limit: 0));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
// Completes immediately (no ACK wait), and a NAK triggers nothing.
|
||||
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
|
||||
await fake.NextWriteAsync();
|
||||
fake.Enqueue((byte)RioControl.Nak);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
||||
Assert.Equal(0, link.Retransmits);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,15 @@ internal static class TaskTestExtensions
|
||||
cts.Cancel(); // stop the delay timer
|
||||
return await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Non-generic counterpart of <see cref="WaitAsync{T}"/>.</summary>
|
||||
public static async Task WaitAsync(this Task task, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
|
||||
if (completed != task)
|
||||
throw new TimeoutException($"Task did not complete within {timeout}.");
|
||||
cts.Cancel();
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copied to RioJoy.Core.Tests.dll.config so the VSTest host binds the single
|
||||
deployed System.Runtime.CompilerServices.Unsafe 6.0.0.0 (pinned in the
|
||||
csproj — System.Threading.Channels 8.0's net462 binary requires it) for
|
||||
the older versions System.Memory / System.Threading.Tasks.Extensions
|
||||
request. Static rather than auto-generated so the suite doesn't depend on
|
||||
RAR's redirect suggestions, which vary with the installed SDK/reference
|
||||
assemblies. -->
|
||||
<configuration>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Diagnostics;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioSerialMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// E0-threshold firmware verification (TeslaRel410 restoration/rio-firmware `--e0thresh` images).
|
||||
///
|
||||
/// Two phases against a DTR-reset board (counters zeroed, display F0000000):
|
||||
/// - Phase A (gate hold): analog requests answered NAK-then-ACK — NAKing
|
||||
/// the COMPLETED reply frame forces exactly one immediate retransmit,
|
||||
/// ACKing that retransmit ends the cycle. Each event increments the
|
||||
/// teardown counter $3185, invoking the display renderer sub-threshold.
|
||||
/// The _e0t5 gate must keep the display F0000000; stock firmware paints
|
||||
/// E0... at the first event. (Responses must follow the complete frame:
|
||||
/// the board arms its reply-await only when the frame finishes sending,
|
||||
/// and once the first retry timeout lapses the cycle runs blind.)
|
||||
/// - Phase B (flip): analog requests we never ACK — the board runs the
|
||||
/// full retry cycle (5 retransmits), gives up ($3184++, RESTART $FE)
|
||||
/// and tears down ($3185++). $3185 crosses the threshold; the display
|
||||
/// must flip to the E0 readout the tool predicts from observed traffic.
|
||||
///
|
||||
/// Bench 2026-07-19 (9600 e0t5 chip): PASS — display held F0000000 through
|
||||
/// the handshake and all four phase-A teardowns, flipped exactly at the
|
||||
/// 5th teardown to E0000105 ($3187=00, $3184=01, $3185=05).
|
||||
///
|
||||
/// dotnet run --project tools/RioSerialMonitor -- --e0test [port]
|
||||
/// [--baud rate] [--hold n] [--flip n]
|
||||
///
|
||||
/// No --baud probes 9600, 31250, 62500 (DTR-resetting each try). HANDS OFF
|
||||
/// buttons/keypads during the run — an unACKed button packet shifts the
|
||||
/// counters (the tool folds observed traffic into its predictions either
|
||||
/// way). Exit: 0 = ran, 2 = no board reply on any baud.
|
||||
/// </summary>
|
||||
internal static class E0Test
|
||||
{
|
||||
private const int Threshold = 5;
|
||||
|
||||
private enum AckMode { Never, Immediate, NakThenAck }
|
||||
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
string port = "COM1";
|
||||
int baud = 0, holdEvents = 4, flipEvents = 1;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--e0test": break;
|
||||
case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b): baud = b; i++; break;
|
||||
case "--hold" when i + 1 < args.Length && int.TryParse(args[i + 1], out int h) && h >= 0: holdEvents = h; i++; break;
|
||||
case "--flip" when i + 1 < args.Length && int.TryParse(args[i + 1], out int f) && f > 0: flipEvents = f; i++; break;
|
||||
default:
|
||||
if (!args[i].StartsWith("--")) port = args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
object gate = new();
|
||||
var buffer = new List<byte>();
|
||||
int cursor = 0;
|
||||
|
||||
// Reader-thread ACK automation (latency matters: the board's ACK wait
|
||||
// at 9600 is ~4ms/retry, so ACKs must not wait for a polling loop).
|
||||
AckMode ackMode = AckMode.Never;
|
||||
int replyByte = 0x87; // frame command byte the current phase expects
|
||||
int frameLen = 12; // full frame: cmd + payload + checksum
|
||||
int repliesSeen = 0; // completed frames of replyByte since phase reset
|
||||
int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
|
||||
bool ackSent = false;
|
||||
|
||||
IRioTransport? transport = null;
|
||||
Task? reader = null;
|
||||
|
||||
void PhaseReset(AckMode mode, int expectReply, int expectLen)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
ackMode = mode;
|
||||
replyByte = expectReply;
|
||||
frameLen = expectLen;
|
||||
repliesSeen = 0;
|
||||
frameCountdown = 0;
|
||||
ackSent = false;
|
||||
cursor = buffer.Count;
|
||||
}
|
||||
}
|
||||
|
||||
int[] bauds = baud != 0 ? new[] { baud } : new[] { 9600, 31250, 62500 };
|
||||
(int Major, int Minor)? version = null;
|
||||
|
||||
foreach (int tryBaud in bauds)
|
||||
{
|
||||
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] probing {port} @ {tryBaud} (DTR reset, ~2s boot wait)...");
|
||||
try { transport = RioTransportFactory.Open(port, tryBaud); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
lock (gate) { buffer.Clear(); cursor = 0; }
|
||||
IRioTransport t = transport;
|
||||
reader = Task.Run(async () =>
|
||||
{
|
||||
var tmp = new byte[256];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int n = await t.ReadAsync(tmp, CancellationToken.None);
|
||||
if (n <= 0) break;
|
||||
byte? respond = null;
|
||||
lock (gate)
|
||||
{
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
buffer.Add(tmp[i]);
|
||||
// Frame tracking: respond only once the FULL reply
|
||||
// frame is on the wire � the board arms its
|
||||
// reply-await when the frame finishes sending; a
|
||||
// response mid-frame is ignored.
|
||||
if (frameCountdown > 0)
|
||||
{
|
||||
if (--frameCountdown > 0) continue;
|
||||
repliesSeen++;
|
||||
if (ackMode == AckMode.Immediate)
|
||||
{
|
||||
// ACK every completed frame (check replies
|
||||
// arrive as several 0x85 frames in a row).
|
||||
ackSent = true;
|
||||
respond = 0xFC;
|
||||
}
|
||||
else if (ackMode == AckMode.NakThenAck && repliesSeen <= 2)
|
||||
{
|
||||
// frame 1 -> NAK (board resends, $3184++),
|
||||
// frame 2 -> ACK (cycle ends cleanly).
|
||||
respond = repliesSeen == 1 ? (byte)0xFD : (byte)0xFC;
|
||||
ackSent = repliesSeen == 2;
|
||||
}
|
||||
}
|
||||
else if (tmp[i] == replyByte)
|
||||
{
|
||||
frameCountdown = frameLen - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (respond is byte r)
|
||||
await t.WriteAsync(new byte[] { r }, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch { /* port closed */ }
|
||||
});
|
||||
|
||||
await Task.Delay(2000); // boot: counters cleared, display F0000000
|
||||
|
||||
PhaseReset(AckMode.Immediate, 0x86, 4); // version reply: cmd+2+ck
|
||||
await t.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
|
||||
byte[] got = await Collect(1200, b => IndexOf(b, 0x86) >= 0);
|
||||
int vi = IndexOf(got, 0x86);
|
||||
if (vi >= 0)
|
||||
{
|
||||
if (vi + 2 < got.Length) version = (got[vi + 1], got[vi + 2]);
|
||||
baud = tryBaud;
|
||||
break;
|
||||
}
|
||||
|
||||
Console.WriteLine($" no version reply at {tryBaud}");
|
||||
transport.Dispose();
|
||||
transport = null;
|
||||
if (reader is not null) await reader;
|
||||
}
|
||||
|
||||
if (transport is null)
|
||||
{
|
||||
Console.WriteLine(" no board reply on any baud — is the board powered / the right chip in?");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Handshake bookkeeping: fold any retries/give-up into the prediction.
|
||||
// Bench-calibrated counter semantics (2026-07-19, confirmed on the
|
||||
// display): $3184 = timeout-retry cycles (give-ups), $3185 = reply
|
||||
// teardowns, i.e. any cycle that needed at least one retransmission.
|
||||
await Task.Delay(300);
|
||||
byte[] hs = Drain();
|
||||
int cycles = SeenAll(0xFE) ? 1 : 0; // $3184 prediction
|
||||
int teardowns = CountAll(0x86) > 1 ? 1 : 0; // $3185 prediction
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"== E0-threshold test :: {transport.Description}, firmware {version?.Major}.{version?.Minor} ==");
|
||||
Console.WriteLine($" threshold {Threshold}; phase A: {holdEvents} sub-threshold event(s), phase B: {flipEvents} give-up event(s)");
|
||||
if (cycles > 0 || teardowns > 0)
|
||||
Console.WriteLine($" note: handshake was not clean (teardowns={teardowns}, give-ups={cycles}); predictions include it.");
|
||||
Console.WriteLine(" HANDS OFF buttons/keypads for the whole run.");
|
||||
Console.WriteLine();
|
||||
string expected = Expected(cycles, teardowns);
|
||||
Console.WriteLine($">>> WATCH THE 8-DIGIT DISPLAY. It should read {expected} right now. <<<");
|
||||
await Task.Delay(4000);
|
||||
|
||||
int eventNo = 0;
|
||||
bool ok = true;
|
||||
|
||||
for (int k = 0; k < holdEvents && ok; k++)
|
||||
ok = await RunEvent(AckMode.NakThenAck, "hold");
|
||||
for (int k = 0; k < flipEvents && ok; k++)
|
||||
ok = await RunEvent(AckMode.Never, "flip");
|
||||
|
||||
if (ok)
|
||||
{
|
||||
// Edit-6 acceptance (rc1/RIO4.3 chips): a CheckRequest self-test
|
||||
// must repaint the display afterwards — F0000000 when healthy,
|
||||
// or the E0 readout when counters are over threshold (they are
|
||||
// now, after the flip). Chips without edit 6 show 04000000 here.
|
||||
PhaseReset(AckMode.Immediate, 0x85, 4); // check replies: cmd+2+ck
|
||||
await transport.WriteAsync(new byte[] { 0x80, 0x00 }, CancellationToken.None);
|
||||
byte[] chk = await Collect(4000, _ => false); // self-test ~1-2s, lamps flash
|
||||
int checkReplies = Count(chk, 0x85);
|
||||
Console.WriteLine($"[check ] CheckRequest sent: self-test ran (lamps flash), {checkReplies} status frame(s).");
|
||||
Console.WriteLine($" Edit-6 chips repaint, then re-render the over-threshold E0 readout.");
|
||||
Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(cycles, teardowns)} — small drift in either pair is normal");
|
||||
Console.WriteLine($" (late ACKs among the status frames start timeout retries: $3184 +1 each;");
|
||||
Console.WriteLine($" 04000000 here = no edit 6 on this chip) <<<");
|
||||
await Task.Delay(3000);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== done ==");
|
||||
Console.WriteLine($" final predicted counters: $3184=${cycles:X2} $3185=${teardowns:X2} -> display {Expected(cycles, teardowns)}");
|
||||
Console.WriteLine(" PASS = display held F0000000 through every sub-threshold line above and");
|
||||
Console.WriteLine(" matched the E0 predictions after the threshold crossing.");
|
||||
Console.WriteLine(" (Stock firmware flips at the very first retry.) DTR reset restores F0.");
|
||||
|
||||
transport.Dispose();
|
||||
if (reader is not null) await reader;
|
||||
return 0;
|
||||
|
||||
async Task<bool> RunEvent(AckMode mode, string phase)
|
||||
{
|
||||
eventNo++;
|
||||
PhaseReset(mode, 0x87, 12); // analog reply: cmd+10+ck
|
||||
await transport!.WriteAsync(new byte[] { 0x82, 0x02 }, CancellationToken.None);
|
||||
|
||||
byte[] bytes = mode == AckMode.Never
|
||||
? await Collect(2500, b => IndexOf(b, 0xFE) >= 0)
|
||||
: await Collect(800, _ => false);
|
||||
await Task.Delay(250); // let the cycle finish either way
|
||||
bytes = Concat(bytes, Drain());
|
||||
|
||||
int transmissions = Count(bytes, 0x87);
|
||||
bool sawRestart = IndexOf(bytes, 0xFE) >= 0;
|
||||
if (transmissions == 0)
|
||||
{
|
||||
Console.WriteLine($"[event {eventNo}] NO analog reply ({bytes.Length} bytes) — aborting.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (transmissions > 1) teardowns++; // $3185: imperfect cycle
|
||||
if (sawRestart && mode == AckMode.Never) cycles++; // $3184: give-up
|
||||
|
||||
Console.WriteLine($"[event {eventNo}] {phase}: reply x{transmissions} " +
|
||||
$"({transmissions - 1} retransmit(s)), give-up cycle={mode == AckMode.Never} " +
|
||||
$"-> $3184=${cycles:X2} $3185=${teardowns:X2}");
|
||||
Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(cycles, teardowns)} <<<");
|
||||
await Task.Delay(4000);
|
||||
return true;
|
||||
}
|
||||
|
||||
string Expected(int cy, int td) =>
|
||||
cy >= Threshold || td >= Threshold ? $"E000{cy:X2}{td:X2}" : "F0000000";
|
||||
|
||||
byte[] Drain()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
byte[] r = buffer.Skip(cursor).ToArray();
|
||||
cursor = buffer.Count;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
int CountAll(byte v) { lock (gate) return buffer.Count(x => x == v); }
|
||||
bool SeenAll(byte v) { lock (gate) return buffer.Contains(v); }
|
||||
|
||||
async Task<byte[]> Collect(int ms, Func<byte[], bool> done)
|
||||
{
|
||||
var end = sw.Elapsed + TimeSpan.FromMilliseconds(ms);
|
||||
var all = new List<byte>();
|
||||
while (sw.Elapsed < end)
|
||||
{
|
||||
all.AddRange(Drain());
|
||||
if (done(all.ToArray())) break;
|
||||
await Task.Delay(15);
|
||||
}
|
||||
all.AddRange(Drain());
|
||||
return all.ToArray();
|
||||
}
|
||||
|
||||
static int IndexOf(byte[] a, byte v) => Array.IndexOf(a, v);
|
||||
static int Count(byte[] a, byte v) => a.Count(x => x == v);
|
||||
static byte[] Concat(byte[] a, byte[] b) => a.Concat(b).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.Diagnostics;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Protocol;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioSerialMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Instrumented firmware mash test (the RIO_TAP protocol, mechanized) for
|
||||
/// validating the RIOv4.2 reply-latch wedge patch — see the firmware
|
||||
/// analysis in the TeslaRel410 repo (restoration/rio-firmware/RIOv4_2-ANALYSIS.md).
|
||||
///
|
||||
/// Runs the live link with the app's own >5s reset-recovery DISABLED (so a
|
||||
/// board wedge stays observable instead of being revived by our watchdog),
|
||||
/// echoes lamps on every press (lamp writes colliding with analog replies are
|
||||
/// the wedge trigger), and records:
|
||||
/// - an inter-analog-reply gap histogram + the longest gaps with timestamps
|
||||
/// (gap timing uses ANY AnalogReply packet, valid or 0xFE-sentinel — a
|
||||
/// sentinel still proves the reply path is alive);
|
||||
/// - WEDGE events: analog silent past the threshold while button traffic
|
||||
/// continues; on resume, whether it self-recovered or was revived by a
|
||||
/// button press (the known unpatched revival mechanism);
|
||||
/// - the board's own RestartCount/AbandonCount/FullBufferCount before and
|
||||
/// after (CheckReply), with the delta;
|
||||
/// - a fixed-layout summary block, teed to a log file, so runs on the
|
||||
/// original vs patched chip diff cleanly.
|
||||
///
|
||||
/// Usage:
|
||||
/// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
|
||||
/// [--label name] [--no-lamps] [--wedge seconds]
|
||||
/// Defaults: COM1, 300 s, label "unlabeled", lamps on, wedge threshold 2 s.
|
||||
/// Exit: 0 = ran with no wedge, 1 = wedge detected, 2 = could not open port.
|
||||
/// </summary>
|
||||
internal static class MashTest
|
||||
{
|
||||
private sealed class WedgeEvent
|
||||
{
|
||||
public TimeSpan Start;
|
||||
public TimeSpan Duration;
|
||||
public int ButtonEventsDuring;
|
||||
public bool RevivedByButton;
|
||||
public bool Unresolved; // run ended while still wedged
|
||||
}
|
||||
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
// --- args ------------------------------------------------------------
|
||||
string port = "COM1";
|
||||
int seconds = 300;
|
||||
string label = "unlabeled";
|
||||
bool lampEcho = true;
|
||||
double wedgeSec = 2.0;
|
||||
bool selftest = false;
|
||||
int baud = 9600;
|
||||
int pollMs = 55; // legacy cadence; FastRIO can sustain much faster
|
||||
|
||||
var positional = new List<string>();
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--mash": break;
|
||||
case "--no-lamps": lampEcho = false; break;
|
||||
case "--selftest": selftest = true; break;
|
||||
case "--label" when i + 1 < args.Length: label = args[++i]; break;
|
||||
case "--wedge" when i + 1 < args.Length && double.TryParse(args[i + 1], out double w):
|
||||
wedgeSec = w; i++; break;
|
||||
case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b):
|
||||
baud = b; i++; break;
|
||||
case "--poll" when i + 1 < args.Length && int.TryParse(args[i + 1], out int p) && p > 0:
|
||||
pollMs = p; i++; break;
|
||||
default: positional.Add(args[i]); break;
|
||||
}
|
||||
}
|
||||
if (positional.Count > 0) port = positional[0];
|
||||
if (positional.Count > 1 && int.TryParse(positional[1], out int s)) seconds = s;
|
||||
if (selftest)
|
||||
{
|
||||
// Scripted board (SelftestTransport): short fixed run, expect exactly
|
||||
// one button-revived wedge and a +4/+1 counter delta → exit 1.
|
||||
port = "selftest";
|
||||
seconds = 10;
|
||||
if (label == "unlabeled") label = "selftest";
|
||||
}
|
||||
|
||||
string logPath = $"riomash-{label}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
|
||||
using var logFile = new StreamWriter(logPath) { AutoFlush = true };
|
||||
|
||||
var gate = new object();
|
||||
var sw = Stopwatch.StartNew();
|
||||
void Log(string msg)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
string line = $"[{sw.Elapsed.TotalSeconds,7:F2}s] {msg}";
|
||||
Console.WriteLine(line);
|
||||
logFile.WriteLine(line);
|
||||
}
|
||||
}
|
||||
void Raw(string msg)
|
||||
{
|
||||
lock (gate) { Console.WriteLine(msg); logFile.WriteLine(msg); }
|
||||
}
|
||||
|
||||
Raw($"== RIO mash test :: {port} @ {baud} 8N1, {seconds}s, chip label '{label}' ==");
|
||||
Raw($" lamp echo {(lampEcho ? "ON (drives reply/lamp collisions)" : "OFF")}, " +
|
||||
$"wedge threshold {wedgeSec:F1}s, poll {pollMs}ms, app auto-recovery DISABLED");
|
||||
Raw($" log: {Path.GetFullPath(logPath)}");
|
||||
|
||||
IRioTransport transport;
|
||||
try
|
||||
{
|
||||
transport = selftest ? new SelftestTransport() : RioTransportFactory.Open(port, baud);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Raw($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Recovery timeout effectively infinite: a wedge must be OURS to observe,
|
||||
// not silently repaired by the link's legacy >5s general-reset rule.
|
||||
var link = new RioSerialLink(transport, new RioSerialLinkOptions
|
||||
{
|
||||
AnalogRecoveryTimeout = TimeSpan.FromDays(365),
|
||||
AnalogPollInterval = TimeSpan.FromMilliseconds(pollMs),
|
||||
});
|
||||
|
||||
// --- shared state (guarded by 'gate') ---------------------------------
|
||||
int presses = 0, releases = 0, framing = 0, naks = 0, sentinels = 0;
|
||||
long analogReplies = 0;
|
||||
TimeSpan lastAnalog = TimeSpan.Zero;
|
||||
TimeSpan lastButton = TimeSpan.MinValue;
|
||||
|
||||
// Gap histogram buckets (upper bounds in ms; last = overflow).
|
||||
double[] bucketMs = { 100, 250, 500, 1000, 2000, 5000, double.MaxValue };
|
||||
string[] bucketNames = { "<100ms", "100-250ms", "250-500ms", "0.5-1s", "1-2s", "2-5s", ">5s" };
|
||||
long[] buckets = new long[bucketMs.Length];
|
||||
var longestGaps = new List<(TimeSpan At, double Ms)>(); // keep top 10
|
||||
|
||||
bool inWedge = false;
|
||||
var currentWedge = default(WedgeEvent);
|
||||
var wedges = new List<WedgeEvent>();
|
||||
bool statusCollecting = false;
|
||||
var statusItems = new List<CheckStatus>();
|
||||
VersionInfo? version = null;
|
||||
|
||||
void NoteGap(double ms, TimeSpan at)
|
||||
{
|
||||
for (int i = 0; i < bucketMs.Length; i++)
|
||||
{
|
||||
if (ms <= bucketMs[i]) { buckets[i]++; break; }
|
||||
}
|
||||
longestGaps.Add((at, ms));
|
||||
if (longestGaps.Count > 10)
|
||||
{
|
||||
longestGaps.Sort((a, b) => b.Ms.CompareTo(a.Ms));
|
||||
longestGaps.RemoveAt(10);
|
||||
}
|
||||
}
|
||||
|
||||
link.PacketReceived += p =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
switch (p.Command)
|
||||
{
|
||||
case RioCommand.AnalogReply:
|
||||
{
|
||||
TimeSpan now = sw.Elapsed;
|
||||
analogReplies++;
|
||||
foreach (byte b in p.Payload)
|
||||
{
|
||||
if (b == (byte)RioControl.Restart) { sentinels++; break; }
|
||||
}
|
||||
if (lastAnalog != TimeSpan.Zero)
|
||||
NoteGap((now - lastAnalog).TotalMilliseconds, lastAnalog);
|
||||
|
||||
if (inWedge && currentWedge is not null)
|
||||
{
|
||||
currentWedge.Duration = now - currentWedge.Start;
|
||||
currentWedge.RevivedByButton =
|
||||
lastButton > currentWedge.Start &&
|
||||
(now - lastButton) < TimeSpan.FromMilliseconds(300);
|
||||
wedges.Add(currentWedge);
|
||||
Log($"*** WEDGE ENDED after {currentWedge.Duration.TotalSeconds:F2}s — " +
|
||||
(currentWedge.RevivedByButton
|
||||
? "revived by a button press (unpatched-firmware signature)"
|
||||
: "self-recovered (patched-firmware expectation)") +
|
||||
$", {currentWedge.ButtonEventsDuring} button events during");
|
||||
inWedge = false;
|
||||
currentWedge = null;
|
||||
}
|
||||
lastAnalog = now;
|
||||
break;
|
||||
}
|
||||
|
||||
case RioCommand.ButtonPressed:
|
||||
case RioCommand.KeyPressed:
|
||||
presses++;
|
||||
lastButton = sw.Elapsed;
|
||||
if (inWedge && currentWedge is not null) currentWedge.ButtonEventsDuring++;
|
||||
// Lamp echo on lamp buttons (keypads have none): the lamp
|
||||
// number is the RIO address, not the raw button code.
|
||||
if (lampEcho && p.Command == RioCommand.ButtonPressed &&
|
||||
p.Payload[0] < RioAddress.ButtonCount)
|
||||
_ = link.SetLampAsync((byte)RioAddress.FromButton(p.Payload[0]), RioLampState.SolidBright);
|
||||
break;
|
||||
|
||||
case RioCommand.ButtonReleased:
|
||||
case RioCommand.KeyReleased:
|
||||
releases++;
|
||||
lastButton = sw.Elapsed;
|
||||
if (inWedge && currentWedge is not null) currentWedge.ButtonEventsDuring++;
|
||||
if (lampEcho && p.Command == RioCommand.ButtonReleased &&
|
||||
p.Payload[0] < RioAddress.ButtonCount)
|
||||
_ = link.SetLampAsync((byte)RioAddress.FromButton(p.Payload[0]), RioLampState.SolidDim);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
link.VersionReceived += v => { lock (gate) version = v; };
|
||||
link.CheckReceived += c => { lock (gate) { if (statusCollecting) statusItems.Add(c); } };
|
||||
link.FramingError += () => { lock (gate) framing++; };
|
||||
link.ControlReceived += b => { if ((RioControl)b == RioControl.Nak) lock (gate) naks++; };
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
// Ctrl+C = "my fingers hurt": end the run NOW but still snapshot the
|
||||
// counters and print the full summary, instead of dying summary-less
|
||||
// (and leaving an orphan holding the port, as an interrupted run did).
|
||||
bool stopRequested = false;
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true; // we shut down ourselves
|
||||
stopRequested = true;
|
||||
};
|
||||
|
||||
// --- counters BEFORE ---------------------------------------------------
|
||||
await Task.Delay(500);
|
||||
Dictionary<RioStatusType, int> before = await SnapshotStatusAsync();
|
||||
Raw($" firmware: {(version is null ? "(no version reply!)" : version.ToString())}");
|
||||
Raw($" counters before: {FormatCounters(before)}");
|
||||
Raw("");
|
||||
Raw(">>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<");
|
||||
Raw($">>> Test runs {seconds}s. A wedge alarm will beep + banner. <<<");
|
||||
Raw(">>> Ctrl+C ends the run early and still prints the summary. <<<");
|
||||
Raw("");
|
||||
|
||||
// --- wedge watchdog + progress ticker ----------------------------------
|
||||
TimeSpan runEnd = sw.Elapsed + TimeSpan.FromSeconds(seconds);
|
||||
TimeSpan nextProgress = sw.Elapsed + TimeSpan.FromSeconds(30);
|
||||
while (sw.Elapsed < runEnd)
|
||||
{
|
||||
if (stopRequested)
|
||||
{
|
||||
seconds = (int)sw.Elapsed.TotalSeconds; // summary reflects reality
|
||||
Log("operator stop (Ctrl+C) — ending run, snapshotting counters...");
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
lock (gate)
|
||||
{
|
||||
if (!inWedge && lastAnalog != TimeSpan.Zero &&
|
||||
(sw.Elapsed - lastAnalog).TotalSeconds > wedgeSec)
|
||||
{
|
||||
inWedge = true;
|
||||
currentWedge = new WedgeEvent { Start = lastAnalog };
|
||||
Log($"*** WEDGE: no analog reply for >{wedgeSec:F1}s (last at {lastAnalog.TotalSeconds:F2}s). " +
|
||||
"Keep pressing buttons — do NOT power cycle. ***");
|
||||
try { Console.Beep(880, 400); } catch { /* no console beep available */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (sw.Elapsed >= nextProgress)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
double rate = presses / Math.Max(1.0, sw.Elapsed.TotalMinutes);
|
||||
double maxGap = longestGaps.Count > 0 ? longestGaps.Max(g => g.Ms) : 0;
|
||||
Log($"progress: presses={presses} ({rate:F0}/min) analog={analogReplies} " +
|
||||
$"maxGap={maxGap / 1000:F2}s wedges={wedges.Count}{(inWedge ? " [WEDGED NOW]" : "")}");
|
||||
}
|
||||
nextProgress = sw.Elapsed + TimeSpan.FromSeconds(30);
|
||||
}
|
||||
}
|
||||
|
||||
// Close out an unresolved wedge.
|
||||
lock (gate)
|
||||
{
|
||||
if (inWedge && currentWedge is not null)
|
||||
{
|
||||
currentWedge.Duration = sw.Elapsed - currentWedge.Start;
|
||||
currentWedge.Unresolved = true;
|
||||
wedges.Add(currentWedge);
|
||||
Log($"*** RUN ENDED WHILE WEDGED ({currentWedge.Duration.TotalSeconds:F1}s and counting) ***");
|
||||
}
|
||||
}
|
||||
|
||||
// --- counters AFTER ------------------------------------------------------
|
||||
Dictionary<RioStatusType, int> after = await SnapshotStatusAsync();
|
||||
|
||||
// Dispose the transport BEFORE awaiting the link: on a wedged (silent)
|
||||
// board the receive loop sits in a pending serial read that ignores
|
||||
// cancellation — closing the port is what unblocks it. Awaiting first
|
||||
// hangs forever and eats the summary (seen on the baseline chip run).
|
||||
cts.Cancel();
|
||||
transport.Dispose();
|
||||
try { await run; } catch { /* shutdown */ }
|
||||
|
||||
// --- summary (fixed layout for diffing runs) ------------------------------
|
||||
double mins = Math.Max(sw.Elapsed.TotalMinutes, 0.001);
|
||||
long expectedPolls = (long)(seconds * 1000.0 / pollMs);
|
||||
bool anyWedge = wedges.Count > 0;
|
||||
bool endedWedged = wedges.Any(w => w.Unresolved);
|
||||
bool anyButtonRevival = wedges.Any(w => w.RevivedByButton);
|
||||
|
||||
Raw("");
|
||||
Raw($"== MASH SUMMARY [{label}] ==");
|
||||
Raw($"run : {seconds}s on {port}, lamps {(lampEcho ? "on" : "off")}, wedge threshold {wedgeSec:F1}s");
|
||||
Raw($"firmware : {(version is null ? "(no reply)" : version.ToString())}");
|
||||
Raw($"presses/releases : {presses} / {releases} ({presses / mins:F0} presses/min)");
|
||||
Raw($"analog replies : {analogReplies} (~{expectedPolls} poll slots; {100.0 * analogReplies / Math.Max(1, expectedPolls):F1}%), sentinels {sentinels}");
|
||||
Raw($"framing / NAK : {framing} / {naks} (command resends NAK/timeout: {link.Retransmits})");
|
||||
Raw("gap histogram : " + string.Join(" ", bucketNames.Select((n, i) => $"{n}:{buckets[i]}")));
|
||||
Raw("longest gaps : " + (longestGaps.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", longestGaps.OrderByDescending(g => g.Ms).Take(10)
|
||||
.Select(g => $"{g.Ms / 1000:F2}s@{g.At.TotalSeconds:F1}s"))));
|
||||
Raw($"wedge events : {wedges.Count}" + (wedges.Count == 0 ? "" :
|
||||
" — " + string.Join("; ", wedges.Select(w =>
|
||||
$"{w.Duration.TotalSeconds:F1}s@{w.Start.TotalSeconds:F1}s " +
|
||||
(w.Unresolved ? "UNRESOLVED" : w.RevivedByButton ? "button-revived" : "self-recovered")))));
|
||||
Raw($"counters before : {FormatCounters(before)}");
|
||||
Raw($"counters after : {FormatCounters(after)}");
|
||||
Raw($"counter delta : {FormatDelta(before, after)}");
|
||||
|
||||
string verdict =
|
||||
endedWedged ? "FAIL — board wedged and never recovered (power cycle it now)" :
|
||||
anyButtonRevival ? "FAIL — wedge required a button press to revive (unpatched behavior)" :
|
||||
anyWedge ? "MARGINAL — wedge(s) occurred but self-recovered; compare durations to baseline" :
|
||||
"PASS — no wedge; compare gap histogram + counter delta to baseline";
|
||||
Raw($"verdict : {verdict}");
|
||||
Raw($"log file : {Path.GetFullPath(logPath)}");
|
||||
|
||||
return anyWedge ? 1 : 0;
|
||||
|
||||
// --- helpers ---------------------------------------------------------
|
||||
async Task<Dictionary<RioStatusType, int>> SnapshotStatusAsync()
|
||||
{
|
||||
// Two attempts: the first request can lose a race with the board's
|
||||
// post-DTR boot (port open pulses DTR = board reset), or get eaten
|
||||
// by a reply collision — seen on the 31250 bench run.
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
lock (gate) { statusItems.Clear(); statusCollecting = true; }
|
||||
try
|
||||
{
|
||||
await link.RequestVersionAsync();
|
||||
await link.RequestCheckAsync();
|
||||
}
|
||||
catch { /* port trouble surfaces as an empty snapshot */ }
|
||||
await Task.Delay(1200);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
statusCollecting = false;
|
||||
// Counters arrive one item per type; keep the last value per type.
|
||||
var map = new Dictionary<RioStatusType, int>();
|
||||
foreach (CheckStatus item in statusItems)
|
||||
map[item.Type] = item.Number;
|
||||
if (map.Count > 0 || attempt == 1)
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static string FormatCounters(Dictionary<RioStatusType, int> c) =>
|
||||
c.Count == 0
|
||||
? "(no status reply — reply path dead?)"
|
||||
: string.Join(" ", new[] { RioStatusType.RestartCount, RioStatusType.AbandonCount, RioStatusType.FullBufferCount }
|
||||
.Select(t => $"{t}={(c.TryGetValue(t, out int v) ? v.ToString() : "-")}"))
|
||||
+ (c.ContainsKey(RioStatusType.BoardBad) || c.ContainsKey(RioStatusType.LampBad)
|
||||
? " [board/lamp faults reported!]" : "");
|
||||
|
||||
static string FormatDelta(Dictionary<RioStatusType, int> b, Dictionary<RioStatusType, int> a)
|
||||
{
|
||||
if (b.Count == 0 || a.Count == 0) return "(incomplete — a snapshot got no reply)";
|
||||
return string.Join(" ", new[] { RioStatusType.RestartCount, RioStatusType.AbandonCount, RioStatusType.FullBufferCount }
|
||||
.Select(t =>
|
||||
{
|
||||
bool hb = b.TryGetValue(t, out int vb), ha = a.TryGetValue(t, out int va);
|
||||
// 7-bit payload counters can wrap at 128.
|
||||
return hb && ha ? $"{t}=+{(va - vb + 128) % 128}" : $"{t}=?";
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,32 @@ using RioJoy.Core.Serial;
|
||||
// log every event (buttons, keypad, axis, version/check, control bytes, framing).
|
||||
// It flashes all lamps once to prove the PC -> RIO output path, then echoes a lamp
|
||||
// on each button press so a physical press lights up.
|
||||
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds]
|
||||
// Exit: 0 = ran, 2 = could not open the port.
|
||||
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds] [--baud rate]
|
||||
// [port] is a COM name or a pipe endpoint: pipe:vrio connects to the vRIO
|
||||
// emulator's \\.\pipe\vrio (no com0com pair; vRIO must have its pipe open).
|
||||
// Firmware wedge-patch validation (RIO_TAP mash test, see MashTest.cs):
|
||||
// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
|
||||
// [--label baseline|patched] [--no-lamps] [--wedge seconds] [--baud rate]
|
||||
// --baud: 31250 for the retuned FastRIO firmware (TeslaRel410 restoration/rio-firmware, make_patch.py --baud31250).
|
||||
// Exit: 0 = ran, 2 = could not open the port (mash: 1 = wedge detected).
|
||||
|
||||
string port = args.Length > 0 ? args[0] : "COM1";
|
||||
int seconds = args.Length > 1 && int.TryParse(args[1], out int s) ? s : 30;
|
||||
// E0-threshold firmware verification (TeslaRel410 restoration/rio-firmware --e0thresh images, see E0Test.cs):
|
||||
// dotnet run --project tools/RioSerialMonitor -- --e0test [port] [--baud rate] [--events n]
|
||||
if (args.Contains("--e0test"))
|
||||
return await RioSerialMonitor.E0Test.RunAsync(args);
|
||||
|
||||
if (args.Contains("--mash"))
|
||||
return await RioSerialMonitor.MashTest.RunAsync(args);
|
||||
|
||||
int baud = 9600;
|
||||
var positional = new List<string>();
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--baud" && i + 1 < args.Length && int.TryParse(args[i + 1], out int b)) { baud = b; i++; }
|
||||
else positional.Add(args[i]);
|
||||
}
|
||||
string port = positional.Count > 0 ? positional[0] : "COM1";
|
||||
int seconds = positional.Count > 1 && int.TryParse(positional[1], out int s) ? s : 30;
|
||||
|
||||
Dictionary<int, string> groupOf = CockpitPanel.Buttons().ToDictionary(b => b.Address, b => b.Group.Title);
|
||||
string Name(int addr) => groupOf.TryGetValue(addr, out string? g) ? $"0x{addr:X2} ({g})" : $"0x{addr:X2}";
|
||||
@@ -24,12 +45,12 @@ void Log(string msg)
|
||||
lock (gate) Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] {msg}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"== RIO serial monitor :: {port} @ 9600 8N1 for {seconds}s ==");
|
||||
Console.WriteLine($"== RIO serial monitor :: {port} @ {baud} 8N1 for {seconds}s ==");
|
||||
|
||||
SerialPortTransport transport;
|
||||
IRioTransport transport;
|
||||
try
|
||||
{
|
||||
transport = new SerialPortTransport(port);
|
||||
transport = RioTransportFactory.Open(port, baud);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -68,7 +89,7 @@ bool Changed(AnalogReport a, AnalogReport b) =>
|
||||
link.PacketReceived += p =>
|
||||
{
|
||||
packets++;
|
||||
ReadOnlySpan<byte> pl = p.Payload.Span;
|
||||
byte[] pl = p.Payload;
|
||||
switch (p.Command)
|
||||
{
|
||||
case RioCommand.ButtonPressed when pl[0] < RioAddress.ButtonCount:
|
||||
@@ -134,9 +155,11 @@ Log(">>> NOW: press the MFD buttons, press keypad keys, and move the axis <<<");
|
||||
Log(">>> a pressed button should light up <<<");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(seconds));
|
||||
// Transport first: a silent board leaves the receive loop in a pending read
|
||||
// that only the port close unblocks (net48 ReadAsync ignores cancellation).
|
||||
cts.Cancel();
|
||||
try { await run; } catch { /* shutdown */ }
|
||||
transport.Dispose();
|
||||
try { await run; } catch { /* shutdown */ }
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("== summary ==");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user