diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f258e14..a156710 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,6 +14,10 @@ concurrency:
cancel-in-progress: true
jobs:
+ recompilation:
+ name: Recompilation test
+ uses: ./.github/workflows/recomp-test.yml
+
translator:
name: Translator (build + test)
runs-on: windows-latest
@@ -26,6 +30,14 @@ jobs:
with:
dotnet-version: '8.0.x'
+ - name: Cache NuGet packages
+ uses: actions/cache@v5
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('translator/Translator.sln', '**/*.csproj', '**/*.props', '**/*.targets', '**/packages.lock.json', 'global.json', 'NuGet.config', 'nuget.config') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Restore
run: dotnet restore translator/Translator.sln
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 6df88da..b97c62a 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -20,6 +20,10 @@ concurrency:
cancel-in-progress: true
jobs:
+ recompilation:
+ name: Recompilation test
+ uses: ./.github/workflows/recomp-test.yml
+
linux-appimage:
name: Linux (AppImage, ${{ matrix.arch }})
strategy:
@@ -42,12 +46,10 @@ jobs:
# appimagetool ships as an AppImage itself and needs to self-mount via FUSE to run directly;
# GitHub's ubuntu-latest runners don't have libfuse2 preinstalled.
- # patchelf: prepare-portable-tools.sh bundles the host libraries the llvm.org binaries link
- # against and rewrites their RPATH to find them.
- - name: Install dependencies (required by appimagetool, SDL3 build and toolchain bundling)
+ - name: Install dependencies (required by appimagetool and SDL3 build)
run: |
sudo apt-get update
- sudo apt-get install -y libfuse2 patchelf build-essential git make \
+ sudo apt-get install -y libfuse2 build-essential git make \
pkg-config cmake ninja-build gnome-desktop-testing libasound2-dev libpulse-dev \
libaudio-dev libfribidi-dev libjack-dev libsndio-dev libx11-dev libxext-dev \
libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev \
@@ -95,7 +97,7 @@ jobs:
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
- needs: [linux-appimage, windows-installer]
+ needs: [linux-appimage, windows-installer, recompilation]
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.github/workflows/recomp-test.yml b/.github/workflows/recomp-test.yml
new file mode 100644
index 0000000..3a1244a
--- /dev/null
+++ b/.github/workflows/recomp-test.yml
@@ -0,0 +1,61 @@
+name: Synthetic recompilation
+
+on:
+ workflow_call:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ windows:
+ name: Windows runtime (synthetic DOL)
+ runs-on: windows-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '8.0.x'
+
+ # Cache downloads only. Preparation still validates pins, and every run
+ # compiles current Aurora, runtime, and generated sources from scratch.
+ - uses: actions/cache@v5
+ with:
+ path: Launcher/artifacts/downloads
+ key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }}
+
+ # Install sccache.
+ - name: Run sccache-action
+ uses: mozilla/sccache-action@v0.0.11
+
+ # Tell CMake to use sccache and use GitHub's API.
+ - name: Configure sccache environment
+ shell: pwsh
+ run: |
+ "SCCACHE_GHA_ENABLED=true" | Add-Content -Path $env:GITHUB_ENV
+ "ACTIONS_CACHE_SERVICE_V2=on" | Add-Content -Path $env:GITHUB_ENV
+ "CMAKE_C_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
+ "CMAKE_CXX_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
+
+ - name: Prepare the shipped Windows toolchain
+ shell: pwsh
+ run: ./Launcher/Prepare-PortableTools.ps1
+
+ - name: Prepare pinned native dependencies
+ shell: pwsh
+ run: ./Launcher/Prepare-Dependencies.ps1
+
+ # Expose cache token context to the build script.
+ - name: Translate, compile the full runtime, and link
+ shell: pwsh
+ run: ./Launcher/Test-Recompilation.ps1 -Parallel 4
+
+ # Print cache results (even if the build fails)
+ - name: Show sccache stats
+ if: always()
+ shell: pwsh
+ run: sccache --show-stats
diff --git a/.gitignore b/.gitignore
index a7bb2a2..e9818b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,8 @@ Code.pul
/build/
/build-*/
/native-build/
+/native-build-macos/
+/local-products/
/dist/
/out/
[Bb]in/
@@ -71,3 +73,5 @@ project.lock.json
*.log
output.txt
+# Operating System
+.DS_Store
diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1
index 112018c..3e12790 100644
--- a/Launcher/Build-Installer.ps1
+++ b/Launcher/Build-Installer.ps1
@@ -282,7 +282,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
- ProductVersion = '0.2.26'
+ ProductVersion = '0.2.31'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
diff --git a/Launcher/NativeBuildFlags.ps1 b/Launcher/NativeBuildFlags.ps1
index 5402643..b288b2e 100644
--- a/Launcher/NativeBuildFlags.ps1
+++ b/Launcher/NativeBuildFlags.ps1
@@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) {
}
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
- [string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') {
+ [string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
<#
- Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait
- is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
- hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process
- doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
+ Runs a build tool and turns a non-zero exit code into a described failure. By default,
+ Start-Process -Wait waits for the whole process tree, since a .NET single-file bundle host may
+ hand off to an extracted child that PowerShell's call operator would not wait for. Callers that
+ need to avoid waiting on unrelated descendants can opt into the call-operator path.
+ Start-Process doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
-StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in
WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line.
#>
@@ -132,9 +133,14 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
'"' + $_ + '"'
})
- $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
- -NoNewWindow -Wait -PassThru
- $exitCode = $process.ExitCode
+ if ($WaitForProcessTree) {
+ $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
+ -NoNewWindow -Wait -PassThru
+ $exitCode = $process.ExitCode
+ } else {
+ & $FilePath @Arguments
+ $exitCode = $LASTEXITCODE
+ }
$global:LASTEXITCODE = $exitCode
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
}
diff --git a/Launcher/Test-Recompilation.ps1 b/Launcher/Test-Recompilation.ps1
new file mode 100644
index 0000000..789e4dd
--- /dev/null
+++ b/Launcher/Test-Recompilation.ps1
@@ -0,0 +1,148 @@
+# Build the real Windows runtime with translated, entirely synthetic PowerPC code.
+# No game dump, game symbol map, REL, mod download, or existing generated/ output is used.
+[CmdletBinding()]
+param(
+ [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
+ [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
+ [string]$StageDirectory = 'build/recomp-test',
+ [ValidateRange(1, 64)] [int]$Parallel = 3
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
+
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+function Full([string]$Path) {
+ if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
+ return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
+}
+$portableTools = Full $PortableToolsDirectory
+$dependencies = Full $DependencySourceDirectory
+$stage = Full $StageDirectory
+$dotnet = (Get-Command dotnet -CommandType Application).Source
+$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe'
+$ninja = Join-Path $portableTools 'Ninja/ninja.exe'
+$compilerBin = Join-Path $portableTools 'llvm-mingw/bin'
+Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)'
+Assert-File $ninja 'Pinned Ninja'
+Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)'
+
+# Refuse reuse so a developer's game translation or an earlier build cannot make
+# the test pass. Keep the staging tree after the run for diagnostics.
+if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" }
+[IO.Directory]::CreateDirectory($stage) | Out-Null
+Write-Host "Synthetic recompilation workspace: $stage"
+
+# Copy current sources, including uncommitted edits, but no ignored build output.
+# An isolated workspace preserves the developer's real generated/ directory.
+$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main
+if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' }
+foreach ($relative in $sourceFiles | Sort-Object -Unique) {
+ $destination = Join-Path $stage $relative
+ [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination
+}
+
+# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr.
+# Native HLE wrappers also call these eight guest symbols directly. Give each
+# its own generated blr function so the real product can link without game code.
+# Keep this list explicit: a new unresolved guest dependency must fail the test.
+[uint32]$entry = 0x80001000L
+[uint32[]]$guestCallbacks = @(
+ 0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL,
+ 0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL
+)
+$textSize = [int]($guestCallbacks[-1] - $entry + 4)
+$dataOffset = 0x100 + $textSize
+$dol = [byte[]]::new($dataOffset + 4)
+function Write-BigEndian32([int]$Offset, [uint32]$Value) {
+ $dol[$Offset] = [byte](($Value -shr 24) -band 255)
+ $dol[$Offset + 1] = [byte](($Value -shr 16) -band 255)
+ $dol[$Offset + 2] = [byte](($Value -shr 8) -band 255)
+ $dol[$Offset + 3] = [byte]($Value -band 255)
+}
+Write-BigEndian32 0x00 0x100 # text[0] file offset
+Write-BigEndian32 0x48 $entry # text[0] guest address
+Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero)
+Write-BigEndian32 0x1C $dataOffset # data[0] file offset
+Write-BigEndian32 0x64 0x80600000L # data[0] guest address
+Write-BigEndian32 0xAC 4 # data[0] length
+Write-BigEndian32 0xD8 0x80601000L # BSS address
+Write-BigEndian32 0xDC 32 # BSS length
+Write-BigEndian32 0xE0 $entry # entry point
+Write-BigEndian32 0x100 0x38600028 # li r3,40
+Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2
+Write-BigEndian32 0x108 0x60000000 # nop
+Write-BigEndian32 0x10C 0x4E800020 # blr
+foreach ($address in $guestCallbacks) {
+ Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020
+}
+Write-BigEndian32 $dataOffset 0x12345678
+[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol)
+$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', '
+$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n"
+[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap)
+$manifest = Join-Path $stage 'recomp.yml'
+[IO.File]::WriteAllText($manifest, @"
+schema_version: 1
+workspace_root: .
+project:
+ id: ci-synthetic-dol
+ display_name: CI Synthetic DOL
+memory:
+ base: 0x80000000
+ size: 0x01800000
+ sda_base: 0x80600000
+ sda2_base: 0x80600000
+inputs:
+ dol:
+ path: synthetic.dol
+translation:
+ entry_points: [$entryPoints]
+ function_map:
+ path: synthetic-functions.txt
+ allow_unsupported_instructions: false
+runtime:
+ native_abi_directories: []
+ native_registration_root: runtime/src
+output:
+ root: generated
+"@)
+
+$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj'
+Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator'
+$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll'
+$metadata = Join-Path $stage 'generated/base_translation_output.json'
+Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest,
+ '--output-metadata', $metadata, '--threads', "$Parallel") `
+ 'Translating the synthetic DOL'
+# Function-map seeds can be skipped by discovery; do not accept a partial fixture.
+$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
+foreach ($address in @($entry) + $guestCallbacks) {
+ if ($address -notin $translated.functions.entryPoint) {
+ throw ('Synthetic function 0x{0:X8} was not translated.' -f $address)
+ }
+}
+Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration'
+Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph'
+
+$nativeBuild = Join-Path $stage 'native-build'
+$oldPath = $env:PATH
+try {
+ $env:PATH = Get-MkwToolchainPath $portableTools
+ $configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild `
+ -Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') `
+ -CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') `
+ -ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') `
+ -DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON')
+ Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' `
+ -WaitForProcessTree $false
+ Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
+ 'Compiling and linking the synthetic product with the full runtime' `
+ -WaitForProcessTree $false
+ Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product'
+} finally {
+ $env:PATH = $oldPath
+}
+Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).'
diff --git a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
index b22d680..97e8ecb 100644
--- a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
+++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Common.Cli
WiiCompiled.Setup.Common.Cli
- 0.2.22
+ 0.2.31
patchzy
WiiCompiled
Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1
diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
index 91fbef7..aebdc1b 100644
--- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
+++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
@@ -5,7 +5,7 @@
enable
WiiCompiled.Setup.Common
WiiCompiled.Setup.Common
- 0.2.22
+ 0.2.31
patchzy
WiiCompiled
Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers
diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs
index b9c9671..b8f8ea6 100644
--- a/Launcher/WiiCompiled.Setup.Linux/Models.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.22";
+ public const string Version = "0.2.31";
}
/// One installed product's record inside install-state.json.
diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
index 7424382..84d3269 100644
--- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
+++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Linux
WiiCompiled.Setup.Linux
- 0.2.22
+ 0.2.31
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled on Linux
diff --git a/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs
index fce86f6..fb96d3c 100644
--- a/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs
+++ b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs
@@ -22,6 +22,9 @@ internal sealed class InstallerEngine
var existing = new Installation(installDirectory);
PortableInstallHealing.HealMovedInstall(existing, _reporter);
+ // Capture this before publishing anything. An existing installation may be repaired or
+ // updated by this invocation, but those operations must not recreate the user's shortcuts.
+ var firstInstall = !existing.IsPresent;
var previousState = existing.ReadInstallState();
using var scratch = Directory.Exists(installDirectory)
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
@@ -139,7 +142,7 @@ internal sealed class InstallerEngine
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
remainingCancellation);
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
- releaseEntries, remainingCancellation);
+ releaseEntries, remainingCancellation, createShortcuts: firstInstall);
return;
}
@@ -161,7 +164,8 @@ internal sealed class InstallerEngine
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
previousState, options, canonicalRetroRoot, retroCompileInputs,
- publishGameAssets: reusableGameAssets is null, cancellationToken);
+ publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall,
+ cancellationToken: cancellationToken);
}
@@ -186,7 +190,7 @@ internal sealed class InstallerEngine
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
InstallOptions options, string? canonicalRetroRoot,
RetroRewindCompileInputs? retroCompileInputs,
- bool publishGameAssets, CancellationToken cancellationToken)
+ bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken)
{
var installDirectory = existing.Root;
@@ -214,7 +218,8 @@ internal sealed class InstallerEngine
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
Publish(staging, installDirectory, canonicalRetroRoot, state,
- entries, cancellationToken, progressPercent: 8, completionPercent: 10);
+ entries, cancellationToken, progressPercent: 8, completionPercent: 10,
+ createShortcuts: createShortcuts);
_reporter.Progress(InstallStages.BuildBase,
"Producing the installed products with the published toolkit...", 11);
@@ -277,7 +282,7 @@ internal sealed class InstallerEngine
private void Publish(string staging, string installDirectory,
string? canonicalRetroRoot, InstallState state,
List entries, CancellationToken cancellationToken,
- int progressPercent = 95, int completionPercent = 99)
+ int progressPercent = 95, int completionPercent = 99, bool createShortcuts = false)
{
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
Path.Combine(installDirectory, "licenses")));
@@ -322,7 +327,8 @@ internal sealed class InstallerEngine
{
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
}
- ShellIntegration.CreateShortcuts(installDirectory);
+ if (createShortcuts)
+ ShellIntegration.CreateShortcuts(installDirectory);
}
catch (Exception ex)
{
diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs
index 3caeff9..7995f17 100644
--- a/Launcher/WiiCompiled.Setup.Windows/Program.cs
+++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.26";
+ public const string Version = "0.2.31";
///
/// The setup executable is copied into the installation under this name. It is the launcher and
diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
index 08c9a6d..0efaa73 100644
--- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
+++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
@@ -7,7 +7,7 @@
WiiCompiled.Setup
WiiCompiled.Setup.Windows
app.manifest
- 0.2.26
+ 0.2.31
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled
diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh
index 9602345..69ea00d 100755
--- a/Launcher/prepare-portable-tools.sh
+++ b/Launcher/prepare-portable-tools.sh
@@ -91,17 +91,11 @@ download_verified() {
mv "$tmp" "$dest"
}
-# lib/bundled is part of the check so a toolchain prepared before runtime-library bundling existed
-# is rebuilt rather than reused.
-if [[ -x "$toolchain_dir/bin/clang" && -x "$toolchain_dir/bin/ninja" && -x "$toolchain_dir/bin/cmake" && -d "$toolchain_dir/lib/bundled" ]]; then
+if [[ -x "$toolchain_dir/bin/clang" && -x "$toolchain_dir/bin/ninja" && -x "$toolchain_dir/bin/cmake" ]]; then
echo "prepare-portable-tools.sh: reusing existing toolchain at $toolchain_dir"
exit 0
fi
-for tool in patchelf ldd strip; do
- command -v "$tool" >/dev/null || { echo "prepare-portable-tools.sh: '$tool' is required (apt-get install patchelf binutils libc-bin)" >&2; exit 1; }
-done
-
work="$destination/.building-toolchain-$arch"
rm -rf "$work"
mkdir -p "$work/bin" "$work/lib/$target_triple" "$work/include/$target_triple/c++/v1"
@@ -190,98 +184,6 @@ echo "prepare-portable-tools.sh: staging ninja $ninja_version..."
unzip -oq "$ninja_archive" -d "$work/bin"
chmod +x "$work/bin/ninja"
-# --- runtime libraries the llvm.org binaries expect the host to provide ---
-#
-# The official LLVM release binaries are dynamically linked against a few host libraries beyond
-# glibc: lld needs libxml2, clang/lld need zlib and zstd, and whatever those pull in. A user's
-# distro is not guaranteed to have them under the same soname - libxml2 2.14 renamed
-# libxml2.so.2 to libxml2.so.16, so on current Arch/Fedora a fresh install died with
-# "ld.lld: error while loading shared libraries: libxml2.so.2". The smoke test below never caught
-# it because the build machine has every one of them. So: copy the transitive closure of non-core
-# libraries next to the binaries and point the binaries at that directory through an RPATH, the
-# way AppImages ship any dynamically linked tool. glibc and its companions stay on the host: they
-# exist everywhere, and a bundled copy could only ever be older than the host's own.
-bundled_lib_dir="$work/lib/bundled"
-mkdir -p "$bundled_lib_dir"
-
-is_host_provided_lib() {
- case "$1" in
- linux-vdso*|ld-linux*|libc.so*|libm.so*|libdl.so*|libpthread.so*|librt.so*|libresolv.so*|libutil.so*|\
- libgcc_s.so*|libstdc++.so*) return 0 ;;
- *) return 1 ;;
- esac
-}
-
-# Prints "soname path" for every dynamically resolved dependency of $1 that the host must not be
-# relied on for. ldd already reports the transitive closure, so no recursion is needed.
-non_host_deps() {
- local line soname path
- ldd "$1" 2>/dev/null | while read -r line; do
- [[ "$line" == *"=>"* ]] || continue
- soname=${line%% =>*}; soname=${soname## }
- path=${line#*=> }; path=${path%% (*}
- is_host_provided_lib "$soname" && continue
- echo "$soname $path"
- done
-}
-
-# A dependency the build machine itself lacks cannot be bundled; say so instead of shipping a
-# toolchain that is broken everywhere. (Checked separately because a failure inside the
-# process-substituted reader above could not stop the script.)
-assert_deps_resolved() {
- if ldd "$1" 2>/dev/null | grep -q "not found"; then
- echo "prepare-portable-tools.sh: $(basename "$1") has dependencies missing on this machine:" >&2
- ldd "$1" | grep "not found" >&2
- exit 1
- fi
-}
-
-# Copies every non-core library $1 needs into lib/bundled, then does the same for each copy, so
-# a library's own dependencies (libxml2 -> ICU, ...) are bundled even if ldd's listing for the
-# executable did not already spell out the whole closure. The "already bundled" check ends the
-# recursion.
-bundle_deps_of() {
- local file=$1 soname path
- assert_deps_resolved "$file"
- while read -r soname path; do
- [[ -n "$soname" ]] || continue
- [[ -e "$bundled_lib_dir/$soname" ]] && continue
- cp -L "$path" "$bundled_lib_dir/$soname"
- chmod u+w "$bundled_lib_dir/$soname"
- # A bundled library must find its own dependencies beside itself: RUNPATH is not
- # inherited from the executable that loaded it.
- patchelf --set-rpath '$ORIGIN' "$bundled_lib_dir/$soname"
- bundle_deps_of "$bundled_lib_dir/$soname"
- done < <(non_host_deps "$file")
-}
-
-bundle_runtime_libs() {
- local file=$1 existing
- bundle_deps_of "$file"
- # Prepend, keeping whatever RPATH the release build set (typically $ORIGIN/../lib).
- existing=$(patchelf --print-rpath "$file" 2>/dev/null || true)
- patchelf --set-rpath "\$ORIGIN/../lib/bundled${existing:+:$existing}" "$file"
-}
-
-echo "prepare-portable-tools.sh: bundling the host runtime libraries the toolchain binaries depend on..."
-for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake"; do
- bundle_runtime_libs "$binary"
-done
-echo "prepare-portable-tools.sh: bundled $(ls "$bundled_lib_dir" | wc -l) librar(y/ies): $(ls "$bundled_lib_dir" | tr '\n' ' ')"
-
-# Fail here, on the build machine, if any non-core dependency still resolves to a host path:
-# that is exactly the file a user's machine may lack.
-for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake" "$bundled_lib_dir"/*; do
- [[ -f "$binary" ]] || continue
- while read -r soname path; do
- [[ -n "$soname" ]] || continue
- if [[ "$path" != "$bundled_lib_dir/"* ]]; then
- echo "prepare-portable-tools.sh: $(basename "$binary") still resolves $soname from the host ($path); bundling failed" >&2
- exit 1
- fi
- done < <(non_host_deps "$binary")
-done
-
cat > "$work/LICENSE.txt" < "$smoke_dir/t.cpp" <<'EOF'
+echo "prepare-portable-tools.sh: testing the toolchain..."
+test_dir=$(mktemp -d)
+trap 'rm -rf "$test_dir"' EXIT
+cat > "$test_dir/t.cpp" <<'EOF'
#include
#include
int main() {
@@ -317,23 +214,23 @@ int main() {
return sum == 6 ? 0 : 1;
}
EOF
-"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t"
-"$smoke_dir/t"
+"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t"
+"$test_dir/t"
# Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain
# clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that
# can't find the compiler.
-cat > "$smoke_dir/CMakeLists.txt" <<'EOF'
+cat > "$test_dir/CMakeLists.txt" <<'EOF'
cmake_minimum_required(VERSION 3.16)
-project(smoke CXX)
-add_executable(smoke t.cpp)
+project(test CXX)
+add_executable(test t.cpp)
EOF
-"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \
+"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \
-DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null
-"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null
-"$smoke_dir/build/smoke"
+"$work/bin/cmake" --build "$test_dir/build" >/dev/null
+"$test_dir/build/test"
-rm -rf "$smoke_dir"
+rm -rf "$test_dir"
trap - EXIT
mv "$work" "$toolchain_dir"
diff --git a/README.md b/README.md
index 19c0caf..0d871a5 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,9 @@ Press **F10** while the game window has focus:
- Internal resolution
- FPS counter
- Controller assignment for all four ports
-- Full per-controller button mapping
+- Full per-controller button mapping, including the bumpers
+- Dolphin-syntax input expressions and GCPadNew.ini import
+- Controller vibration on/off
- Volume, instant mute, and the music ducking toggle
Everything you change is saved to `Config.toml` on the spot and restored next launch.
@@ -63,25 +65,20 @@ Controllers are fed to the game as a GameCube controller.
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra
inputs like paddles, touchpads and share buttons show up when the hardware reports them.
+
+**Dolphin-compatible input expressions.**
+Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
+and the same functions.
+A Dolphin `GCPadNew.ini` can be imported directly from the F10 bar.
+
+**Vibration toggle.**
+Force feedback can be turned off for every port at once.
The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the
adapter must be switched to the WinUSB driver once (Zadig).
**Real Wii Remotes over Bluetooth.**
Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the
-PIN empty) and the game reads it as an actual Wii Remote through KPAD: Wii Remote icons and
-prompts, Wii Wheel tilt steering, wheelies and tricks all come from the game's own motion code.
-Nunchuk and Classic Controller are real Wii extensions too: the game gets the Nunchuk's stick,
-C/Z and accelerometer, and the Classic Controller through `KPADGetUnifiedWpadStatus` with its own
-layout and icons, so its buttons do what the game says they do and no mapping is involved. Plug an
-extension in or pull it out mid-game and the game switches control scheme like on the console
-(the runtime patches SDL's Wii driver, which otherwise loses the remote for good on an extension
-change). Only the Wii U Pro Controller, which has no Wii-era equivalent, is fed to the game as a
-GameCube pad with Nintendo's layout. If a remote drops out or was switched on after launch, the
-runtime keeps rescanning Bluetooth until it comes back (F10 > Controller settings > Wii Remotes). SDL's read of
-the remote's factory accelerometer calibration often times out over Bluetooth (`console.log`
-then says "Using fallback accelerometer calibration") and it falls back to a nominal zero point,
-so the same menu has a one-button calibration (remote flat, buttons up) that removes the small
-tilt offset some remotes show.
+PIN empty)
Known limitations of the Wii Remote path:
- No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as
diff --git a/aurora-main/lib/input.cpp b/aurora-main/lib/input.cpp
index 8884f1a..6c9418b 100644
--- a/aurora-main/lib/input.cpp
+++ b/aurora-main/lib/input.cpp
@@ -401,6 +401,8 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept {
return -1;
}
controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337;
+ const char* serial = SDL_GetGamepadSerial(ctrl);
+ controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial;
if (controller.m_isGameCube ||
(SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) {
controller.m_deadZones.emulateTriggers = false;
@@ -481,6 +483,13 @@ bool controller_has_rumble(Uint32 instance) noexcept {
void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity,
uint16_t duration_ms) noexcept {
if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) {
+ // GC Pocket+ has been observed continuing to vibrate after a hard stop;
+ // an ordinary stop cleared it. With GAMECUBE_RUMBLE_BRAKE enabled, SDL
+ // encodes (0, 1) as adapter command 0, whereas (0, 0) sends command 2.
+ // Apply the workaround here so shutdown uses the same stop as PAD calls.
+ if (it->second.m_gameCubeUseOrdinaryStop && low_freq_intensity == 0 && high_freq_intensity == 0) {
+ high_freq_intensity = 1;
+ }
SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms);
}
}
diff --git a/aurora-main/lib/input.hpp b/aurora-main/lib/input.hpp
index 1c6f24e..ae083b1 100644
--- a/aurora-main/lib/input.hpp
+++ b/aurora-main/lib/input.hpp
@@ -17,6 +17,7 @@ extern Module Log;
struct GameController {
SDL_Gamepad* m_controller = nullptr;
bool m_isGameCube = false;
+ bool m_gameCubeUseOrdinaryStop = false;
Sint32 m_index = -1;
Sint32 m_playerIndex = -1;
bool m_hasRumble = false;
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 87da111..5894a05 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -342,6 +342,32 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI
target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
+add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
+target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
+add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
+
+add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
+find_package(Threads REQUIRED)
+target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
+target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17)
+add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests)
+
+add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp")
+target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17)
+add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests)
+
+# The input expression engine is self-contained, so it can be exercised without
+# linking the runtime or SDL.
+add_executable(mkw_input_expr_tests
+ "${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
+ "${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
+target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
+add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
+
# HostContext deliberately keeps the platform-specific context primitive out
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
# refactors cannot silently remove its headers, implementation, or link edge.
diff --git a/runtime/include/console_identity.h b/runtime/include/console_identity.h
index ef46679..c07fc30 100644
--- a/runtime/include/console_identity.h
+++ b/runtime/include/console_identity.h
@@ -1,38 +1,28 @@
#pragma once
-#include "runtime_config.h"
+#include "nand_path.h"
+#include "nand_settings.h"
-#include
#include
-#include
#include
#include
-#include
-#include
#include
-#include
-#include
#include
#include
-#include
#include
namespace RuntimeConsoleIdentity {
struct Identity {
std::string serial;
+ std::string productCode;
+ std::string area;
+ std::string gameRegion;
std::array mac;
};
-inline bool IsValidSerial(const std::string& serial) {
- return serial.size() == 9 &&
- serial != "000000000" &&
- std::all_of(serial.begin(), serial.end(),
- [](unsigned char value) { return std::isdigit(value) != 0; });
-}
-
inline Identity FromSerial(std::string serial) {
- // Keep Nintendo's Wii OUI. The suffix is derived from the persisted serial
+ // Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial
// so every API exposes one coherent, stable virtual-console identity.
uint32_t hash = 2166136261u;
for (const unsigned char value : serial) {
@@ -46,6 +36,7 @@ inline Identity FromSerial(std::string serial) {
return {
std::move(serial),
+ {}, {}, {},
{
0x00,
0x09,
@@ -57,83 +48,23 @@ inline Identity FromSerial(std::string serial) {
};
}
-inline std::optional ReadSerial(const std::filesystem::path& path) {
- std::ifstream input(path);
- std::string line;
- if (!input || !std::getline(input, line)) {
- return std::nullopt;
+inline Identity LoadFromNand() {
+ const auto root = RuntimeNandPath::DiscoverNandRootPath();
+ const auto settings = RuntimeNandSettings::Read(root);
+ if (!settings || !RuntimeNandSettings::HasIdentity(*settings)) {
+ RuntimeNandPath::FailNandRoot(
+ "NAND setting.txt is missing or has invalid console identity fields (SERNO, CODE, AREA, GAME)",
+ root / "title/00000001/00000002/data/setting.txt");
}
- constexpr std::string_view prefix = "serial=";
- if (line.rfind(prefix, 0) != 0) {
- return std::nullopt;
- }
- std::string serial = line.substr(prefix.size());
- if (!IsValidSerial(serial)) {
- return std::nullopt;
- }
- return serial;
-}
-
-inline bool WriteSerial(const std::filesystem::path& path, const std::string& serial) {
- std::error_code ec;
- std::filesystem::create_directories(path.parent_path(), ec);
- if (ec) {
- return false;
- }
-
- std::filesystem::path temporary = path;
- temporary += ".tmp";
- {
- std::ofstream output(temporary, std::ios::trunc);
- if (!output) {
- return false;
- }
- output << "serial=" << serial << '\n';
- output.close();
- if (!output) {
- return false;
- }
- }
-
- std::filesystem::rename(temporary, path, ec);
- if (!ec) {
- return true;
- }
- std::filesystem::remove(temporary, ec);
- return false;
-}
-
-inline std::string GenerateSerial() {
- std::random_device entropy;
- std::seed_seq seed{
- entropy(),
- entropy(),
- entropy(),
- entropy(),
- };
- std::mt19937 generator(seed);
- std::uniform_int_distribution distribution(100000000u, 999999999u);
- return std::to_string(distribution(generator));
-}
-
-inline Identity LoadOrCreate(const std::filesystem::path& path) {
- if (const auto serial = ReadSerial(path)) {
- return FromSerial(*serial);
- }
-
- const std::string generated = GenerateSerial();
- if (WriteSerial(path, generated)) {
- return FromSerial(generated);
- }
-
- // Remain operational in a read-only environment. This fallback matches
- // Dolphin's deterministic serial while keeping the same valid identity shape.
- return FromSerial("123456789");
+ Identity identity = FromSerial(settings->at("SERNO"));
+ identity.productCode = settings->at("CODE");
+ identity.area = settings->at("AREA");
+ identity.gameRegion = settings->at("GAME");
+ return identity;
}
inline const Identity& Current() {
- static const Identity identity =
- LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt");
+ static const Identity identity = LoadFromNand();
return identity;
}
diff --git a/runtime/include/controller_button_names.h b/runtime/include/controller_button_names.h
new file mode 100644
index 0000000..d0d2b81
--- /dev/null
+++ b/runtime/include/controller_button_names.h
@@ -0,0 +1,159 @@
+#pragma once
+
+// The single vocabulary shared by everything that has to turn a Config.toml
+// controller name into a real button: the F10 settings bar, the macro engine,
+// and the startup mapping pass. Keeping one table here means a name that the
+// settings bar offers is always a name the config parser accepts, and vice
+// versa; the two used to drift because each side carried its own copy.
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace ControllerNames {
+
+// A GameCube button as the game sees it, with the Config.toml key that selects
+// it. Order matches RuntimeConfigFile::kControllerButtonKeys.
+struct GameCubeButtonItem {
+ const char* configKey;
+ const char* label;
+ PADButton padButton;
+};
+
+inline constexpr std::array kGameCubeButtons = {{
+ {"a", "A", PAD_BUTTON_A},
+ {"b", "B", PAD_BUTTON_B},
+ {"x", "X", PAD_BUTTON_X},
+ {"y", "Y", PAD_BUTTON_Y},
+ {"start", "Start", PAD_BUTTON_START},
+ {"z", "Z", PAD_TRIGGER_Z},
+ {"l", "L", PAD_TRIGGER_L},
+ {"r", "R", PAD_TRIGGER_R},
+ {"up", "D-pad Up", PAD_BUTTON_UP},
+ {"down", "D-pad Down", PAD_BUTTON_DOWN},
+ {"left", "D-pad Left", PAD_BUTTON_LEFT},
+ {"right", "D-pad Right", PAD_BUTTON_RIGHT},
+}};
+
+// A physical button on the host pad. Names are positional (south/east/...)
+// rather than Xbox-labelled so one config reads the same on any hardware.
+struct NativeButtonItem {
+ const char* configName;
+ const char* label;
+ uint32_t nativeButton;
+};
+
+inline constexpr std::array kNativeButtons = {{
+ {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
+ {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
+ {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
+ {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
+ {"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH},
+ {"back", "Back / Select / Create", SDL_GAMEPAD_BUTTON_BACK},
+ {"guide", "Guide / Home / PS", SDL_GAMEPAD_BUTTON_GUIDE},
+ {"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
+ {"left_stick", "Left stick click (L3)", SDL_GAMEPAD_BUTTON_LEFT_STICK},
+ {"right_stick", "Right stick click (R3)", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
+ {"left_shoulder", "Left bumper (LB / L1)", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
+ {"right_shoulder", "Right bumper (RB / R1)", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
+ {"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP},
+ {"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
+ {"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
+ {"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
+ {"misc1", "Misc 1 / Share / Mic", SDL_GAMEPAD_BUTTON_MISC1},
+ {"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
+ {"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
+ {"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
+ {"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
+ {"touchpad", "Touchpad click", SDL_GAMEPAD_BUTTON_TOUCHPAD},
+ {"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2},
+ {"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3},
+ {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
+ {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
+ {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
+}};
+
+inline std::string TrimToken(std::string_view token) {
+ const size_t begin = token.find_first_not_of(" \t");
+ if (begin == std::string_view::npos) {
+ return {};
+ }
+ const size_t end = token.find_last_not_of(" \t");
+ return std::string(token.substr(begin, end - begin + 1));
+}
+
+inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
+ const std::string name = TrimToken(configName);
+ const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
+ [&](const NativeButtonItem& item) { return name == item.configName; });
+ return it == kNativeButtons.end() ? nullptr : &*it;
+}
+
+// Falls back to the "unmapped" entry so callers always have a label to draw.
+inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
+ const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
+ [&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; });
+ return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
+}
+
+inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
+ const std::string key = TrimToken(configKey);
+ const auto it = std::find_if(kGameCubeButtons.begin(), kGameCubeButtons.end(),
+ [&](const GameCubeButtonItem& item) { return key == item.configKey; });
+ return it == kGameCubeButtons.end() ? nullptr : &*it;
+}
+
+// "up" or "up,a" -> the OR of those GC button bits. Unknown names are skipped so
+// a typo costs one button instead of the whole macro.
+inline uint16_t GameCubeMaskFromKeys(std::string_view keys) {
+ uint16_t mask = 0;
+ size_t begin = 0;
+ while (begin <= keys.size()) {
+ const size_t comma = keys.find(',', begin);
+ const std::string_view token =
+ keys.substr(begin, comma == std::string_view::npos ? std::string_view::npos : comma - begin);
+ if (const GameCubeButtonItem* item = FindGameCubeButton(token)) {
+ mask |= static_cast(item->padButton);
+ }
+ if (comma == std::string_view::npos) {
+ break;
+ }
+ begin = comma + 1;
+ }
+ return mask;
+}
+
+inline std::string GameCubeKeysFromMask(uint16_t mask) {
+ std::string keys;
+ for (const auto& item : kGameCubeButtons) {
+ if ((mask & static_cast(item.padButton)) == 0) {
+ continue;
+ }
+ if (!keys.empty()) {
+ keys += ',';
+ }
+ keys += item.configKey;
+ }
+ return keys;
+}
+
+inline std::string GameCubeLabelsFromMask(uint16_t mask) {
+ std::string labels;
+ for (const auto& item : kGameCubeButtons) {
+ if ((mask & static_cast(item.padButton)) == 0) {
+ continue;
+ }
+ if (!labels.empty()) {
+ labels += " + ";
+ }
+ labels += item.label;
+ }
+ return labels.empty() ? std::string("None") : labels;
+}
+
+} // namespace ControllerNames
diff --git a/runtime/include/input_bindings.h b/runtime/include/input_bindings.h
new file mode 100644
index 0000000..37d664b3
--- /dev/null
+++ b/runtime/include/input_bindings.h
@@ -0,0 +1,67 @@
+#pragma once
+
+// Per-port expression bindings for the GameCube controls, plus import of a
+// Dolphin GCPadNew.ini.
+
+#include
+#include
+#include
+
+#include
+
+namespace InputBindings {
+
+// The controls an expression can drive, in Dolphin's own naming so an
+// imported config maps across without translation.
+struct ControlInfo {
+ const char* dolphinName;
+ const char* label;
+ uint16_t padButton; // 0 for the analog-only controls below
+ int analog; // 0 none, 1 trigger L, 2 trigger R
+};
+
+inline constexpr std::array kControls = {{
+ {"Buttons/A", "A", PAD_BUTTON_A, 0},
+ {"Buttons/B", "B", PAD_BUTTON_B, 0},
+ {"Buttons/X", "X", PAD_BUTTON_X, 0},
+ {"Buttons/Y", "Y", PAD_BUTTON_Y, 0},
+ {"Buttons/Z", "Z", PAD_TRIGGER_Z, 0},
+ {"Buttons/Start", "Start", PAD_BUTTON_START, 0},
+ {"D-Pad/Up", "D-pad Up", PAD_BUTTON_UP, 0},
+ {"D-Pad/Down", "D-pad Down", PAD_BUTTON_DOWN, 0},
+ {"D-Pad/Left", "D-pad Left", PAD_BUTTON_LEFT, 0},
+ {"D-Pad/Right", "D-pad Right", PAD_BUTTON_RIGHT, 0},
+ {"Triggers/L", "L", PAD_TRIGGER_L, 1},
+ {"Triggers/R", "R", PAD_TRIGGER_R, 2},
+ {"Triggers/L-Analog", "L analog", 0, 1},
+ {"Triggers/R-Analog", "R analog", 0, 2},
+}};
+
+void Reload() noexcept;
+
+// The pad library has PADBlockInput but no matching query, so the settings
+// overlay reports its own state here.
+void SetInputBlocked(bool blocked) noexcept;
+bool InputBlocked() noexcept;
+
+// Mix expression output into a freshly read status set. Call once per guest
+// PADRead, after every other input source has been merged.
+void Apply(PADStatus* statuses) noexcept;
+
+std::string GetExpression(uint32_t port, size_t control) noexcept;
+// Returns false and fills error if the text does not parse; the binding is
+// left unchanged in that case.
+bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept;
+
+// True while the control's expression is above the press threshold.
+bool IsActive(uint32_t port, size_t control) noexcept;
+
+// The default Dolphin config location on Windows, then next to the executable.
+std::string DefaultDolphinConfigPath() noexcept;
+
+// Imports [GCPad] into the given port. Returns the number of controls
+// imported, or -1 on failure with error filled.
+int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port,
+ std::string& summary, std::string& error) noexcept;
+
+} // namespace InputBindings
diff --git a/runtime/include/input_expr.h b/runtime/include/input_expr.h
new file mode 100644
index 0000000..8fbb149
--- /dev/null
+++ b/runtime/include/input_expr.h
@@ -0,0 +1,53 @@
+#pragma once
+
+// Dolphin-compatible input expressions.
+//
+// Values are doubles in Dolphin's ControlState style; a control counts as
+// pressed above kConditionThreshold. Timing matches Dolphin: wall-clock
+// seconds on a steady clock, so an expression copied from GCPadNew.ini
+// behaves the same here as it does there.
+
+#include
+#include
+#include
+#include
+#include
+
+namespace InputExpr {
+
+inline constexpr double kConditionThreshold = 0.5;
+
+// Resolves a backtick-quoted input name to its current value.
+using InputSource = std::function;
+
+struct Node;
+
+class Expression {
+public:
+ Expression();
+ ~Expression();
+ Expression(Expression&&) noexcept;
+ Expression& operator=(Expression&&) noexcept;
+
+ // Returns false and fills error on a syntax problem.
+ static bool Parse(const std::string& text, Expression& out, std::string& error);
+
+ bool Empty() const { return m_root == nullptr; }
+ double Evaluate(const InputSource& source) const;
+
+ // Input names the expression references, for diagnostics.
+ std::vector ReferencedInputs() const;
+
+private:
+ std::unique_ptr m_root;
+};
+
+// Parses a Dolphin GCPadNew.ini and returns the expression text for each
+// control of the requested pad, keyed by Dolphin's own control names
+// ("Buttons/A", "D-Pad/Up", "Triggers/L", ...). Returns false if the file
+// cannot be read or the section is missing.
+bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
+ std::vector>& controls,
+ std::string& deviceName, std::string& error);
+
+} // namespace InputExpr
diff --git a/runtime/include/nand_path.h b/runtime/include/nand_path.h
index 709e4af..eb0fda8 100644
--- a/runtime/include/nand_path.h
+++ b/runtime/include/nand_path.h
@@ -1,6 +1,7 @@
#pragma once
#include "runtime_config.h"
+#include "nand_settings.h"
#include "runtime_log.h"
#include "system_bridge.h"
@@ -163,7 +164,7 @@ inline std::filesystem::path CreateManagedNandRoot() {
return root;
}
-inline std::filesystem::path DiscoverNandRootPath() {
+inline std::filesystem::path ResolveNandRootPath() {
const std::string configPath = RuntimeConfigFile::NandRoot();
if (!configPath.empty()) {
const auto path = ResolveConfiguredPath(configPath);
@@ -179,4 +180,16 @@ inline std::filesystem::path DiscoverNandRootPath() {
return CreateManagedNandRoot();
}
+inline std::filesystem::path DiscoverNandRootPath() {
+ static const auto root = [] {
+ const auto resolved = ResolveNandRootPath();
+ std::string error;
+ if (!RuntimeNandSettings::Ensure(resolved, error)) {
+ FailNandRoot(error.c_str(), RuntimeNandSettings::FilePath(resolved));
+ }
+ return resolved;
+ }();
+ return root;
+}
+
} // namespace RuntimeNandPath
diff --git a/runtime/include/nand_save_probe.h b/runtime/include/nand_save_probe.h
new file mode 100644
index 0000000..b00b7d8
--- /dev/null
+++ b/runtime/include/nand_save_probe.h
@@ -0,0 +1,59 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace RuntimeNandSave {
+
+enum class Contents { Missing, Blank, Nonzero, Error };
+enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded };
+
+// A failed read is not evidence that a save is blank. Check badbit before EOF:
+// an I/O failure may set both, whereas a successful short final read sets EOF.
+inline Contents InspectStream(std::istream& input) {
+ if (!input) return Contents::Error;
+ char block[4096];
+ for (;;) {
+ input.read(block, sizeof(block));
+ if (input.bad() || (input.fail() && !input.eof())) return Contents::Error;
+ for (std::streamsize i = 0; i < input.gcount(); ++i) {
+ if (block[i] != 0) return Contents::Nonzero;
+ }
+ if (input.eof()) return Contents::Blank;
+ }
+}
+
+inline Contents InspectFile(const std::filesystem::path& path) {
+ std::error_code ec;
+ const auto status = std::filesystem::symlink_status(path, ec);
+ if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error;
+ if (!std::filesystem::exists(status)) return Contents::Missing;
+ if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error;
+ std::ifstream input(path, std::ios::binary);
+ return InspectStream(input);
+}
+
+// Probe only read-only opens of the actual save and its exact write shadow.
+// No probe writes, removes, or repairs data, and backups are not save aliases.
+inline ReadAction CheckRead(const std::filesystem::path& path, int mode) {
+ const auto name = path.filename();
+ const bool isMain = name == "rksys.dat";
+ if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed;
+ const auto contents = InspectFile(path);
+ if (contents == Contents::Error) return ReadAction::Error;
+ if (contents == Contents::Nonzero) return ReadAction::Proceed;
+ if (isMain) {
+ auto shadow = path;
+ shadow += ".nandsafe.tmp";
+ const auto shadowContents = InspectFile(shadow);
+ if (shadowContents == Contents::Error) return ReadAction::Error;
+ // The next write normally discards an old shadow. Preserve a possible
+ // recovery source when there is no usable original, without promoting
+ // an uncommitted (and potentially incomplete) shadow to the real save.
+ if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded;
+ }
+ return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed;
+}
+
+} // namespace RuntimeNandSave
diff --git a/runtime/include/nand_settings.h b/runtime/include/nand_settings.h
new file mode 100644
index 0000000..055f4c2
--- /dev/null
+++ b/runtime/include/nand_settings.h
@@ -0,0 +1,220 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include