Compare commits

...
10 Commits
Author SHA1 Message Date
CydandClaude Fable 5 2ecb617c09 Phase 8D: universal deployment package (XP + Win10/11, two entry points)
One RIOJoy-<ver>.zip now deploys on both OS generations (~75 MB with the
offline XP prerequisites):
- Layout: RIOJoy\app (net48 x64) + RIOJoy\app-xp (net40 x86) +
  RIOJoy\vendor (ViGEmBus; vendor\xp: .NET 4.0 offline installer +
  KB2468871, both signature-verified; RioGamepadXP driver slots in when
  8B lands - build warns/skips until then).
- RIOJoy\install-core.bat: shared OS-detecting install logic (ver ->
  5.1 = XP); pure cmd.exe on the XP path, delegates to install-rio.ps1
  on 10/11. Exports RIOJOY_APPDIR for shortcut creation.
- postinstall.bat (TeslaConsole, unattended): elevates, runs the core,
  then deletes install.bat + README.txt so C:\games stays clean.
- install.bat (standalone computers): same core + Start Menu/desktop
  shortcuts via make-shortcut.vbs (XP-safe); keeps the README.
- README.txt at the zip root explains which entry point to run.
- deploy *.ps1 re-encoded UTF-8-with-BOM: Windows PowerShell read the
  BOM-less files as ANSI, where an em-dash byte parses as a curly quote
  and breaks string parsing (bit install-rio.ps1 in dry-run testing).

Verified: zip layout correct; extracted package dispatch-tested on
Win10 non-elevated (OS detect -> modern route -> admin guard, system
untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:53:43 -05:00
CydandClaude Fable 5 63af7bde01 Phase 8C: RioJoy.Tray multi-targets net48 (x64) + net40 (x86, Windows XP)
The XP flavor keeps the full runtime AND the mapping editor (decided:
editor everywhere). Gated per flavor:
- Wallpaper maker + WallpaperCanvas + generation (SkiaSharp) are
  net48-only; the XP coordinator applies the profile's pre-rendered
  wallpaper instead, and WallpaperApplier converts PNG->BMP on net40
  (XP SPI_SETDESKWALLPAPER accepts only BMP).
- Joystick chain: ViGEm branch compiled out on net40 (HID feeder stays,
  ready for RioGamepadXP.sys); the editor button picker offers the full
  96 HID buttons on net40 vs the 11 named Xbox buttons on net48.

Verified: net40/x86 exe boots on Win10; the editor renders offline in a
32-bit host with a Button-50 joystick binding resolving correctly and
live gauges working (screenshot harness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:45:34 -05:00
CydandClaude Fable 5 1ff0b16015 Phase 8A (2/2): RioJoy.Core multi-targets net48 + net40 (Windows XP flavor)
- TargetFrameworks net48;net40. net48 keeps x64 + ViGEm + System.IO.Ports
  package; net40 adds Microsoft.Bcl.Async + System.ValueTuple and uses the
  in-box SerialPort.
- Compat/TaskCompat bridges Task.Run/Delay/WhenAny/WhenAll (TaskEx on
  net40) and SemaphoreSlim.WaitAsync (net40 blocks briefly - trivial at
  9600 baud).
- IReadOnlyList/IReadOnlyDictionary -> IList/IDictionary throughout
  (net40 predates the IReadOnly* interfaces and the Bcl backport cannot
  make arrays implement them).
- HashCode.Combine replaced with a manual combine (Bcl.HashCode has no
  net40 build); Marshal.SizeOf<T> -> typeof form; ViGEmJoystickSink
  gated #if !NET40. HidFeederJoystickSink stays on both flavors - it
  will drive RioGamepadXP.sys on XP via the same contract.

Both TFMs build; 275 tests green on net48.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:41:58 -05:00
CydandClaude Fable 5 3b2af7b79a Phase 8A (1/2): de-Span the serial layer, swap JSON to Newtonsoft
Prepares RioJoy.Core for the net40 (Windows XP) target, which has no
System.Memory, ValueTask, or System.Text.Json:
- IRioTransport and the whole protocol/framing layer now use byte[] +
  Task (RioPacket.Payload, PacketParser/Builder, RioChecksum, replies,
  AnalogReport, RioHidReport). At 9600 baud Span bought nothing; the
  SerialPortTransport bridge copies disappear entirely.
- ConfigStore/OverlayTemplateStore switch to Newtonsoft 13 with the
  same conventions (indented, PascalCase, string enums, null-skipping);
  verified against the real STJ-written config.json and regions.json
  (load + round-trip). System.Memory and System.Text.Json packages
  dropped.

275 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:36:19 -05:00
CydandClaude Fable 5 02abfa8a14 Phase 8D: root README.txt + postinstall.bat cleans the C:\games root
The universal archive gains a root README.txt aimed at standalone
installers (which entry point to run, supported OSes, offline prereqs).
postinstall.bat (the TeslaConsole hook) deletes install.bat and that
README as its final step so cabinet deploys leave C:\games clean;
install.bat keeps the README as the standalone machine's docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:27:41 -05:00
CydandClaude Fable 5 a9de26eff6 Phase 8: final decisions — editor everywhere, universal archive, dual installers
- Mapping editor ships in all instances, XP included (wallpaper maker
  stays modern-only with SkiaSharp).
- One dist zip deploys on both XP and Win10/11: app (net48 x64) +
  app-xp (net40 x86) + vendor prereqs incl. offline .NET 4.0/KB2468871
  redistributables and RioGamepadXP.sys.
- Two entry points: postinstall.bat (TeslaConsole/launcher, unattended)
  and install.bat (freestanding, adds shortcuts). OS-detected via ver;
  pure cmd on the XP path.

All Phase 8 open decisions are now resolved; ready to implement 8A.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:22:20 -05:00
CydandClaude Fable 5 6ad1abc919 Phase 8 decision: no vJoy — XP gets RioGamepadXP.sys, our own HID minidriver
vJoy/PPJoy are unmaintained, so third-party virtual joysticks are out.
The XP joystick becomes a thin WDM HID minidriver (vhidmini shape,
WDK 7.1.0, x86, no signing required on XP) exposing the same Public.h
IOCTL + 25-byte report contract as the modern RioGamepad.sys, so
HidFeederJoystickSink drives both. Precedent: FASA's tasgame.sys was an
XP HID minidriver. Staged: XP app ships keyboard/lamps/plasma first,
driver as milestone 2. Win10/11 chain unchanged (ViGEm preferred).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:14:23 -05:00
CydandClaude Fable 5 ae7a5d21ee Phase 8 decision: Xbox 360 pad (ViGEm) stays preferred on Win10/11
vJoy is fallback-only on modern Windows and the primary backend on XP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:12:06 -05:00
CydandClaude Fable 5 39d79e1fb1 Plan Phase 8: Windows XP compatibility (net40/x86 + vJoy dual-target)
One codebase, two flavors: Win10/11 stays net48/x64 with ViGEm/RioGamepad
and remains the authoring platform; XP SP3 gets a net40/x86 runtime with
vJoy (full 96-button fidelity), pre-rendered wallpapers (PNG converted to
BMP at apply), Newtonsoft JSON shared by both flavors, Bcl.Async + small
compat shims, and a .bat-only installer. Grounded in a source audit:
about 20 Span/Memory uses in the 9600-baud serial path, 6 modern Task
calls, 1 HashCode.Combine, System.Text.Json in two stores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:55:38 -05:00
CydandClaude Fable 5 2ac3188528 Fix pedal gauges frozen under ZR mix: feed L/R from pre-mix pedal readouts
The editor's L/R gauges read Rx/Ry, but EnableZR (on in real profiles)
folds the pedals into Rz and pins Rx/Ry to center, so the bars never
moved with the physical pedals. AxisCalibrator now exposes
LeftPedalOutput/RightPedalOutput (the calibrated pre-mix positions) and
RioRuntime.AxesUpdated carries an AxisReadout (the six virtual axes +
both pedals); the strip draws L/R from the pedal readouts, Z/Rz/X/Y
from the axes. With ZR off the readouts equal Rx/Ry, so nothing
changes there. Verified by screenshot: Rx/Ry centered while L=75% and
R=25% render correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:22:05 -05:00
64 changed files with 781 additions and 198 deletions
+59
View File
@@ -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.
+56 -12
View File
@@ -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,45 @@ 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)' }
)
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 }
+68
View File
@@ -0,0 +1,68 @@
@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 (bundled from Phase 8B onward).
if exist "%PKGROOT%vendor\xp\RioGamepadXP.inf" (
echo Installing the RioGamepadXP virtual joystick driver...
rundll32 setupapi.dll,InstallHinfSection DefaultInstall 132 %PKGROOT%vendor\xp\RioGamepadXP.inf
) else (
echo Note: RioGamepadXP driver not bundled yet - 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 -1
View File
@@ -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).
+54
View File
@@ -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%
+27
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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,
+112 -3
View File
@@ -281,11 +281,12 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
Opened from the tray ("Edit profile…"); Save persists the profile. The encoder
gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
(post invert/deadzone/mix — exactly what the virtual joystick receives) into the
gauges are **live**: `RioRuntime.AxesUpdated` streams an `AxisReadout` (the six
virtual-joystick outputs plus the calibrated pre-mix pedal positions) into the
strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested). L/R read the pedals
from before the ZR mix, since the mix pins Rx/Ry to center.
⏳ Still to refine: showing each button's assigned key as a
caption, grouping a button's two bank addresses, and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
@@ -293,6 +294,114 @@ 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, the XP flavor of our own driver.** Third-party
virtual-joystick drivers are ruled out (decided: **no vJoy** — the project
is unmaintained; PPJoy likewise). Instead, rebuild the thin driver side of
our existing split for XP: a **WDM HID minidriver** in the shape of the
DDK `vhidmini` sample (`HidRegisterMinidriver`, x86), exposing the **same
`Public.h` contract** as the modern driver — identical
`IOCTL_RIO_SUBMIT_REPORT`, identical 25-byte report, same descriptor
(6×16-bit axes, hat, 96 buttons) — so the existing `HidFeederJoystickSink`
drives it unchanged (only the device path differs). Precedent: the
original FASA `tasgame.sys` was exactly an XP HID minidriver
(docs/Win32RIO/, analyzed); ours stays thin with serial in user mode.
Toolchain: **WDK 7.1.0** (last XP-capable kit) under `driver/RioGamepadXP/`;
XP x86 enforces no kernel signing, so install is just the INF — none of
the Phase 1 test-signing/Secure Boot friction exists there.
Acquisition order (decided): **the Xbox 360 pad (ViGEm) stays preferred
on 10/11** — net48: ViGEm → RioGamepad → Null (unchanged);
net40: RioGamepadXP → Null.
*Staging:* the XP app is useful before the driver lands — milestone 1
ships keyboard/mouse + lamps + plasma (joystick = Null sink), the driver
follows as milestone 2.
- **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
@@ -70,6 +70,16 @@ public sealed class AxisCalibrator
private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max);
/// <summary>
/// The calibrated left-pedal position from the last <see cref="Update"/>
/// (<c>0..32766</c>) — always tracks the pedal, even when
/// <see cref="AxisCalibrationConfig.EnableZR"/> centers <see cref="AxisOutputs.Rx"/>.
/// </summary>
public int LeftPedalOutput => Clamp(_leftPedalLast);
/// <summary>Right-pedal counterpart of <see cref="LeftPedalOutput"/>.</summary>
public int RightPedalOutput => Clamp(_rightPedalLast);
private int Throttle(int throttle)
{
if (throttle < -RangeThrottle || throttle > RangeThrottle)
@@ -0,0 +1,27 @@
namespace RioJoy.Core.Calibration;
/// <summary>
/// One live calibration sample for gauge displays: the six virtual-joystick axis
/// outputs plus the calibrated pedal positions. The pedals are surfaced
/// separately because the ZR mix (<see cref="AxisCalibrationConfig.EnableZR"/>)
/// folds them into Rz and pins Rx/Ry to center — a pedal gauge fed from the axes
/// alone would freeze while the physical pedals move.
/// </summary>
public readonly struct AxisReadout
{
/// <summary>The six virtual-joystick outputs (what the HID device receives).</summary>
public AxisOutputs Axes { get; }
/// <summary>Calibrated left-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int LeftPedal { get; }
/// <summary>Calibrated right-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int RightPedal { get; }
public AxisReadout(AxisOutputs axes, int leftPedal, int rightPedal)
{
Axes = axes;
LeftPedal = leftPedal;
RightPedal = rightPedal;
}
}
+4 -2
View File
@@ -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;
+40
View File
@@ -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
}
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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 0x000x6F), 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));
+1 -4
View File
@@ -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>
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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)
@@ -114,7 +114,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;
+1 -1
View File
@@ -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 --------------------------------------------------------
+3 -1
View File
@@ -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",
};
@@ -125,3 +126,4 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
_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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -48,5 +48,5 @@ public sealed class PlasmaDisplay
}
private Task WriteAsync(byte[] data, CancellationToken ct) =>
_transport.WriteAsync(data, ct).AsTask();
_transport.WriteAsync(data, ct);
}
+4 -1
View File
@@ -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)
{
+13 -9
View File
@@ -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>
+1 -1
View File
@@ -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>();
+2 -1
View File
@@ -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.",
+9 -6
View File
@@ -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 });
}
+10 -7
View File
@@ -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();
+12 -5
View File
@@ -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);
}
+7 -6
View File
@@ -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}]";
}
}
+4 -3
View File
@@ -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]);
+30 -10
View File
@@ -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>
+8 -7
View File
@@ -57,11 +57,11 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
public event Action<int, bool>? ButtonActivity;
/// <summary>
/// Raised with the six calibrated axis values after every analog reply —
/// independent of the joystick sink, so the editor can show live gauges even
/// while input routing is suppressed.
/// Raised with the calibrated readout (virtual axes + pre-mix pedal positions)
/// after every analog reply — independent of the joystick sink, so the editor
/// can show live gauges even while input routing is suppressed.
/// </summary>
public event Action<AxisOutputs>? AxesUpdated;
public event Action<AxisReadout>? AxesUpdated;
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
public event Action<VersionInfo>? VersionReceived;
@@ -137,13 +137,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
// Output is best-effort: a faulty joystick sink must not kill the link.
}
AxesUpdated?.Invoke(axes);
AxesUpdated?.Invoke(new AxisReadout(
axes, _calibrator.LeftPedalOutput, _calibrator.RightPedalOutput));
}
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:
@@ -184,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)
+7 -2
View File
@@ -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);
}
+8 -8
View File
@@ -69,19 +69,19 @@ 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)
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
await Compat.TaskCompat.WaitAsync(_writeLock, cancellationToken).ConfigureAwait(false);
try
{
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
@@ -155,7 +155,7 @@ public sealed class RioSerialLink
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 +163,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 +191,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);
+5 -15
View File
@@ -47,21 +47,11 @@ public sealed class SerialPortTransport : IRioTransport
public string Description => $"{_port.PortName} @ {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 +74,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)
{
+3 -3
View File
@@ -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)
{
+8 -6
View File
@@ -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()
{
@@ -32,7 +32,7 @@ internal sealed class PanelCanvas : Control
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
private readonly HashSet<int> _livePressed = new();
private AxisOutputs? _axes;
private AxisReadout? _axes;
public int? SelectedAddress { get; private set; }
@@ -46,14 +46,16 @@ internal sealed class PanelCanvas : Control
}
/// <summary>
/// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at
/// Show the latest calibrated readout on the encoder-gauge strip. Arrives at
/// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a
/// value actually changed.
/// </summary>
public void SetAxes(AxisOutputs axes)
public void SetAxes(AxisReadout axes)
{
if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z &&
p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz)
if (_axes is { } p &&
p.Axes.X == axes.Axes.X && p.Axes.Y == axes.Axes.Y && p.Axes.Z == axes.Axes.Z &&
p.Axes.Rx == axes.Axes.Rx && p.Axes.Ry == axes.Axes.Ry && p.Axes.Rz == axes.Axes.Rz &&
p.LeftPedal == axes.LeftPedal && p.RightPedal == axes.RightPedal)
return;
_axes = axes;
Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip));
+15 -14
View File
@@ -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,13 +69,13 @@ 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,
Func<int, bool> live,
AxisOutputs? axes,
AxisReadout? axes,
int? selected)
{
g.Clear(Color.FromArgb(28, 28, 28));
@@ -152,7 +152,7 @@ public static class PanelView
}
}
private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes)
private static void DrawEncoderStrip(Graphics g, Font font, AxisReadout? axes)
{
using var pen = new Pen(Color.FromArgb(120, 160, 120));
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
@@ -181,12 +181,13 @@ public static class PanelView
g.FillRectangle(gauge, box);
// Live values from the RIO's analog stream (null when the link is down).
// The gauges show the calibrated outputs — post invert/deadzone/mix — i.e.
// exactly what the virtual joystick receives.
// Z/Rz/X/Y show the virtual-joystick outputs; L and R show the calibrated
// pedal positions from before the ZR mix, which pins Rx/Ry to center — the
// pedal gauges must track the physical pedals either way.
if (axes is { } a)
{
// Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) })
// Z (throttle) and the L/R pedals fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Axes.Z), (lBox, a.LeftPedal), (rBox, a.RightPedal) })
{
int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
if (h > 0)
@@ -194,7 +195,7 @@ public static class PanelView
}
// Rz (mixed rudder) deflects left/right from the bar's center.
double d = AxisGauges.Deflection(a.Rz);
double d = AxisGauges.Deflection(a.Axes.Rz);
int cx = rzBox.X + rzBox.Width / 2;
int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
if (w > 0)
@@ -217,14 +218,14 @@ public static class PanelView
// (0 = left/top, matching the HID axis convention).
if (axes is { } live)
{
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1));
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.Axes.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Axes.Y) * (xyBox.Height - 1));
using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
g.FillEllipse(dot, px - 3, py - 3, 7, 7);
}
}
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))
+8 -2
View File
@@ -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 })
@@ -466,8 +472,8 @@ public sealed class ProfileEditorForm : Form
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
/// <summary>Show the live calibrated axis values on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisOutputs axes) =>
/// <summary>Show the live calibrated readout on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisReadout axes) =>
RunOnUi(() => _canvas.SetAxes(axes));
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
+20 -1
View File
@@ -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;
@@ -149,12 +151,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))
{
@@ -241,6 +245,20 @@ 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
{
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;
@@ -263,6 +281,7 @@ public sealed class RioCoordinator : IDisposable
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
#endif
}
private static string SafeFileName(string name)
+25 -5
View File
@@ -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,29 @@
<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>
</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>
@@ -74,10 +74,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 +157,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 +208,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()
+16
View File
@@ -30,6 +30,22 @@ 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);
}
@@ -69,6 +69,26 @@ public class AxisCalibratorTests
// lP = (-100) - 0 = -100 → ×1000/500 = -200 → |·|×32 = 6400.
Assert.Equal(6400, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Rz); // Rz centered when ZR disabled
// The pedal readouts mirror Rx/Ry when there is no mix.
Assert.Equal(o.Rx, cal.LeftPedalOutput);
Assert.Equal(o.Ry, cal.RightPedalOutput);
}
[Fact]
public void PedalOutputs_TrackPedals_EvenWithZrMix()
{
var cal = new AxisCalibrator(); // EnableZR default true
cal.Update(Report(left: -100, right: -100));
AxisOutputs o = cal.Update(Report(left: 0, right: 0));
// The mix centers Rx/Ry, but the pedal readouts still track the pedals
// (same math as the ZR-off Rx/Ry: 6400 for this deflection).
Assert.Equal(AxisOutputs.Center, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Ry);
Assert.Equal(6400, cal.LeftPedalOutput);
Assert.Equal(6400, cal.RightPedalOutput);
}
// --- Joystick (X / Y) ----------------------------------------------------
@@ -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
}
}
@@ -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);
@@ -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]
+8 -4
View File
@@ -1,5 +1,6 @@
using System.Diagnostics;
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
using AxisReadout = RioJoy.Core.Calibration.AxisReadout;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
@@ -81,19 +82,22 @@ public class RioRuntimeTests
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
AxisOutputs? seen = null;
AxisReadout? seen = null;
runtime.AxesUpdated += axes => seen = axes;
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// All raw axes at zero → joystick X/Y centered, same values the sink receives.
// All raw axes at zero → joystick X/Y centered, same values the sink receives;
// both pedals at rest → pedal readouts empty.
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await WaitUntilAsync(() => seen is not null);
Assert.Equal(AxisOutputs.Center, seen!.Value.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Y);
Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y);
Assert.Equal(0, seen.Value.LeftPedal);
Assert.Equal(0, seen.Value.RightPedal);
cts.Cancel();
await run;
@@ -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>
+1 -1
View File
@@ -68,7 +68,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: