Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ecb617c09 | ||
|
|
63af7bde01 | ||
|
|
1ff0b16015 | ||
|
|
3b2af7b79a | ||
|
|
02abfa8a14 | ||
|
|
a9de26eff6 | ||
|
|
6ad1abc919 | ||
|
|
ae7a5d21ee | ||
|
|
39d79e1fb1 |
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
+108
@@ -294,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user