The portable config a bundle shipped carried only Profiles, so every app-level setting - ports, baud, poll rate - rode whatever that build of RIOJoy compiled in. That matched today and would drift invisibly the day a default changes or the FastRIO path moves AnalogPollMs. The generated config now writes them out, so a bundle says what it runs with. Verified by building the Descent 3 bundle and parsing the result. profiles/descent3.json was a pre-cockpit draft: old button layout, no explicit ports, no overlay labels. Replaced verbatim with the flown-and-confirmed Tesla profile from the game's own repo (Descent3 venue/riojoy/tesla.riojoy.json, which pack-dist feeds to build-pod and is the canonical copy) - explicit COM1/COM2, the 30-mapping layout, afterburner on the thumb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
13 KiB
PowerShell
258 lines
13 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Build a SELF-CONTAINED pod bundle of RIOJoy for one podized game
|
|
(PLAN.md Phase 10): a drop-in the game's install carries inside its own
|
|
directory - app + portable config (the game's profile) + start script +
|
|
ALL prerequisites (ViGEmBus / XP driver + .NET payload) with an
|
|
idempotent install entry point. The game's postinstall.bat calls
|
|
install-riojoy.bat (safe to re-run); the game's launch script calls
|
|
start-riojoy.bat; RIOJoy exits by itself when the game exits
|
|
(--exit-with). No operator ever touches the pod to install anything.
|
|
|
|
There is deliberately NO uninstall step: drivers are abandoned in place
|
|
on game removal, because nothing can know whether another podized game
|
|
still uses them - and idle drivers are harmless.
|
|
|
|
.PARAMETER ProfileJson
|
|
Path to the game's single-profile JSON document (must carry "Name";
|
|
same format as profiles\descent-d1x.json).
|
|
.PARAMETER GameExe
|
|
Game executable name for --exit-with. Default: the profile's first
|
|
MatchExecutables entry.
|
|
.PARAMETER Flavor
|
|
net48 (Windows 10/11 pods, default) or net40 (XP pods, x86). Selects
|
|
both the app build and which prerequisites are bundled.
|
|
.PARAMETER VigemInstaller
|
|
net48 only: path to the signed ViGEmBus installer. If omitted, looks in
|
|
deploy\vendor\ViGEmBus*.exe.
|
|
.PARAMETER OutDir
|
|
Where to write the zip (default: dist, relative to the repo root).
|
|
.PARAMETER Configuration
|
|
Build configuration (default: Release).
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ProfileJson,
|
|
[string]$GameExe,
|
|
[ValidateSet('net48', 'net40')]
|
|
[string]$Flavor = 'net48',
|
|
[string]$VigemInstaller,
|
|
[string]$OutDir = 'dist',
|
|
[string]$Configuration = 'Release'
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$repo = Split-Path $PSScriptRoot -Parent # deploy\ -> repo root
|
|
$staging = Join-Path ([IO.Path]::GetTempPath()) "riojoy-pod-$([Guid]::NewGuid().ToString('N'))"
|
|
|
|
Write-Host '== RIOJoy pod bundle (self-contained) ==' -ForegroundColor Cyan
|
|
|
|
# --- profile document -----------------------------------------------------
|
|
if (-not (Test-Path $ProfileJson)) { throw "Profile document not found: $ProfileJson" }
|
|
$profileText = Get-Content $ProfileJson -Raw
|
|
$profileDoc = $profileText | ConvertFrom-Json
|
|
if (-not $profileDoc.Name) { throw "Profile file '$ProfileJson' has no Name." }
|
|
if (-not $GameExe) {
|
|
$GameExe = @($profileDoc.MatchExecutables) | Select-Object -First 1
|
|
if (-not $GameExe) { throw "Profile has no MatchExecutables; pass -GameExe <name>." }
|
|
}
|
|
|
|
# --- version stamp --------------------------------------------------------
|
|
$sha = (& git -C $repo rev-parse --short HEAD 2>$null)
|
|
if (-not $sha) { $sha = 'nogit' }
|
|
$version = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd'), $sha
|
|
$safeName = $profileDoc.Name
|
|
foreach ($c in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($c, '_') }
|
|
$safeName = $safeName -replace ' ', '-'
|
|
|
|
try {
|
|
# The payload uses the SAME inner layout as the universal package
|
|
# (app / app-xp, vendor, install-core.bat, install-rio.ps1), so the
|
|
# idempotent install machinery is reused verbatim - no forked scripts.
|
|
$pkgDir = Join-Path $staging 'riojoy'
|
|
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
|
|
$appDirName = if ($Flavor -eq 'net48') { 'app' } else { 'app-xp' }
|
|
$appOut = Join-Path $pkgDir $appDirName
|
|
|
|
# 1. Publish the tray app (framework-dependent, like the universal package).
|
|
Write-Host "Publishing RioJoy.Tray ($Configuration, $Flavor)..."
|
|
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
|
|
-c $Configuration -f $Flavor -p:DebugType=none `
|
|
-o $appOut | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw "dotnet publish ($Flavor) failed." }
|
|
|
|
if ($Flavor -eq 'net48') {
|
|
# Same SkiaSharp pruning as build-package.ps1.
|
|
foreach ($d in 'x64', 'x86', 'arm64') {
|
|
$nd = Join-Path $appOut $d
|
|
if (Test-Path $nd) { Remove-Item $nd -Recurse -Force }
|
|
}
|
|
Get-ChildItem $appOut -Filter '*.dylib' -ErrorAction SilentlyContinue | Remove-Item -Force
|
|
Get-ChildItem $appOut -Filter '*.so' -ErrorAction SilentlyContinue | Remove-Item -Force
|
|
}
|
|
|
|
# 2. Portable config beside the exe (ConfigLocator picks it over %APPDATA%):
|
|
# an AppConfig wrapping this game's profile, verbatim, plus the app-level
|
|
# settings written out explicitly. A config that carries only Profiles
|
|
# leaves every app-level value to whatever this build of RIOJoy compiles
|
|
# in - which happens to match today, and would drift invisibly the day a
|
|
# default changes or a pod needs tuning (the FastRIO path will move
|
|
# AnalogPollMs). A pod bundle should say what it runs with.
|
|
$configJson = @"
|
|
{
|
|
"DefaultRioComPort": "COM1",
|
|
"DefaultPlasmaComPort": "COM2",
|
|
"RioBaudRate": 9600,
|
|
"AnalogPollMs": 55,
|
|
"Profiles": [
|
|
$profileText
|
|
]
|
|
}
|
|
"@
|
|
Set-Content -Path (Join-Path $appOut 'config.json') -Value $configJson -Encoding utf8
|
|
Set-Content -Path (Join-Path $pkgDir 'VERSION.txt') -Value "RIOJoy pod bundle $version ($Flavor) - $($profileDoc.Name)" -Encoding utf8
|
|
|
|
# 3. Prerequisites + the shared idempotent install core, per flavor.
|
|
Copy-Item (Join-Path $PSScriptRoot 'install-core.bat') $pkgDir
|
|
$vendorOut = Join-Path $pkgDir 'vendor'
|
|
New-Item -ItemType Directory -Force -Path $vendorOut | Out-Null
|
|
|
|
if ($Flavor -eq 'net48') {
|
|
Copy-Item (Join-Path $PSScriptRoot 'install-rio.ps1') $pkgDir
|
|
if (-not $VigemInstaller) {
|
|
$VigemInstaller = Get-ChildItem -Path (Join-Path $PSScriptRoot 'vendor') -Filter 'ViGEmBus*.exe' `
|
|
-ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
|
}
|
|
if (-not $VigemInstaller -or -not (Test-Path $VigemInstaller)) {
|
|
throw "ViGEmBus installer not found. Pass -VigemInstaller <path>, or drop ViGEmBus_*.exe into deploy\vendor\."
|
|
}
|
|
$sig = Get-AuthenticodeSignature $VigemInstaller
|
|
if ($sig.Status -ne 'Valid') { throw "ViGEmBus installer is not validly signed ($($sig.Status)): $VigemInstaller" }
|
|
Copy-Item $VigemInstaller $vendorOut
|
|
Write-Host "Bundled prerequisite: $(Split-Path $VigemInstaller -Leaf) (signature $($sig.Status))"
|
|
} else {
|
|
$vendorXpSrc = Join-Path $PSScriptRoot 'vendor\xp'
|
|
$vendorXpOut = Join-Path $vendorOut 'xp'
|
|
New-Item -ItemType Directory -Force -Path $vendorXpOut | Out-Null
|
|
$xpWanted = @(
|
|
'dotNetFx40_Full_x86_x64.exe', 'NDP40-KB2468871-v2-x86.exe',
|
|
'RioGamepadXP.inf', 'RioGamepadXP.sys', 'devcon.exe'
|
|
)
|
|
foreach ($name in $xpWanted) {
|
|
$src = Join-Path $vendorXpSrc $name
|
|
if (Test-Path $src) {
|
|
Copy-Item $src $vendorXpOut
|
|
Write-Host "Bundled XP prerequisite: $name"
|
|
} else {
|
|
Write-Warning "XP prerequisite missing from deploy\vendor\xp: $name - install will warn/skip."
|
|
}
|
|
}
|
|
}
|
|
|
|
# 4. install-riojoy.bat - the game's postinstall.bat calls this. Elevates
|
|
# (modern) like the universal postinstall.bat, runs the shared core,
|
|
# deletes nothing. Idempotent; there is no uninstall counterpart.
|
|
$installBat = @(
|
|
'@echo off'
|
|
'rem ==========================================================================='
|
|
"rem RIOJoy pod install for $($profileDoc.Name) - call from the game's"
|
|
'rem postinstall.bat. Idempotent: safe to run on every (re)install/update.'
|
|
'rem Installs only what is absent (drivers, runtime); never removes anything.'
|
|
'rem There is deliberately NO uninstall: other podized games may share the'
|
|
'rem drivers, and abandoned-in-place drivers are harmless.'
|
|
'rem ==========================================================================='
|
|
'setlocal'
|
|
'net session >nul 2>&1'
|
|
'if %errorlevel% neq 0 ('
|
|
' ver | findstr /C:"Version 5.1" >nul'
|
|
' if not errorlevel 1 ('
|
|
' echo install-riojoy.bat must run as an Administrator user.'
|
|
' exit /b 1'
|
|
' )'
|
|
' echo Requesting administrator privileges...'
|
|
" powershell -NoProfile -Command `"Start-Process -FilePath '%~f0' -Verb RunAs -Wait`""
|
|
' exit /b 0'
|
|
')'
|
|
'call "%~dp0riojoy\install-core.bat"'
|
|
'exit /b %errorlevel%'
|
|
) -join "`r`n"
|
|
Set-Content -Path (Join-Path $staging 'install-riojoy.bat') -Value $installBat -Encoding Ascii
|
|
|
|
# 5. Start script: the game's launch script calls this before the game.
|
|
# Everything explicit - no foreground detection: --profile activates
|
|
# immediately so the virtual pad exists before the game enumerates
|
|
# controllers, and --exit-with ends RIOJoy when the game exits.
|
|
$startBat = @(
|
|
'@echo off'
|
|
"rem RIOJoy pod companion for $($profileDoc.Name)."
|
|
'rem Call from the game''s launch script BEFORE starting the game:'
|
|
'rem the profile activates immediately (no foreground detection), so'
|
|
'rem the virtual controller exists before the game enumerates devices.'
|
|
'rem RIOJoy exits by itself when the game exits (--exit-with).'
|
|
"start `"`" `"%~dp0riojoy\$appDirName\RioJoy.Tray.exe`" --profile `"$($profileDoc.Name)`" --exit-with $GameExe"
|
|
) -join "`r`n"
|
|
Set-Content -Path (Join-Path $staging 'start-riojoy.bat') -Value $startBat -Encoding Ascii
|
|
|
|
# 6. Integrator note.
|
|
$readme = @(
|
|
"RIOJoy pod bundle - $($profileDoc.Name) ($version, $Flavor)"
|
|
''
|
|
'Self-contained: app, this game''s profile, and all RIOJoy prerequisites'
|
|
'(drivers/runtime). Nothing is installed on the pod by hand.'
|
|
''
|
|
'Integrate into the game''s package:'
|
|
' 1. Ship the riojoy\ folder, install-riojoy.bat and start-riojoy.bat'
|
|
' inside the game''s install directory.'
|
|
' 2. Call install-riojoy.bat from the game''s postinstall.bat.'
|
|
' Idempotent - safe on every install, reinstall and update; it only'
|
|
' adds what is missing and never removes anything.'
|
|
' 3. Call start-riojoy.bat from the game''s launch script before the'
|
|
' game. The profile activates immediately and explicitly - no'
|
|
' foreground detection - so the virtual controller exists before'
|
|
' the game enumerates devices; RIOJoy exits by itself when the'
|
|
' game exits. A launcher should wait (with a timeout) on the named'
|
|
' event "RIOJoy.Tray.Ready" before starting the game: signaled ='
|
|
' pad + ports ready; RIOJoy exit code 4/5 = config/activation'
|
|
' failure (reason on stderr); neither = stuck, report.'
|
|
' 4. Put NOTHING in the game''s pre-uninstall: drivers stay in place by'
|
|
' design (another podized game may use them; idle drivers are'
|
|
' harmless).'
|
|
''
|
|
"The game's profile lives in riojoy\$appDirName\config.json (portable"
|
|
'mode - the per-user %APPDATA% config is ignored while it exists). Edit'
|
|
"it there, or run riojoy\$appDirName\RioJoy.Tray.exe with no arguments"
|
|
'for the profile editor.'
|
|
) -join "`r`n"
|
|
Set-Content -Path (Join-Path $staging 'README-POD.txt') -Value $readme -Encoding Ascii
|
|
|
|
# 7. Zip with forward-slash entry names (same rationale as build-package.ps1).
|
|
$outDirFull = if ([IO.Path]::IsPathRooted($OutDir)) { $OutDir } else { Join-Path $repo $OutDir }
|
|
New-Item -ItemType Directory -Force -Path $outDirFull | Out-Null
|
|
$zip = Join-Path $outDirFull "RIOJoy-pod-$safeName-$version.zip"
|
|
if (Test-Path $zip) { Remove-Item $zip -Force }
|
|
Write-Host "Zipping -> $zip"
|
|
Add-Type -AssemblyName System.IO.Compression
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$base = (Resolve-Path $staging).Path.TrimEnd('\') + '\'
|
|
$fs = [System.IO.File]::Open($zip, [System.IO.FileMode]::CreateNew)
|
|
try {
|
|
$archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
|
|
try {
|
|
foreach ($file in Get-ChildItem -Path $staging -Recurse -File) {
|
|
$entryName = $file.FullName.Substring($base.Length) -replace '\\', '/'
|
|
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
|
$archive, $file.FullName, $entryName,
|
|
[System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
|
|
}
|
|
} finally { $archive.Dispose() }
|
|
} finally { $fs.Dispose() }
|
|
|
|
$size = '{0:N1} MB' -f ((Get-Item $zip).Length / 1MB)
|
|
Write-Host ''
|
|
Write-Host "Pod bundle built: $zip ($size)" -ForegroundColor Green
|
|
Write-Host "Integrate: extract into the game's folder; game postinstall.bat calls install-riojoy.bat; launch script calls start-riojoy.bat (game exe: $GameExe)."
|
|
}
|
|
finally {
|
|
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
|
|
}
|