25 Commits

Author SHA1 Message Date
patchzyy 3d2c1fd8b8 extra safety 2026-09-06 10:49:01 +02:00
patchzyy 0e647b3dbb Merge commit '28cbea46ab44105fde928653507add5d9d4873f4' into fix/pr168-save-safety 2026-09-06 10:13:19 +02:00
patchzyy 28cbea46ab import setting.txt 2026-09-06 10:04:07 +02:00
JGM01 55ea5dfbda I dont really want to change this to be honest. 2026-09-05 22:55:59 -04:00
JGM01 4a7f592b8a shorten 2026-09-05 22:51:58 -04:00
JGM01 c95f1c95b7 treat empty mkw save as missing
first-run format zero-fills rksys.dat before any real save; a quit before
the first save left an all-zero file that read back as corrupt and trapped
the user in a delete/recreate loop. read opens now treat an all-zero
rksys.dat as absent (a real save always begins with the RKSD0006 header),
so the game recreates it from scratch. also ignore native build output.
2026-09-05 22:48:05 -04:00
patchzyy 8e57cc162f version 2026-09-06 00:48:41 +02:00
patchzyy 1c0a3edee9 Add synthetic recompilation CI workflow (#161) 2026-09-05 23:00:47 +02:00
patchzyy d1d80613cc Load console identity from NAND setting.txt (#164) 2026-09-05 23:00:23 +02:00
patchzyy 5d67b229f6 Update settings_overlay.cpp 2026-09-05 16:54:24 +02:00
patchzyy 56db6ba641 v0.2.28 2026-09-05 16:09:34 +02:00
Michael G a67069afd3 fix(macos): increase guest fiber stack size (#154) 2026-09-05 15:45:29 +02:00
patchzyy 009697fb97 Update network_socket.cpp 2026-09-05 14:31:53 +02:00
Cristian Boehm 3f7fed48c9 Fix GC Pocket+ rumble stop handling (#148) 2026-09-05 10:20:15 +02:00
theofficialgman 6eba523d70 Switch back to LLVM 22 so build can succeed on at least some systems (#141)
two upstream LLVM bugs currently prevent building on some of the newest distros. There is no current LLVM release that works on them so we are pending fixes from LLVM
https://github.com/patchzyy/Wiicompiled/issues/136
2026-09-05 10:17:21 +02:00
patchzyy 8c6c177857 Simplify README by removing redundant input details 2026-09-05 10:11:48 +02:00
Nicholas Bly be153e0fa0 Add Dolphin-compatible input expressions and GCPadNew.ini import, DualSense L/R remapping, vibration toggle (#89)
* Add Dolphin-compatible input expressions and GCPadNew.ini import

Rebased onto current main; addresses both CodeRabbit reviews on #89.

- Expression engine matching Dolphin's semantics: doubles rather than
  booleans, 0.5 press threshold, & as min, | as max, and the functions if,
  min, max, clamp, abs, sqrt, pow, sin, cos, tan, deadzone, timer, toggle,
  hold, tap, pulse and smooth. Timing uses a steady clock in seconds, as
  Dolphin does, so a copied expression behaves identically.

- Expressions bind to the GameCube buttons and triggers, combined with the
  existing button mapping rather than replacing it, and are skipped while
  the settings overlay holds input.

- Import reads [GCPadN] from the Dolphin config directory or from
  GCPadNew.ini beside the executable. Stick axes are not expression driven
  and keep their normal mapping.

- Fixes #74: a digital button bound to L or R now reports a fully pulled
  analog trigger, plus a PlayStation preset and a vibration toggle.

Review fixes: config paths round-trip through RuntimeConfigFile::PathToUtf8
and PathFromUtf8 so non-ASCII paths open correctly on Windows, and the
duplicated exists branch is gone; the tap count is clamped before the
unsigned conversion; the expression editor uses resizable storage via
ImGuiInputTextFlags_CallbackResize so a long expression cannot be saved
truncated; clamp bounds are ordered before std::clamp; <cstdlib> is included
for std::strtod; non-finite values are rejected at the evaluator boundary as
well as at the deadzone and timer divisions; and InputBindings::Reload() runs
from InitializeRuntimeSettings rather than the vibration handler.

runtime/tests/test_expr.cpp covers operator precedence, each stateful
function and every case raised in review.

Third review round: smooth() guards NaN as well as infinity so a zero rate
cannot latch a non-finite value in node state; division evaluates both operands
so stateful functions in the left subtree still update when the divisor is zero;
the expression editor clears stale errors when the port changes; and
runtime/tests/test_expr.cpp is registered with CTest as mkw_input_expr_tests,
following the existing test targets.

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-05 10:07:05 +02:00
patchzyy e34f055b3a Shortcut (#155) 2026-09-05 09:55:09 +02:00
patchzyy 602348f905 Update Models.cs 2026-09-04 22:00:21 +02:00
patchzyy e6f9b2197e Fix LLVM path 2026-09-04 21:17:11 +02:00
patchzyy 65047bc7b7 Bump LLVM 2026-09-04 21:12:18 +02:00
patchzyy 989d5e00da Updat eversion 2026-09-04 21:02:02 +02:00
patchzyy efc44b0482 Default Wii remote continuous scanning to off (#137) 2026-09-04 19:35:39 +02:00
patchzyy d3d0de62a6 Throttle SDL logs and gate Wii rescans (#129)
* Throttle SDL logs and gate Wii rescans

* Clarify Wii remote scan state in overlay
2026-09-03 23:29:49 +02:00
patchzyy c6ef17378e Bundle LLVM runtime libs in portable tools 2026-09-03 11:32:15 +02:00
43 changed files with 2644 additions and 242 deletions
+4
View File
@@ -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
+5 -1
View File
@@ -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:
@@ -93,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
+41
View File
@@ -0,0 +1,41 @@
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') }}
- 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
- name: Translate, compile the full runtime, and link
shell: pwsh
run: ./Launcher/Test-Recompilation.ps1 -Parallel 3
+2
View File
@@ -26,6 +26,8 @@ Code.pul
/build/
/build-*/
/native-build/
/native-build-macos/
/local-products/
/dist/
/out/
[Bb]in/
+1 -1
View File
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
ProductVersion = '0.2.26'
ProductVersion = '0.2.29'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
+146
View File
@@ -0,0 +1,146 @@
# 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'
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
'Compiling and linking the synthetic product with the full runtime'
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).'
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
<Version>0.2.22</Version>
<Version>0.2.29</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1</Description>
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
<Version>0.2.22</Version>
<Version>0.2.29</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
+1 -1
View File
@@ -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.29";
}
/// <summary>One installed product's record inside install-state.json.</summary>
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
<Version>0.2.22</Version>
<Version>0.2.29</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
@@ -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<InstallTransactionEntry> 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)
{
@@ -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.29";
/// <summary>
/// The setup executable is copied into the installation under this name. It is the launcher and
@@ -7,7 +7,7 @@
<AssemblyName>WiiCompiled.Setup</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.2.26</Version>
<Version>0.2.29</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
+13 -13
View File
@@ -201,10 +201,10 @@ Ninja $ninja_version
Apache License 2.0
EOF
echo "prepare-portable-tools.sh: smoke-testing the toolchain..."
smoke_dir=$(mktemp -d)
trap 'rm -rf "$smoke_dir"' EXIT
cat > "$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 <vector>
#include <cstdio>
int main() {
@@ -214,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"
+12 -15
View File
@@ -46,7 +46,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.
@@ -56,25 +58,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
+9
View File
@@ -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);
}
}
+1
View File
@@ -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;
+21
View File
@@ -277,6 +277,27 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_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_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)
# 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.
+20 -89
View File
@@ -1,38 +1,28 @@
#pragma once
#include "runtime_config.h"
#include "nand_path.h"
#include "nand_settings.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <optional>
#include <random>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
namespace RuntimeConsoleIdentity {
struct Identity {
std::string serial;
std::string productCode;
std::string area;
std::string gameRegion;
std::array<uint8_t, 6> 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<std::string> 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<uint32_t> 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;
}
+159
View File
@@ -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 <algorithm>
#include <array>
#include <cstdint>
#include <string>
#include <string_view>
#include <SDL3/SDL_gamepad.h>
#include <dolphin/pad.h>
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<GameCubeButtonItem, PAD_BUTTON_COUNT> 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<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> 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<uint16_t>(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<uint16_t>(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<uint16_t>(item.padButton)) == 0) {
continue;
}
if (!labels.empty()) {
labels += " + ";
}
labels += item.label;
}
return labels.empty() ? std::string("None") : labels;
}
} // namespace ControllerNames
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// Per-port expression bindings for the GameCube controls, plus import of a
// Dolphin GCPadNew.ini.
#include <array>
#include <cstdint>
#include <string>
#include <dolphin/pad.h>
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<ControlInfo, 14> 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<padIndex>] 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
+53
View File
@@ -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 <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace InputExpr {
inline constexpr double kConditionThreshold = 0.5;
// Resolves a backtick-quoted input name to its current value.
using InputSource = std::function<double(const std::string&)>;
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<std::string> ReferencedInputs() const;
private:
std::unique_ptr<Node> 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<std::pair<std::string, std::string>>& controls,
std::string& deviceName, std::string& error);
} // namespace InputExpr
+14 -1
View File
@@ -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
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <istream>
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
+199
View File
@@ -0,0 +1,199 @@
#pragma once
#include <array>
#include <atomic>
#include <chrono>
#include <ctime>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <string>
#include <utility>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#endif
namespace RuntimeNandSettings {
using Settings = std::map<std::string, std::string>;
inline std::filesystem::path FilePath(const std::filesystem::path& root) {
return root / "title/00000001/00000002/data/setting.txt";
}
// Wii setting.txt is a 256-byte buffer encrypted with a rotating XOR key.
inline std::optional<Settings> Read(const std::filesystem::path& nandRoot) {
std::ifstream input(FilePath(nandRoot), std::ios::binary);
std::array<uint8_t, 256> bytes{};
if (!input.read(reinterpret_cast<char*>(bytes.data()), bytes.size())) {
return std::nullopt;
}
uint32_t key = 0x73B5DBFAu;
std::string decoded;
for (const uint8_t byte : bytes) {
const char value = static_cast<char>(byte ^ static_cast<uint8_t>(key));
key = (key << 1) | (key >> 31);
if (value == '\0') {
break;
}
if (value != '\r') {
decoded += value;
}
}
Settings settings;
for (size_t start = 0; start < decoded.size();) {
const size_t end = decoded.find('\n', start);
const std::string line = decoded.substr(start, end - start);
const size_t equals = line.find('=');
if (equals != std::string::npos && equals != 0) {
settings.emplace(line.substr(0, equals), line.substr(equals + 1));
}
if (end == std::string::npos) {
break;
}
start = end + 1;
}
return settings;
}
inline bool HasIdentity(const Settings& settings) {
const auto serial = settings.find("SERNO");
if (serial == settings.end() || serial->second.empty() || serial->second.size() > 9 ||
serial->second.find_first_not_of("0123456789") != std::string::npos ||
serial->second.find_first_not_of('0') == std::string::npos) {
return false;
}
for (const auto& field : {std::pair{"CODE", 5u}, {"AREA", 3u}, {"GAME", 2u}}) {
const auto value = settings.find(field.first);
if (value == settings.end() || value->second.empty() ||
value->second.size() > field.second) {
return false;
}
}
return true;
}
// Dolphin's normal (non-deterministic) first-boot algorithm. It is independent
// of the ES device ID. Matching another NAND requires that NAND's saved serial.
inline std::string GenerateSerial(std::time_t now) {
if (now < 0) {
return {};
}
const auto digits = std::to_string(now % 1000000000);
return std::string(9 - digits.size(), '0') + digits;
}
// This recompilation targets the European disc. These are Dolphin's PAL boot
// defaults; an existing setting.txt always takes precedence, in every region.
inline std::optional<std::array<uint8_t, 256>> EncodeNew(const std::string& serial) {
const Settings identity{{"SERNO", serial}, {"CODE", "LEH"}, {"AREA", "EUR"}, {"GAME", "EU"}};
if (!HasIdentity(identity)) {
return std::nullopt;
}
std::array<uint8_t, 256> bytes{};
size_t position = 0;
uint32_t key = 0x73B5DBFAu;
const auto writeByte = [&](char value) {
bytes[position++] = static_cast<uint8_t>(value) ^ static_cast<uint8_t>(key);
key = (key << 1) | (key >> 31);
};
for (const std::string& line : {std::string("AREA=EUR\r\n"), std::string("MODEL=RVL-001(EUR)\r\n"),
std::string("DVD=0\r\n"), std::string("MPCH=0x7FFE\r\n"), std::string("CODE=LEH\r\n"),
"SERNO=" + serial + "\r\n", std::string("VIDEO=PAL\r\n"), std::string("GAME=EU\r\n")}) {
for (;;) {
if (position + line.size() > bytes.size()) {
return std::nullopt;
}
const auto start = position;
const auto savedKey = key;
bool hasNull = false;
for (const char value : line) {
writeByte(value);
hasNull |= bytes[position - 1] == 0;
}
if (!hasNull) {
break;
}
// Nintendo stops at an encoded NUL. Dolphin inserts an extra LF
// before this line and retries with the shifted encryption key.
position = start;
key = savedKey;
writeByte('\n');
}
}
return bytes; // The unused tail stays raw zero, as in Dolphin.
}
// Never replace an existing file, including an unreadable or damaged one.
// Publish a complete file atomically so simultaneous launches use one identity.
inline bool Ensure(const std::filesystem::path& root, std::string& error,
std::time_t now = std::time(nullptr)) {
const auto path = FilePath(root);
std::error_code ec;
const auto status = std::filesystem::symlink_status(path, ec);
if (ec && ec != std::errc::no_such_file_or_directory) {
error = "Cannot inspect NAND setting.txt: " + ec.message();
return false;
}
if (std::filesystem::exists(status)) {
const auto existing = Read(root);
if (existing && HasIdentity(*existing)) {
return true;
}
error = "Existing NAND setting.txt is unreadable or invalid; restore it from this console's backup";
return false;
}
const auto bytes = EncodeNew(GenerateSerial(now));
if (!bytes) {
error = "Cannot initialize NAND settings: invalid system clock";
return false;
}
ec.clear();
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
error = "Cannot create NAND settings directory: " + ec.message();
return false;
}
static std::atomic<unsigned> sequence{0};
const auto scratch = path.parent_path() / (".setting-init-" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + std::to_string(sequence++));
if (!std::filesystem::create_directory(scratch, ec)) {
error = "Cannot create temporary NAND settings directory: " + ec.message();
return false;
}
const auto temporary = scratch / "setting.txt";
bool written = false;
{
std::ofstream output(temporary, std::ios::binary);
output.write(reinterpret_cast<const char*>(bytes->data()), bytes->size());
output.close();
written = static_cast<bool>(output);
}
bool published = false;
if (written) {
#ifdef _WIN32
published = MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH) != 0;
#else
published = ::link(temporary.c_str(), path.c_str()) == 0;
#endif
}
std::filesystem::remove(temporary, ec);
std::filesystem::remove(scratch, ec);
// A competing launcher may have published its settings first. Always read
// the winner from NAND rather than using our unpersisted candidate serial.
const auto persisted = Read(root);
if (persisted && HasIdentity(*persisted)) {
return true;
}
error = published ? "Cannot read newly initialized NAND setting.txt" :
"Cannot persist NAND setting.txt; check NAND directory permissions";
return false;
}
} // namespace RuntimeNandSettings
+34 -1
View File
@@ -11,6 +11,7 @@
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <optional>
#include <sstream>
#include <string>
@@ -89,6 +90,8 @@ struct RuntimeUserConfig {
// comma-separated SDL-style physical button names ("south", or
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
std::array<std::optional<std::string>, 12> controllerButtons;
std::optional<bool> rumbleEnabled;
std::map<std::string, std::string> controllerExpressions;
};
namespace RuntimeConfigFile {
@@ -407,6 +410,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
FindConfigValue<std::string>(document, "controller", buttonKeys[index]);
}
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr;
section != nullptr && section->is_table()) {
for (const auto& [key, value] : section->as_table()) {
if (key.rfind("expr_", 0) == 0 && value.is_string()) {
config.controllerExpressions[key] = value.as_string();
}
}
}
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
config.windowPosX = FindConfigInt(document, "video", "window_x");
config.windowPosY = FindConfigInt(document, "video", "window_y");
@@ -677,6 +691,25 @@ inline bool SetControllerButton(size_t index, std::string value) {
return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value));
}
inline std::string ControllerExpression(const std::string& key) {
const auto it = Get().controllerExpressions.find(key);
return it == Get().controllerExpressions.end() ? std::string() : it->second;
}
inline bool SetControllerExpression(const std::string& key, const std::string& value) {
Mutable().controllerExpressions[key] = value;
return WriteSetting("controller", key, FormatString(value));
}
inline bool RumbleEnabled(bool fallback = true) {
return Get().rumbleEnabled.value_or(fallback);
}
inline bool SetRumbleEnabled(bool value) {
Mutable().rumbleEnabled = value;
return WriteSetting("controller", "rumble", value ? "true" : "false");
}
inline bool SetAudioVolume(float value) {
value = std::clamp(value, 0.0f, 1.0f);
Mutable().audioVolume = value;
@@ -804,7 +837,7 @@ inline bool SetWiiRemotesEnabled(bool value) {
}
// Whether to keep rescanning Bluetooth while no Wii controller is connected.
inline bool WiiContinuousScanEnabled(bool fallback = true) {
inline bool WiiContinuousScanEnabled(bool fallback = false) {
return Get().wiiContinuousScan.value_or(fallback);
}
+3 -1
View File
@@ -102,8 +102,10 @@ void HideRemotesFromPad(PADStatus* statuses, uint32_t count);
void Poll();
// Forces one re-enumeration right now (settings overlay "Rescan now").
void RescanNow();
// True while Poll() is actively rescanning (no Wii controller connected).
// True while Poll() is looking for a remote (no Wii controller connected).
bool IsScanning();
// True where looking means periodic rescans; elsewhere Poll() waits for hotplug.
bool PeriodicRescanEnabled();
// Rescans issued since a Wii controller was last seen.
uint32_t ScanCount();
+6 -2
View File
@@ -256,8 +256,12 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr
gf.cpuContext.srr0 = entryPoint;
// The host stack models only translated host calls; the guest stack starts
// at stackBase in the CPU context above.
constexpr size_t kHostStackSize = 64 * 1024;
// at stackBase in the CPU context above. 64 KiB is too small for deep
// translated/HLE call chains (notably NW4R's sound worker), and on macOS
// it can exhaust the guarded coroutine stack as unrelated host work (such
// as a window resize) adds a little more nesting. Keep enough headroom for
// those chains while the guest stack remains separately bounded.
constexpr size_t kHostStackSize = 1024 * 1024;
gf.fiber = HostContext::Create(kHostStackSize, FiberProc,
reinterpret_cast<void*>(static_cast<uintptr_t>(guestThreadAddr)));
+68
View File
@@ -1,18 +1,75 @@
#include "hle_stubs.h"
#include "memory.h"
#include "hle/controller_status_contract.h"
#include "input_bindings.h"
#include "wii_remote_input.h"
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <SDL3/SDL_gamepad.h>
#include <dolphin/pad.h>
namespace {
std::atomic<bool> g_rumbleEnabled{true};
bool NativeButtonHeld(SDL_Gamepad* gamepad, uint32_t nativeButton) {
if (gamepad == nullptr || nativeButton == PAD_NATIVE_BUTTON_INVALID ||
nativeButton >= SDL_GAMEPAD_BUTTON_COUNT) {
return false;
}
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(nativeButton));
}
// A digital button bound to L or R has no analog travel of its own. On real
// hardware the click only engages at full depression, so report a full pull.
void FillTriggersHeldByButtons(PADStatus* statuses) {
if (InputBindings::InputBlocked()) {
return;
}
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
if (statuses[port].err != PAD_ERR_NONE) {
continue;
}
const s32 index = PADGetIndexForPort(port);
if (index < 0) {
continue;
}
SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast<u32>(index));
if (gamepad == nullptr) {
continue;
}
const auto scan = [&](PADButtonMapping* mappings, u32 count) {
if (mappings == nullptr) {
return;
}
for (u32 i = 0; i < count; ++i) {
const PADButtonMapping& mapping = mappings[i];
if (mapping.padButton != PAD_TRIGGER_L && mapping.padButton != PAD_TRIGGER_R) {
continue;
}
if (!NativeButtonHeld(gamepad, mapping.nativeButton)) {
continue;
}
if (mapping.padButton == PAD_TRIGGER_L) {
statuses[port].triggerLeft = 255;
} else {
statuses[port].triggerRight = 255;
}
}
};
u32 count = 0;
scan(PADGetButtonMappings(port, &count), count);
count = 0;
scan(PADGetAltButtonMappings(port, &count), count);
}
}
void WritePadStatus(uint32_t base, const PADStatus& status) {
const auto guestStatus = PadStatusContract::Encode({
status.button,
@@ -32,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) {
} // namespace
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled)
{
g_rumbleEnabled.store(enabled, std::memory_order_relaxed);
}
extern "C" uint32_t PAD__Init_HLE()
{
return PADInit() ? 1u : 0u;
@@ -54,6 +116,9 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
// between "connected" and "no controller" every time the overlay toggles.
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
FillTriggersHeldByButtons(statuses);
InputBindings::Apply(statuses);
try {
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
WritePadStatus(statusPtr + static_cast<uint32_t>(i * PadStatusContract::kGuestStatusSize),
@@ -81,6 +146,9 @@ PPC_NATIVE_OVERRIDE(801AF1E4, PAD__Recalibrate_HLE, uint32_t, (uint32_t mask), (
extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command)
{
if (command == PAD_MOTOR_RUMBLE && !g_rumbleEnabled.load(std::memory_order_relaxed)) {
command = PAD_MOTOR_STOP;
}
PADControlMotor(chan, command);
}
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
+1 -1
View File
@@ -499,7 +499,7 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector<IoVector>& in, const s
// Nonblocking sockets get -SO_EAGAIN immediately (Dolphin's retry predicate
// short-circuits on nonBlock/forceNonBlock, IOS/Network/Socket.cpp:715-718);
// waiting here anyway stalled the whole emulation thread on every empty read.
constexpr int kStreamRecvWaitMs = 250;
constexpr int kStreamRecvWaitMs = 1000;
const int streamWaitMs = (forceNonBlock || s->nonblocking) ? 0 : kStreamRecvWaitMs;
const bool waited = ret < 0 && !fromPtr && s->type == SOCK_STREAM &&
IsWouldBlockError(nativeErr) && WaitForReadable(s->native, streamWaitMs);
+29 -15
View File
@@ -12,7 +12,25 @@
namespace {
constexpr uint32_t kPalProductRegion = 2;
// Use the SDK's own value tables, including its unknown-region result.
uint32_t LookupProductRegion(uint32_t table, uint32_t stride, uint32_t count,
const std::string& value) {
for (uint32_t index = 0; index < count; ++index) {
const uint32_t entry = table + index * stride;
if (!Memory::Contains(entry, stride)) {
break;
}
const auto* bytes = static_cast<const uint8_t*>(Memory::GetPointer(entry, stride));
if (bytes[0] == 0xFF) {
break;
}
if (value.size() < stride - 1 &&
std::memcmp(bytes + 1, value.c_str(), value.size() + 1) == 0) {
return bytes[0];
}
}
return 0xFFFFFFFFu;
}
} // namespace
@@ -50,16 +68,12 @@ extern "C" uint32_t SCGetEuRgb60Mode_HLE()
PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ());
// The managed NAND intentionally starts without a console-owned setting.txt.
// DWC nevertheless requires the Wii product code and serial number so it can
// include csnum in NAS authentication. Expose one stable virtual-console
// identity without requiring or mutating a user's real NAND.
// Expose the selected emulated NAND identity through the SDK SC APIs.
extern "C" uint32_t SCGetProductArea_HLE()
{
// The PAL setting.txt AREA value is "EUR". The SDK's lookup table at
// 0x8029CEB0 maps JPN=0, USA=1, EUR=2.
return kPalProductRegion;
return LookupProductRegion(0x8029CEB0u, 5, 13,
RuntimeConsoleIdentity::Current().area);
}
PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ());
@@ -68,12 +82,13 @@ extern "C" uint32_t SCGetProductCode_HLE()
{
// Original PAL SC storage for the six-byte CODE value.
constexpr uint32_t kProductCodeAddress = 0x803869E0u;
static constexpr char kProductCode[] = "LEH";
if (!Memory::Contains(kProductCodeAddress, sizeof(kProductCode))) {
const std::string& productCode = RuntimeConsoleIdentity::Current().productCode;
const size_t size = productCode.size() + 1;
if (!Memory::Contains(kProductCodeAddress, size)) {
return 0;
}
std::memcpy(Memory::GetPointer(kProductCodeAddress, sizeof(kProductCode)),
kProductCode, sizeof(kProductCode));
std::memcpy(Memory::GetPointer(kProductCodeAddress, size),
productCode.c_str(), size);
return kProductCodeAddress;
}
@@ -94,9 +109,8 @@ PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddr
extern "C" uint32_t SCGetProductGameRegion_HLE()
{
// The PAL setting.txt GAME value is "EU". The SDK's own lookup table at
// 0x8029CEF8 maps JP=0, US=1, EU=2.
return kPalProductRegion;
return LookupProductRegion(0x8029CEF8u, 4, 4,
RuntimeConsoleIdentity::Current().gameRegion);
}
PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ());
+3
View File
@@ -93,6 +93,9 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t
const std::filesystem::path hostPath = TranslateNandPath(path);
if (const auto result = NandCheckSystemSaveRead("NANDOpen", hostPath, mode))
return *result;
// Existing-file write opens go through a shadow copy seeded from the original, so a
// crash between NANDWrite and NANDClose cannot leave a torn file (the game patches
// sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place.
+2
View File
@@ -411,6 +411,8 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint
if (mode == 1) {
// Read-only safe open reads the original in place; the library builds no scratch
// copy for this case.
if (const auto result = NandCheckSystemSaveRead("NANDSafeOpen", hostPath, mode))
return *result;
FILE* file = NandFopen(hostPath, "rb");
if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) {
file = NandFopen(hostPath, "rb");
+20
View File
@@ -411,6 +411,26 @@ bool IsFaceLibResourcePath(const char* path) {
return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0;
}
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
const std::filesystem::path& hostPath, int mode, bool ios) {
const auto action = RuntimeNandSave::CheckRead(hostPath, mode);
if (action == RuntimeNandSave::ReadAction::Proceed) return std::nullopt;
if (action == RuntimeNandSave::ReadAction::Missing) {
LogNandWarning(who, "treating empty or zero-filled system save '%s' as missing",
HostPathText(hostPath).c_str());
return ios ? ISFS_ENOENT : NAND_RESULT_NOEXISTS;
}
if (action == RuntimeNandSave::ReadAction::RecoveryNeeded) {
LogNandError(who, "system save '%s' is missing or blank but its .nandsafe.tmp contains data; "
"back up both files before attempting recovery",
HostPathText(hostPath).c_str());
} else {
LogNandError(who, "could not inspect system save '%s' or its write shadow; leaving data untouched",
HostPathText(hostPath).c_str());
}
return ios ? ISFS_EIO : NAND_RESULT_UNKNOWN;
}
// Create directories recursively
bool CreateDirectoryPath(const std::filesystem::path& path) {
if (path.empty()) {
+7
View File
@@ -9,6 +9,7 @@
#include "hle/runtime_parse_helpers.h"
#include "memory.h"
#include "nand_path.h"
#include "nand_save_probe.h"
#include "hle/net/network.h"
#include "recomp_mod_loader.h"
#include "runtime_config.h"
@@ -26,6 +27,7 @@
#include <deque>
#include <map>
#include <mutex>
#include <optional>
#include <vector>
#include <filesystem>
#include <string>
@@ -56,6 +58,11 @@ constexpr uint32_t kNandTitleIdLo = 0x524D4350; // "RMCP" fallback
void LogNandError(const char* func, const char* fmt, ...);
void LogNandWarning(const char* func, const char* fmt, ...);
// An empty optional means continue opening normally; otherwise return the
// supplied NAND/IOS error without exposing a failed scan as a missing save.
std::optional<int32_t> NandCheckSystemSaveRead(const char* who,
const std::filesystem::path& hostPath, int mode, bool ios = false);
// ============================================================================
// File Descriptor Management
// ============================================================================
+3
View File
@@ -391,6 +391,9 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) {
// It's a NAND file path
const std::filesystem::path hostPath = TranslateNandPath(path);
if (const auto result = NandCheckSystemSaveRead("IOS_Open", hostPath, mode, true))
return *result;
// Seed FaceLib resources before the existence check so every open mode can
// still find them on a fresh managed NAND.
+302
View File
@@ -0,0 +1,302 @@
#include "input_bindings.h"
#include "controller_button_names.h"
#include "input_expr.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <mutex>
#include <unordered_map>
#include <SDL3/SDL_gamepad.h>
namespace InputBindings {
namespace {
struct Binding {
std::string text;
InputExpr::Expression expr;
bool active = false;
};
std::mutex g_mutex;
std::array<std::array<Binding, kControls.size()>, PAD_CHANMAX> g_bindings;
bool g_anyBound = false;
bool g_inputBlocked = false;
// Dolphin input names, mapped onto SDL. XInput-style names are exact; DInput
// "Button <n>" indices follow the common PlayStation layout, which is what
// DInput reports for a DualShock/DualSense. Other pads may number differently.
const std::unordered_map<std::string, SDL_GamepadButton>& ButtonNames() {
static const std::unordered_map<std::string, SDL_GamepadButton> table = {
{"Button A", SDL_GAMEPAD_BUTTON_SOUTH}, {"Button B", SDL_GAMEPAD_BUTTON_EAST},
{"Button X", SDL_GAMEPAD_BUTTON_WEST}, {"Button Y", SDL_GAMEPAD_BUTTON_NORTH},
{"Shoulder L", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"Shoulder R", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"Thumb L", SDL_GAMEPAD_BUTTON_LEFT_STICK}, {"Thumb R", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"Start", SDL_GAMEPAD_BUTTON_START}, {"Back", SDL_GAMEPAD_BUTTON_BACK},
{"Guide", SDL_GAMEPAD_BUTTON_GUIDE},
{"Pad N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Pad S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"Pad W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Pad E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"Hat 0 N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Hat 0 S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"Hat 0 W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Hat 0 E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"Button 0", SDL_GAMEPAD_BUTTON_WEST}, {"Button 1", SDL_GAMEPAD_BUTTON_SOUTH},
{"Button 2", SDL_GAMEPAD_BUTTON_EAST}, {"Button 3", SDL_GAMEPAD_BUTTON_NORTH},
{"Button 4", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"Button 5", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"Button 8", SDL_GAMEPAD_BUTTON_BACK}, {"Button 9", SDL_GAMEPAD_BUTTON_START},
{"Button 10", SDL_GAMEPAD_BUTTON_LEFT_STICK},
{"Button 11", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"Button 12", SDL_GAMEPAD_BUTTON_GUIDE}, {"Button 13", SDL_GAMEPAD_BUTTON_TOUCHPAD},
};
return table;
}
// Signed axis names: SDL axis plus the direction that counts as positive.
struct AxisRef {
SDL_GamepadAxis axis;
int sign;
};
const std::unordered_map<std::string, AxisRef>& AxisNames() {
static const std::unordered_map<std::string, AxisRef> table = {
{"Axis X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Axis X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
{"Axis Y-", {SDL_GAMEPAD_AXIS_LEFTY, -1}}, {"Axis Y+", {SDL_GAMEPAD_AXIS_LEFTY, 1}},
{"Axis Z-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}}, {"Axis Z+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
{"Axis Zr-", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},{"Axis Zr+", {SDL_GAMEPAD_AXIS_RIGHTY, 1}},
{"Left X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Left X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
{"Left Y-", {SDL_GAMEPAD_AXIS_LEFTY, 1}}, {"Left Y+", {SDL_GAMEPAD_AXIS_LEFTY, -1}},
{"Right X-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}},{"Right X+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
{"Right Y-", {SDL_GAMEPAD_AXIS_RIGHTY, 1}}, {"Right Y+", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},
{"Full Axis Xr+", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
{"Full Axis Yr+", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
{"Trigger L", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
{"Trigger R", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
};
return table;
}
double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
if (gamepad == nullptr) {
return 0.0;
}
if (const auto it = ButtonNames().find(name); it != ButtonNames().end()) {
return SDL_GetGamepadButton(gamepad, it->second) ? 1.0 : 0.0;
}
if (const auto it = AxisNames().find(name); it != AxisNames().end()) {
const double raw = SDL_GetGamepadAxis(gamepad, it->second.axis) / 32767.0;
return std::clamp(raw * it->second.sign, 0.0, 1.0);
}
// Fall back to this project's own positional names, so a binding written
// here does not have to use Dolphin vocabulary.
if (const auto* native = ControllerNames::FindNativeButton(name)) {
if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) {
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
: 0.0;
}
}
return 0.0;
}
SDL_Gamepad* GamepadForPort(uint32_t port) {
const s32 index = PADGetIndexForPort(port);
return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast<u32>(index));
}
size_t ControlIndexForDolphinName(const std::string& name) {
for (size_t i = 0; i < kControls.size(); ++i) {
if (name == kControls[i].dolphinName) {
return i;
}
}
return kControls.size();
}
void RecomputeAnyBoundLocked() {
g_anyBound = false;
for (const auto& port : g_bindings) {
for (const auto& binding : port) {
if (!binding.expr.Empty()) {
g_anyBound = true;
return;
}
}
}
}
std::string ConfigKey(uint32_t port, size_t control) {
std::string key = "expr_" + std::to_string(port + 1) + "_";
for (const char* c = kControls[control].dolphinName; *c != '\0'; ++c) {
key += (*c == '/' || *c == '-') ? '_' : static_cast<char>(std::tolower(*c));
}
return key;
}
} // namespace
void SetInputBlocked(bool blocked) noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
g_inputBlocked = blocked;
}
bool InputBlocked() noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
return g_inputBlocked;
}
void Reload() noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
for (size_t control = 0; control < kControls.size(); ++control) {
Binding& binding = g_bindings[port][control];
binding = Binding{};
binding.text = RuntimeConfigFile::ControllerExpression(ConfigKey(port, control));
std::string error;
if (!binding.text.empty() &&
!InputExpr::Expression::Parse(binding.text, binding.expr, error)) {
RT_LOG(RT_TAG_CONFIG) << "expression for port " << (port + 1) << " "
<< kControls[control].dolphinName << ": " << error << std::endl;
}
}
}
RecomputeAnyBoundLocked();
}
void Apply(PADStatus* statuses) noexcept {
if (statuses == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_anyBound) {
return;
}
const bool blocked = g_inputBlocked;
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
if (statuses[port].err != PAD_ERR_NONE) {
continue;
}
SDL_Gamepad* gamepad = GamepadForPort(port);
const InputExpr::InputSource source = [gamepad](const std::string& name) {
return ReadInput(gamepad, name);
};
for (size_t control = 0; control < kControls.size(); ++control) {
Binding& binding = g_bindings[port][control];
if (binding.expr.Empty()) {
continue;
}
if (blocked) {
binding.active = false;
continue;
}
const double value = binding.expr.Evaluate(source);
binding.active = value > InputExpr::kConditionThreshold;
const ControlInfo& info = kControls[control];
if (info.padButton != 0 && binding.active) {
statuses[port].button |= info.padButton;
}
if (info.analog != 0) {
const double safe = std::isfinite(value) ? std::clamp(value, 0.0, 1.0) : 0.0;
const auto scaled = static_cast<uint8_t>(safe * 255.0);
uint8_t& target =
info.analog == 1 ? statuses[port].triggerLeft : statuses[port].triggerRight;
target = std::max(target, scaled);
}
}
}
}
std::string GetExpression(uint32_t port, size_t control) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
return {};
}
std::lock_guard<std::mutex> lock(g_mutex);
return g_bindings[port][control].text;
}
bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
error = "invalid control";
return false;
}
InputExpr::Expression parsed;
if (!InputExpr::Expression::Parse(text, parsed, error)) {
return false;
}
{
std::lock_guard<std::mutex> lock(g_mutex);
Binding& binding = g_bindings[port][control];
binding.text = text;
binding.expr = std::move(parsed);
binding.active = false;
RecomputeAnyBoundLocked();
}
RuntimeConfigFile::SetControllerExpression(ConfigKey(port, control), text);
return true;
}
bool IsActive(uint32_t port, size_t control) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
return false;
}
std::lock_guard<std::mutex> lock(g_mutex);
return g_bindings[port][control].active;
}
std::string DefaultDolphinConfigPath() noexcept {
std::error_code ec;
if (const char* appdata = std::getenv("APPDATA"); appdata != nullptr) {
const std::filesystem::path roaming =
std::filesystem::path(appdata) / "Dolphin Emulator" / "Config" / "GCPadNew.ini";
if (std::filesystem::exists(roaming, ec)) {
return RuntimeConfigFile::PathToUtf8(roaming);
}
}
const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory();
return RuntimeConfigFile::PathToUtf8(executableDirectory ? *executableDirectory / "GCPadNew.ini"
: std::filesystem::path("GCPadNew.ini"));
}
int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, std::string& summary,
std::string& error) noexcept {
std::vector<std::pair<std::string, std::string>> controls;
std::string device;
if (!InputExpr::ReadDolphinConfig(RuntimeConfigFile::PathFromUtf8(path), padIndex, controls, device,
error)) {
return -1;
}
int imported = 0;
std::vector<std::string> skipped;
for (const auto& [name, text] : controls) {
const size_t control = ControlIndexForDolphinName(name);
if (control == kControls.size()) {
if (name.rfind("Main Stick/", 0) == 0 || name.rfind("C-Stick/", 0) == 0) {
skipped.push_back(name);
}
continue;
}
std::string parseError;
if (!SetExpression(port, control, text, parseError)) {
skipped.push_back(name);
RT_LOG(RT_TAG_CONFIG) << "import " << name << ": " << parseError << std::endl;
continue;
}
++imported;
}
summary = "Imported " + std::to_string(imported) + " controls";
if (!device.empty()) {
summary += " from " + device;
}
if (!skipped.empty()) {
summary += "; skipped " + std::to_string(skipped.size()) +
" (stick axes and unsupported inputs keep their existing mapping)";
}
return imported;
}
} // namespace InputBindings
+631
View File
@@ -0,0 +1,631 @@
#include "input_expr.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <unordered_map>
namespace InputExpr {
namespace {
using Clock = std::chrono::steady_clock;
using FSec = std::chrono::duration<double>;
enum class Kind {
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
Greater, Less, Equal,
FnIf, FnMin, FnMax, FnClamp, FnAbs, FnSqrt, FnPow, FnSin, FnCos, FnTan,
FnDeadzone, FnTimer, FnToggle, FnHold, FnTap, FnPulse, FnSmooth, FnNot,
};
struct FnInfo {
Kind kind;
int minArgs;
int maxArgs;
};
const std::unordered_map<std::string, FnInfo>& FunctionTable() {
static const std::unordered_map<std::string, FnInfo> table = {
{"not", {Kind::FnNot, 1, 1}}, {"if", {Kind::FnIf, 3, 3}},
{"min", {Kind::FnMin, 2, 2}}, {"max", {Kind::FnMax, 2, 2}},
{"clamp", {Kind::FnClamp, 3, 3}}, {"abs", {Kind::FnAbs, 1, 1}},
{"sqrt", {Kind::FnSqrt, 1, 1}}, {"pow", {Kind::FnPow, 2, 2}},
{"sin", {Kind::FnSin, 1, 1}}, {"cos", {Kind::FnCos, 1, 1}},
{"tan", {Kind::FnTan, 1, 1}}, {"deadzone", {Kind::FnDeadzone, 2, 2}},
{"timer", {Kind::FnTimer, 1, 1}}, {"toggle", {Kind::FnToggle, 1, 2}},
{"hold", {Kind::FnHold, 2, 2}}, {"tap", {Kind::FnTap, 2, 3}},
{"pulse", {Kind::FnPulse, 2, 2}}, {"smooth", {Kind::FnSmooth, 2, 3}},
};
return table;
}
} // namespace
struct Node {
Kind kind;
double literal = 0.0;
std::string input;
std::vector<std::unique_ptr<Node>> args;
// Per-instance state for the stateful functions. Mutable because Evaluate
// is logically a read of current input state.
mutable bool released = false;
mutable bool state = false;
mutable unsigned taps = 0;
mutable double value = 0.0;
mutable Clock::time_point mark = Clock::now();
mutable bool marked = false;
};
namespace {
// ---- tokenizer ----------------------------------------------------------
struct Token {
enum Type { End, Input, Number, Ident, Op, LParen, RParen, Comma } type = End;
std::string text;
};
class Lexer {
public:
explicit Lexer(const std::string& text) : m_text(text) {}
bool Next(Token& tok, std::string& error) {
while (m_pos < m_text.size() && std::isspace(static_cast<unsigned char>(m_text[m_pos]))) {
++m_pos;
}
if (m_pos >= m_text.size()) {
tok = Token{};
return true;
}
const char c = m_text[m_pos];
if (c == '`') {
const size_t close = m_text.find('`', m_pos + 1);
if (close == std::string::npos) {
error = "unterminated ` in expression";
return false;
}
tok.type = Token::Input;
tok.text = m_text.substr(m_pos + 1, close - m_pos - 1);
m_pos = close + 1;
return true;
}
if (std::isdigit(static_cast<unsigned char>(c)) || c == '.') {
size_t end = m_pos;
while (end < m_text.size() &&
(std::isdigit(static_cast<unsigned char>(m_text[end])) || m_text[end] == '.')) {
++end;
}
tok.type = Token::Number;
tok.text = m_text.substr(m_pos, end - m_pos);
m_pos = end;
return true;
}
if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
size_t end = m_pos;
while (end < m_text.size() &&
(std::isalnum(static_cast<unsigned char>(m_text[end])) || m_text[end] == '_' ||
m_text[end] == ' ')) {
++end;
}
// Trailing spaces belong to the separator, not the identifier.
while (end > m_pos && m_text[end - 1] == ' ') {
--end;
}
tok.type = Token::Ident;
tok.text = m_text.substr(m_pos, end - m_pos);
m_pos = end;
return true;
}
if (c == '(') { tok.type = Token::LParen; ++m_pos; return true; }
if (c == ')') { tok.type = Token::RParen; ++m_pos; return true; }
if (c == ',') { tok.type = Token::Comma; ++m_pos; return true; }
if (std::string("!&|^+-*/><=").find(c) != std::string::npos) {
tok.type = Token::Op;
tok.text = std::string(1, c);
++m_pos;
return true;
}
error = std::string("unexpected character '") + c + "' in expression";
return false;
}
size_t Position() const { return m_pos; }
private:
const std::string& m_text;
size_t m_pos = 0;
};
// ---- parser -------------------------------------------------------------
using NodePtr = std::unique_ptr<Node>;
class Parser {
public:
explicit Parser(const std::string& text) : m_lexer(text) { Advance(); }
NodePtr ParseExpression(std::string& error) {
NodePtr node = ParseBinary(0, error);
if (!node) {
return nullptr;
}
if (m_failed) {
error = m_lexError;
return nullptr;
}
if (m_tok.type != Token::End) {
error = "unexpected trailing input in expression";
return nullptr;
}
return node;
}
private:
void Advance() {
if (!m_lexer.Next(m_tok, m_lexError)) {
m_tok = Token{};
m_failed = true;
}
}
static int Precedence(const std::string& op) {
if (op == "|") return 1;
if (op == "^") return 2;
if (op == "&") return 3;
if (op == ">" || op == "<" || op == "=") return 4;
if (op == "+" || op == "-") return 5;
if (op == "*" || op == "/") return 6;
return -1;
}
static Kind BinaryKind(const std::string& op) {
if (op == "|") return Kind::Or;
if (op == "^") return Kind::Xor;
if (op == "&") return Kind::And;
if (op == ">") return Kind::Greater;
if (op == "<") return Kind::Less;
if (op == "=") return Kind::Equal;
if (op == "+") return Kind::Add;
if (op == "-") return Kind::Sub;
if (op == "*") return Kind::Mul;
return Kind::Div;
}
NodePtr ParseBinary(int minPrec, std::string& error) {
NodePtr lhs = ParseUnary(error);
if (!lhs) {
return nullptr;
}
while (m_tok.type == Token::Op) {
const int prec = Precedence(m_tok.text);
if (prec < 0 || prec < minPrec) {
break;
}
const std::string op = m_tok.text;
Advance();
NodePtr rhs = ParseBinary(prec + 1, error);
if (!rhs) {
return nullptr;
}
auto node = std::make_unique<Node>();
node->kind = BinaryKind(op);
node->args.push_back(std::move(lhs));
node->args.push_back(std::move(rhs));
lhs = std::move(node);
}
return lhs;
}
NodePtr ParseUnary(std::string& error) {
if (m_failed) {
error = m_lexError;
return nullptr;
}
if (m_tok.type == Token::Op && (m_tok.text == "!" || m_tok.text == "-" || m_tok.text == "+")) {
const std::string op = m_tok.text;
Advance();
NodePtr inner = ParseUnary(error);
if (!inner) {
return nullptr;
}
if (op == "+") {
return inner;
}
auto node = std::make_unique<Node>();
if (op == "!") {
node->kind = Kind::Not;
node->args.push_back(std::move(inner));
} else {
node->kind = Kind::Sub;
auto zero = std::make_unique<Node>();
zero->kind = Kind::Literal;
node->args.push_back(std::move(zero));
node->args.push_back(std::move(inner));
}
return node;
}
return ParsePrimary(error);
}
NodePtr ParsePrimary(std::string& error) {
if (m_failed) {
error = m_lexError;
return nullptr;
}
switch (m_tok.type) {
case Token::Input: {
auto node = std::make_unique<Node>();
node->kind = Kind::Input;
node->input = m_tok.text;
Advance();
return node;
}
case Token::Number: {
auto node = std::make_unique<Node>();
node->kind = Kind::Literal;
node->literal = std::strtod(m_tok.text.c_str(), nullptr);
Advance();
return node;
}
case Token::LParen: {
Advance();
NodePtr inner = ParseBinary(0, error);
if (!inner) {
return nullptr;
}
if (m_tok.type != Token::RParen) {
error = "expected closing paren";
return nullptr;
}
Advance();
return inner;
}
case Token::Ident: {
const std::string name = m_tok.text;
Advance();
if (m_tok.type != Token::LParen) {
// A bare identifier is an input name, as Dolphin allows for
// simple cases such as "Start" or "LSHIFT".
auto node = std::make_unique<Node>();
node->kind = Kind::Input;
node->input = name;
return node;
}
const auto it = FunctionTable().find(name);
if (it == FunctionTable().end()) {
error = "unknown function '" + name + "'";
return nullptr;
}
Advance();
auto node = std::make_unique<Node>();
node->kind = it->second.kind;
if (m_tok.type != Token::RParen) {
while (true) {
NodePtr arg = ParseBinary(0, error);
if (!arg) {
return nullptr;
}
node->args.push_back(std::move(arg));
if (m_tok.type != Token::Comma) {
break;
}
Advance();
}
}
if (m_tok.type != Token::RParen) {
error = "expected closing paren after " + name + " arguments";
return nullptr;
}
Advance();
const int count = static_cast<int>(node->args.size());
if (count < it->second.minArgs || count > it->second.maxArgs) {
error = name + " takes " + std::to_string(it->second.minArgs) + " to " +
std::to_string(it->second.maxArgs) + " arguments";
return nullptr;
}
return node;
}
default:
error = "expected start of expression";
return nullptr;
}
}
Lexer m_lexer;
Token m_tok;
std::string m_lexError;
bool m_failed = false;
};
// ---- evaluator ----------------------------------------------------------
double Eval(const Node& node, const InputSource& source);
double Arg(const Node& node, size_t index, const InputSource& source) {
return Eval(*node.args[index], source);
}
double Eval(const Node& node, const InputSource& source) {
switch (node.kind) {
case Kind::Literal: return node.literal;
case Kind::Input: return source ? source(node.input) : 0.0;
case Kind::Not:
case Kind::FnNot: return 1.0 - Arg(node, 0, source);
case Kind::Add: return Arg(node, 0, source) + Arg(node, 1, source);
case Kind::Sub: return Arg(node, 0, source) - Arg(node, 1, source);
case Kind::Mul: return Arg(node, 0, source) * Arg(node, 1, source);
case Kind::Div: {
// Both sides are evaluated even when the divisor is zero: the left
// subtree may hold stateful functions that need their frame update.
const double lhs = Arg(node, 0, source);
const double rhs = Arg(node, 1, source);
return rhs == 0.0 ? 0.0 : lhs / rhs;
}
case Kind::And: return std::min(Arg(node, 0, source), Arg(node, 1, source));
case Kind::Or: return std::max(Arg(node, 0, source), Arg(node, 1, source));
case Kind::Xor: {
const double a = Arg(node, 0, source);
const double b = Arg(node, 1, source);
return std::max(std::min(a, 1.0 - b), std::min(b, 1.0 - a));
}
case Kind::Greater: return Arg(node, 0, source) > Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::Less: return Arg(node, 0, source) < Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::Equal: return Arg(node, 0, source) == Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::FnIf:
return Arg(node, 0, source) > kConditionThreshold ? Arg(node, 1, source) : Arg(node, 2, source);
case Kind::FnMin: return std::min(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnMax: return std::max(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnClamp: {
const double v = Arg(node, 0, source);
double lo = Arg(node, 1, source);
double hi = Arg(node, 2, source);
if (lo > hi) {
std::swap(lo, hi);
}
return std::clamp(v, lo, hi);
}
case Kind::FnAbs: return std::abs(Arg(node, 0, source));
case Kind::FnSqrt: return std::sqrt(Arg(node, 0, source));
case Kind::FnPow: return std::pow(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnSin: return std::sin(Arg(node, 0, source));
case Kind::FnCos: return std::cos(Arg(node, 0, source));
case Kind::FnTan: return std::tan(Arg(node, 0, source));
case Kind::FnDeadzone: {
const double v = Arg(node, 0, source);
const double dz = std::clamp(Arg(node, 1, source), 0.0, 0.999);
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
}
case Kind::FnTimer: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double period = Arg(node, 0, source);
double progress = std::chrono::duration_cast<FSec>(now - node.mark).count() / period;
if (!std::isfinite(progress) || progress < 0.0) {
progress = 0.0;
node.mark = now;
} else if (progress >= 1.0) {
const double resets = std::floor(progress);
node.mark += std::chrono::duration_cast<Clock::duration>(FSec(period * resets));
progress -= resets;
}
return progress;
}
case Kind::FnToggle: {
const double inner = Arg(node, 0, source);
if (inner < kConditionThreshold) {
node.released = true;
} else if (node.released) {
node.released = false;
node.state = !node.state;
}
if (node.args.size() == 2 && Arg(node, 1, source) > kConditionThreshold) {
node.state = false;
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnHold: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double input = Arg(node, 0, source);
if (input < kConditionThreshold) {
node.state = false;
node.mark = now;
} else if (!node.state) {
if (std::chrono::duration_cast<FSec>(now - node.mark).count() >= Arg(node, 1, source)) {
node.state = true;
}
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnTap: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
const double input = Arg(node, 0, source);
const bool timeUp = elapsed > Arg(node, 1, source);
// The count is user authored, so a negative or huge value must not
// reach the unsigned conversion.
double requested = node.args.size() == 3 ? Arg(node, 2, source) : 2.0;
if (!std::isfinite(requested)) {
requested = 2.0;
}
const auto desired = static_cast<unsigned>(std::clamp(requested + 0.5, 1.0, 64.0));
if (input < kConditionThreshold) {
node.released = true;
if (node.taps > 0 && timeUp) {
node.taps = 0;
}
return 0.0;
}
if (node.released) {
if (node.taps == 0) {
node.mark = now;
}
++node.taps;
node.released = false;
}
return desired == node.taps ? 1.0 : 0.0;
}
case Kind::FnPulse: {
const auto now = Clock::now();
const double input = Arg(node, 0, source);
if (input < kConditionThreshold) {
node.released = true;
} else if (node.released) {
node.released = false;
const double requested = Arg(node, 1, source);
const double safe = std::isfinite(requested) ? std::clamp(requested, 0.0, 3600.0) : 0.0;
const auto seconds = std::chrono::duration_cast<Clock::duration>(FSec(safe));
if (node.state) {
node.mark += seconds;
} else {
node.state = true;
node.mark = now + seconds;
}
}
if (node.state && now >= node.mark) {
node.state = false;
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnSmooth: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
node.mark = now;
const double desired = Arg(node, 0, source);
const double up = Arg(node, 1, source);
const double down = node.args.size() == 3 ? Arg(node, 2, source) : up;
const double rate = (desired < node.value) ? down : up;
const double maxMove = elapsed / rate;
if (!std::isfinite(maxMove)) {
node.value = desired;
} else {
const double diff = desired - node.value;
node.value += std::copysign(std::min(maxMove, std::abs(diff)), diff);
}
return node.value;
}
}
return 0.0;
}
void Collect(const Node& node, std::vector<std::string>& out) {
if (node.kind == Kind::Input) {
if (std::find(out.begin(), out.end(), node.input) == out.end()) {
out.push_back(node.input);
}
}
for (const auto& arg : node.args) {
Collect(*arg, out);
}
}
std::string Trim(const std::string& text) {
const size_t begin = text.find_first_not_of(" \t\r\n");
if (begin == std::string::npos) {
return {};
}
return text.substr(begin, text.find_last_not_of(" \t\r\n") - begin + 1);
}
} // namespace
Expression::Expression() = default;
Expression::~Expression() = default;
Expression::Expression(Expression&&) noexcept = default;
Expression& Expression::operator=(Expression&&) noexcept = default;
bool Expression::Parse(const std::string& text, Expression& out, std::string& error) {
out.m_root.reset();
if (Trim(text).empty()) {
return true;
}
Parser parser(text);
NodePtr root = parser.ParseExpression(error);
if (!root) {
return false;
}
out.m_root = std::move(root);
return true;
}
double Expression::Evaluate(const InputSource& source) const {
if (m_root == nullptr) {
return 0.0;
}
const double value = Eval(*m_root, source);
return std::isfinite(value) ? value : 0.0;
}
std::vector<std::string> Expression::ReferencedInputs() const {
std::vector<std::string> out;
if (m_root) {
Collect(*m_root, out);
}
return out;
}
bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
std::vector<std::pair<std::string, std::string>>& controls,
std::string& deviceName, std::string& error) {
std::ifstream file(path);
if (!file) {
error = "could not open " + path.string();
return false;
}
const std::string wanted = "[GCPad" + std::to_string(padIndex) + "]";
bool inSection = false;
bool found = false;
std::string line;
controls.clear();
deviceName.clear();
while (std::getline(file, line)) {
const std::string trimmed = Trim(line);
if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') {
continue;
}
if (trimmed.front() == '[') {
inSection = trimmed == wanted;
found = found || inSection;
continue;
}
if (!inSection) {
continue;
}
const size_t eq = trimmed.find('=');
if (eq == std::string::npos) {
continue;
}
const std::string key = Trim(trimmed.substr(0, eq));
const std::string value = Trim(trimmed.substr(eq + 1));
if (key == "Device") {
deviceName = value;
} else if (!value.empty()) {
controls.emplace_back(key, value);
}
}
if (!found) {
error = wanted + " not found in " + path.string();
return false;
}
return true;
}
} // namespace InputExpr
+134 -84
View File
@@ -1,6 +1,8 @@
#include "settings_overlay.h"
#include "audio_backend.h"
#include "controller_button_names.h"
#include "controller_mapping_wizard.h"
#include "input_bindings.h"
#include "game_graphics_options.h"
#include "music_attenuation.h"
#include "runtime_config.h"
@@ -34,6 +36,8 @@
#endif
#include <dolphin/pad.h>
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled);
#include <dolphin/vi.h>
#include <aurora/aurora.h>
#include <aurora/gfx.h>
@@ -65,6 +69,7 @@ const char* GraphicsApiDisplayName() {
}
bool g_topBarVisible = false;
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
int g_controllerPort = 0;
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f));
@@ -106,62 +111,9 @@ std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
return indices;
}();
struct ControllerButtonItem {
const char* configKey;
const char* label;
PADButton padButton;
};
constexpr std::array<ControllerButtonItem, PAD_BUTTON_COUNT> kControllerButtons = {{
{"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},
}};
struct NativeButtonItem {
const char* configName;
const char* label;
uint32_t nativeButton;
};
constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> 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", SDL_GAMEPAD_BUTTON_BACK},
{"guide", "Guide / Home", SDL_GAMEPAD_BUTTON_GUIDE},
{"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
{"left_stick", "Left stick click", SDL_GAMEPAD_BUTTON_LEFT_STICK},
{"right_stick", "Right stick click", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"left_shoulder", "Left shoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"right_shoulder", "Right shoulder", 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", 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", 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},
}};
using ControllerNames::kNativeButtons;
using ControllerNames::NativeButtonItem;
constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons;
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
// diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC
@@ -178,6 +130,13 @@ constexpr std::array<const char*, PAD_BUTTON_COUNT> kClassicProPreset = {
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share.
constexpr std::array<const char*, PAD_BUTTON_COUNT> kPlayStationPreset = {
"south", "east", "west", "north", "start", "back",
"left_shoulder", "right_shoulder",
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
struct ResolutionItem {
const char* label;
float scale;
@@ -225,43 +184,25 @@ void LimitResolutionForFrameRate() {
}
}
const NativeButtonItem* FindNativeButton(std::string value) {
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
return value == item.configName;
});
return it == kNativeButtons.end() ? nullptr : &*it;
}
using ControllerNames::FindNativeButton;
struct ControllerBindingPair {
std::string primary;
std::string secondary;
};
std::string TrimBindingToken(const std::string& token) {
const size_t begin = token.find_first_not_of(" \t");
if (begin == std::string::npos) {
return {};
}
const size_t end = token.find_last_not_of(" \t");
return token.substr(begin, end - begin + 1);
}
// Config values hold up to two comma-separated button names ("dpad_up" or
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
ControllerBindingPair SplitControllerBinding(const std::string& value) {
const size_t comma = value.find(',');
if (comma == std::string::npos) {
return {TrimBindingToken(value), {}};
return {ControllerNames::TrimToken(value), {}};
}
return {TrimBindingToken(value.substr(0, comma)), TrimBindingToken(value.substr(comma + 1))};
return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))};
}
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;
}
using ControllerNames::NativeButtonForValue;
void SetTopBarVisible(bool visible) {
if (g_topBarVisible == visible) {
@@ -321,7 +262,7 @@ void ApplyConfiguredMappings() {
}
bool g_wiiRemotesEnabled = RuntimeConfigFile::WiiRemotesEnabled(true);
bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(true);
bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(false);
// Accelerometer readout and zero-point calibration for a bare remote / remote + Nunchuk.
void DrawWiiRemoteAccelerometer(uint32_t port) {
@@ -455,6 +396,100 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
}
// Controller settings menu: port selection, controller assignment and button mapping.
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* text = static_cast<std::string*>(data->UserData);
text->resize(static_cast<size_t>(data->BufTextLen));
data->Buf = text->data();
}
return 0;
}
void DrawExpressionSettings() {
ImGui::SeparatorText("Expressions (Dolphin syntax)");
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f);
ImGui::TextDisabled(
"Optional. An expression overrides nothing: its result is combined with the "
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
"timer, toggle, hold, tap, pulse, smooth, deadzone behave as they do in Dolphin.");
ImGui::PopTextWrapPos();
static std::array<std::string, InputBindings::kControls.size()> errors;
static std::array<std::string, InputBindings::kControls.size()> buffers;
static std::string importStatus;
static int loadedPort = -1;
static bool reloadBuffers = true;
const auto port = static_cast<uint32_t>(g_controllerPort);
if (loadedPort != g_controllerPort || reloadBuffers) {
for (size_t i = 0; i < buffers.size(); ++i) {
buffers[i] = InputBindings::GetExpression(port, i);
}
errors.fill(std::string());
loadedPort = g_controllerPort;
reloadBuffers = false;
}
if (ImGui::Button("Import from Dolphin")) {
const std::string path = InputBindings::DefaultDolphinConfigPath();
std::string summary;
std::string error;
if (InputBindings::ImportDolphinConfig(path, g_controllerPort + 1, port, summary, error) < 0) {
importStatus = error;
} else {
importStatus = summary;
errors.fill(std::string());
reloadBuffers = true;
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Reads [GCPad%d] from %%APPDATA%%\\Dolphin Emulator\\Config\\GCPadNew.ini,\n"
"or GCPadNew.ini next to the executable.", g_controllerPort + 1);
}
if (!importStatus.empty()) {
ImGui::TextDisabled("%s", importStatus.c_str());
}
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
ImGui::PushID(static_cast<int>(i) + 2000);
std::string& text = buffers[i];
ImGui::SetNextItemWidth(300.0f);
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
ExpressionResizeCallback, &text)) {
std::string error;
errors[i] = InputBindings::SetExpression(port, i, text, error) ? std::string() : error;
}
if (InputBindings::IsActive(port, i)) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "active");
}
if (!errors[i].empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", errors[i].c_str());
}
ImGui::PopID();
}
}
void DrawRumbleSettings() {
ImGui::SeparatorText("Vibration");
if (ImGui::Checkbox("Controller vibration", &g_rumbleEnabled)) {
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
RuntimeConfigFile::SetRumbleEnabled(g_rumbleEnabled);
if (!g_rumbleEnabled) {
// Stop whatever is already running: the game will not send another
// motor command until its own state machine decides to.
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD,
};
PADControlAllMotors(stopAll.data());
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Applies to every port.");
}
}
void DrawControllerSettings() {
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const std::string label = "Port " + std::to_string(port + 1);
@@ -543,20 +578,28 @@ void DrawControllerSettings() {
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
}
ImGui::SameLine();
if (ImGui::Button("Classic Controller Pro")) {
const auto applyPreset = [&](const std::array<const char*, PAD_BUTTON_COUNT>& preset) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
if (const NativeButtonItem* native = FindNativeButton(kClassicProPreset[i])) {
if (const NativeButtonItem* native = FindNativeButton(preset[i])) {
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
PADSetAltButtonMapping(port,
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
RuntimeConfigFile::SetControllerButton(i, kClassicProPreset[i]);
RuntimeConfigFile::SetControllerButton(i, preset[i]);
}
}
altRowExpanded.fill(false);
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
};
ImGui::SameLine();
if (ImGui::Button("Classic Controller Pro")) {
applyPreset(kClassicProPreset);
}
ImGui::SameLine();
if (ImGui::Button("PlayStation")) {
applyPreset(kPlayStationPreset);
}
ImGui::SeparatorText("Button mapping");
@@ -638,6 +681,8 @@ void DrawControllerSettings() {
ImGui::TextUnformatted(kControllerButtons[i].label);
ImGui::PopID();
}
DrawExpressionSettings();
DrawRumbleSettings();
}
void DrawAudioSettings() {
@@ -992,6 +1037,8 @@ void PersistDisplayModeIfChanged() {
} // namespace
void InitializeRuntimeSettings() noexcept {
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
InputBindings::Reload();
controller_mapping_wizard::LoadPersistedMappings();
ApplyConfiguredMappings();
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
@@ -1012,6 +1059,7 @@ void InitializeRuntimeSettings() noexcept {
g_strapInputAccepted.store(false, std::memory_order_relaxed);
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
PADBlockInput(false);
InputBindings::SetInputBlocked(false);
}
void HandleEvents(const AuroraEvent* events) noexcept {
@@ -1054,7 +1102,9 @@ void Draw() noexcept {
DrawTopBar();
controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game.
PADBlockInput(controller_mapping_wizard::IsActive());
const bool inputBlocked = controller_mapping_wizard::IsActive();
PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen();
}
+49 -6
View File
@@ -16,6 +16,8 @@
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <unordered_set>
namespace WiiRemoteInput {
namespace {
@@ -346,6 +348,10 @@ bool AnyWiiControllerConnected() {
// Route SDL's input diagnostics (HIDAPI open failures, the Wii driver's
// extension/status messages) into console.log, minus the periodic chatter.
// A sub-warning message is written once: SDL repeats the same line on every
// enumeration (one "couldn't open /dev/hidraw7: Permission denied" per HID
// device per pass), and console.log is unbuffered, so the repeats were a
// per-pass burst of writes on the main thread for no new information.
void SDLCALL LogSdlMessage(void*, int category, SDL_LogPriority priority, const char* message) {
if (message == nullptr) {
return;
@@ -354,11 +360,42 @@ void SDLCALL LogSdlMessage(void*, int category, SDL_LogPriority priority, const
(std::strstr(message, "Motion Plus") != nullptr || std::strstr(message, "Resetting report mode") != nullptr)) {
return;
}
if (category == SDL_LOG_CATEGORY_INPUT || priority >= SDL_LOG_PRIORITY_WARN) {
RT_LOG("sdl") << message << std::endl;
if (category != SDL_LOG_CATEGORY_INPUT && priority < SDL_LOG_PRIORITY_WARN) {
return;
}
if (priority < SDL_LOG_PRIORITY_WARN) {
// Device paths in these messages keep changing (/dev/hidrawN climbs with
// hotplug churn), so cap the set instead of holding one string per line
// for the whole session.
static std::unordered_set<std::string> s_seen;
if (s_seen.size() >= 256) {
s_seen.clear();
}
if (!s_seen.insert(message).second) {
return;
}
RT_LOG("sdl") << message << " (further identical messages suppressed)" << std::endl;
return;
}
RT_LOG("sdl") << message << std::endl;
}
// Whether Poll() drives its own periodic re-enumeration. The 1->0->1 hint
// flip below makes SDL close and re-open every HIDAPI device on the main
// thread, and on Linux that means an open() attempt on every /dev/hidraw node
// (each failing with EACCES until a udev rule grants access), which showed up
// as a frame hitch every scan interval even on an empty menu. It exists for
// Windows Bluetooth stacks, where a remote that drops or is switched on after
// launch is not seen again until the driver re-enumerates. Linux and macOS
// already get hotplug from udev / IOKit: SDL re-enumerates when a device
// appears, so nothing periodic is needed there. The overlay's "Rescan now"
// still works everywhere.
#if defined(_WIN32)
constexpr bool kPeriodicRescan = true;
#else
constexpr bool kPeriodicRescan = false;
#endif
// Second half of a rescan: re-enables the Wii driver once SDL has seen it off.
void FinishRescan(uint64_t now) {
if (g_driverOffSinceMs == 0 || now - g_driverOffSinceMs < kRescanDriverOffMs) {
@@ -444,18 +481,19 @@ void Poll() {
g_lastScanMs = SDL_GetTicks();
return;
}
if (!RuntimeConfigFile::WiiContinuousScanEnabled(true)) {
if (!RuntimeConfigFile::WiiContinuousScanEnabled(false)) {
g_scanning = false;
return;
}
const uint64_t now = SDL_GetTicks();
if (!g_scanning) {
RT_LOG(RT_TAG_CONFIG) << "No Wii Remote connected; scanning for one (press 1+2 on the remote)"
<< std::endl;
RT_LOG(RT_TAG_CONFIG) << "No Wii Remote connected; "
<< (kPeriodicRescan ? "scanning for one" : "waiting for one to be paired")
<< " (press 1+2 on the remote)" << std::endl;
g_scanning = true;
g_lostAtMs = now;
}
if (now - g_lostAtMs < kScanStartDelayMs) {
if (!kPeriodicRescan || now - g_lostAtMs < kScanStartDelayMs) {
return;
}
const uint64_t interval = now - g_lostAtMs < kFastScanWindowMs ? kFastScanIntervalMs : kScanIntervalMs;
@@ -470,6 +508,11 @@ bool IsScanning() {
return g_scanning;
}
// Whether looking for a remote means periodic rescans or waiting for hotplug.
bool PeriodicRescanEnabled() {
return kPeriodicRescan;
}
// Number of rescans since a Wii controller was last seen.
uint32_t ScanCount() {
return g_scanCount;
+138
View File
@@ -0,0 +1,138 @@
#include "nand_save_probe.h"
#include "nand_settings.h"
#include <algorithm>
#include <chrono>
#include <iostream>
#include <sstream>
#include <stdexcept>
namespace fs = std::filesystem;
using RuntimeNandSave::ReadAction;
using RuntimeNandSave::Contents;
static void Require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
static void Write(const fs::path& path, const std::string& bytes) {
fs::create_directories(path.parent_path());
std::ofstream output(path, std::ios::binary);
output.write(bytes.data(), bytes.size());
output.close();
Require(static_cast<bool>(output), "Fixture write failed");
}
static std::string Read(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
Require(static_cast<bool>(input), "Fixture read failed");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
// A disk error after zero-filled blocks must not look like a blank file's EOF.
class FailingDisk : public std::streambuf {
int blocks;
public:
explicit FailingDisk(int zeroBlocks) : blocks(zeroBlocks) {}
std::streamsize xsgetn(char* buffer, std::streamsize length) override {
if (blocks-- <= 0) throw std::runtime_error("injected read failure");
std::fill(buffer, buffer + length, '\0');
return length;
}
};
int main() {
const auto root = fs::temp_directory_path() / ("wiicomp-save-scenarios-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
try {
const auto save = root / "title/00010004/524d4350/data/rksys.dat";
const auto shadow = fs::path(save.native() + fs::path(".nandsafe.tmp").native());
std::string error;
Require(RuntimeNandSettings::Ensure(root, error, 1800000123), "New profile settings bootstrap");
const auto identity = Read(RuntimeNandSettings::FilePath(root));
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Fresh profile follows normal missing-file handling");
Require(!fs::exists(save), "Probing fresh profile must not create a save");
// First launch interrupted before save initialization, including block
// boundaries and a full-sized synthetic zero-filled allocation.
for (const size_t size : {size_t(0), size_t(1), size_t(4095), size_t(4096), size_t(4097), size_t(3 * 1024 * 1024)}) {
const std::string bytes(size, '\0');
Write(save, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Blank save should be offered first-save recovery");
Require(Read(save) == bytes, "Blank-save detection must not modify the file");
for (int mode : {2, 3}) {
Require(RuntimeNandSave::CheckRead(save, mode) == ReadAction::Proceed, "Write opens must remain available for initialization");
}
}
// Existing saves, imported saves, partial/corrupt saves, and a zero
// prefix with data only in the final byte are all left to the game.
std::string existing(3 * 1024 * 1024, '\0');
existing.replace(0, 8, "RKSD0006");
existing[10000] = 42;
for (const std::string& bytes : {existing, std::string("RKSD"), std::string("damaged-header"),
std::string(8192, '\0') + "x", std::string(8191, '\0') + "x"}) {
Write(save, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Never hide a save containing any data");
Require(Read(save) == bytes, "Existing/partial save must be byte-identical after inspection");
}
// Interrupted replacement: retain a committed original regardless of
// whether the shadow is blank, partial, or contains a complete header.
Write(save, existing);
for (const std::string& bytes : {std::string(), std::string(4096, '\0'), std::string("RKSD"), existing}) {
Write(shadow, bytes);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Committed original takes precedence over write shadow");
Require(Read(save) == existing && Read(shadow) == bytes, "Probe must preserve both sides of an interrupted write");
}
// No usable original: do not let missing-save recovery discard the
// only possible recovery source, and do not auto-promote that shadow.
for (const bool mainExists : {false, true}) {
fs::remove(save);
if (mainExists) Write(save, std::string(4096, '\0'));
Write(shadow, existing);
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::RecoveryNeeded, "Preserve recovery candidate when original is missing or blank");
Require(Read(shadow) == existing, "Recovery candidate must remain unchanged");
Require(fs::exists(save) == mainExists, "Do not promote shadow automatically");
}
Write(shadow, std::string(4096, '\0'));
Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Two blank files may use first-save recovery");
fs::remove(shadow);
for (const char* name : {"rksys.dat.bak", "rksys.dat.backup", "rksys.dat2", "banner.bin", "setting.txt"}) {
const auto unrelated = save.parent_path() / name;
Write(unrelated, std::string(4096, '\0'));
Require(RuntimeNandSave::CheckRead(unrelated, 1) == ReadAction::Proceed, "Do not classify backups or unrelated files as missing saves");
}
for (int blocks : {0, 1, 2}) {
FailingDisk disk(blocks);
std::istream input(&disk);
Require(RuntimeNandSave::InspectStream(input) == Contents::Error, "Read failure must remain an error, including after zero-filled blocks");
}
std::istringstream badEof;
badEof.setstate(std::ios::badbit | std::ios::eofbit);
Require(RuntimeNandSave::InspectStream(badEof) == Contents::Error, "Badbit plus EOF must not imply a blank save");
#ifdef _WIN32
Write(save, existing);
const HANDLE locked = CreateFileW(save.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Require(locked != INVALID_HANDLE_VALUE, "Could not lock fixture");
const auto lockedResult = RuntimeNandSave::CheckRead(save, 1);
CloseHandle(locked);
Require(lockedResult == ReadAction::Error, "Sharing/access failure must not report a missing save");
Require(Read(save) == existing, "Locked save must survive inspection unchanged");
Require(SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_READONLY) != 0, "Set fixture read-only");
const auto readOnlyResult = RuntimeNandSave::CheckRead(save, 1);
SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_NORMAL);
Require(readOnlyResult == ReadAction::Proceed && Read(save) == existing, "Readable read-only save remains available");
#endif
Require(RuntimeNandSettings::Ensure(root, error, 1900000123), "Existing profile settings bootstrap");
Require(Read(RuntimeNandSettings::FilePath(root)) == identity, "Save recovery must not change console identity");
fs::remove_all(root);
std::cout << "NAND save startup, preservation, interrupted-write and I/O failure scenarios passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << " (fixtures retained at " << root << ")\n";
return 1;
}
}
+151
View File
@@ -0,0 +1,151 @@
#include "nand_settings.h"
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
static void Require(bool condition, const char* message = "NAND settings check failed") {
if (!condition) {
throw std::runtime_error(message);
}
}
static std::string ReadBytes(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
int main() {
const auto root = std::filesystem::temp_directory_path() /
("wiicomp-nand-settings-" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
const auto path = root / "title/00000001/00000002/data/setting.txt";
try {
using namespace RuntimeNandSettings;
Require(GenerateSerial(1800000123) == "800000123", "Dolphin timestamp modulo");
Require(GenerateSerial(1000000001) == "000000001", "Dolphin leading zero padding");
Require(GenerateSerial(-1).empty(), "Invalid clock must not supply an identity");
// Golden bytes generated by Dolphin's unmodified SettingsHandler.cpp
// (upstream 2026-09-06), PAL boot fields and synthetic serial 000000001.
// Everything after this prefix is raw zero padding to 256 bytes.
const std::string goldenHex =
"bba6ac929a0bc96b7eed83d27f33a1e7e73d9b836d8b47c59ee23df6b275baab"
"bec9d9dead03cc7a3bdafee50c30ab9fb86194e119fe4ba19eff62d5ec3aacb3"
"b5c9d9e3977eac0943d7ff903120a49ef024eafe1cf77be79cf6229a823aabf0f0";
std::array<uint8_t, 256> golden{};
for (size_t i = 0; i < goldenHex.size() / 2; ++i) {
golden[i] = static_cast<uint8_t>(std::stoul(goldenHex.substr(i * 2, 2), nullptr, 16));
}
Require(EncodeNew("000000001") == golden, "Exact Dolphin writer golden fixture");
std::string error;
Require(!RuntimeNandSettings::Read(root));
Require(!std::filesystem::exists(root));
std::filesystem::create_directories(path.parent_path());
const std::string plain = "AREA=USA\r\n\nCODE=LU\r\nSERNO=987654321\r\nGAME=US\r\n";
std::array<uint8_t, 256> fixture{};
for (size_t i = 0; i < fixture.size(); ++i) {
const unsigned shift = i % 32;
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
fixture[i] = static_cast<uint8_t>(key) ^ (i < plain.size() ? plain[i] : 0);
}
{
std::ofstream output(path, std::ios::binary);
output.write(reinterpret_cast<const char*>(fixture.data()), fixture.size());
}
auto settings = RuntimeNandSettings::Read(root);
Require(settings && RuntimeNandSettings::HasIdentity(*settings));
Require(settings->at("SERNO") == "987654321" && settings->at("CODE") == "LU");
Require(settings->at("AREA") == "USA" && settings->at("GAME") == "US");
Require(Ensure(root, error, 1800000123), "Existing imported NAND must work");
std::array<uint8_t, 256> after{};
{
std::ifstream input(path, std::ios::binary);
input.read(reinterpret_cast<char*>(after.data()), after.size());
}
Require(after == fixture);
for (const auto serial : {"", "000000000", "1234567890", "123ABC789"}) {
(*settings)["SERNO"] = serial;
Require(!RuntimeNandSettings::HasIdentity(*settings));
}
(*settings)["SERNO"] = "012345678";
Require(RuntimeNandSettings::HasIdentity(*settings));
(*settings)["CODE"] = "TOOLONG";
Require(!RuntimeNandSettings::HasIdentity(*settings));
(*settings)["CODE"] = "LEH";
settings->erase("GAME");
Require(!RuntimeNandSettings::HasIdentity(*settings));
std::filesystem::resize_file(path, 128);
Require(!RuntimeNandSettings::Read(root));
const auto damaged = ReadBytes(path);
Require(!Ensure(root, error, 1800000123), "Do not replace a truncated identity");
Require(ReadBytes(path) == damaged, "Damaged file must remain untouched");
const auto fresh = root / "fresh";
Require(Ensure(fresh, error, 1800000123), "Missing setting.txt must initialize");
const auto generated = Read(fresh);
Require(generated && HasIdentity(*generated), "Generated file must be readable");
Require(generated->at("SERNO") == "800000123", "Persist Dolphin-generated serial");
Require(generated->at("CODE") == "LEH" && generated->at("AREA") == "EUR" &&
generated->at("GAME") == "EU", "PAL first-boot fields");
Require(generated->at("MODEL") == "RVL-001(EUR)" && generated->at("VIDEO") == "PAL" &&
generated->at("DVD") == "0" && generated->at("MPCH") == "0x7FFE",
"Complete Dolphin boot settings");
const auto firstBoot = ReadBytes(FilePath(fresh));
Require(firstBoot.size() == 256 && firstBoot.back() == 0, "Dolphin buffer size and raw zero padding");
Require(Ensure(fresh, error, 1900000999), "Second boot");
Require(ReadBytes(FilePath(fresh)) == firstBoot, "Second boot must not change any bytes");
const auto blocked = root / "blocked";
{ std::ofstream output(blocked); output << "file obstructing NAND directory"; }
Require(!Ensure(blocked, error, 1800000123), "Write failure must not return an ephemeral identity");
Require(!Ensure(root / "bad-clock", error, -1), "Clock failure must not initialize");
const auto concurrent = root / "concurrent";
std::array<bool, 16> results{};
std::vector<std::thread> workers;
for (size_t i = 0; i < results.size(); ++i) {
workers.emplace_back([&, i] {
std::string detail;
results[i] = Ensure(concurrent, detail, 1800000001 + i);
});
}
for (auto& worker : workers) worker.join();
for (const bool result : results) Require(result, "Concurrent boot must read the persisted winner");
const auto winner = ReadBytes(FilePath(concurrent));
Require(Read(concurrent) && HasIdentity(*Read(concurrent)), "Concurrent boot must persist valid settings");
Require(Ensure(concurrent, error, 1900000999), "Boot after concurrent initialization");
Require(ReadBytes(FilePath(concurrent)) == winner, "Concurrent winner must remain stable");
// Independently decode as Nintendo does: stop at the first encoded NUL.
// Exercise serials that force Dolphin's extra-LF escaping, not only
// values that happen to work with a plain rotating-XOR encoder.
bool sawExtraLf = false;
for (int serial = 1; serial <= 10000; ++serial) {
const auto number = GenerateSerial(1000000000 + serial);
const auto encoded = EncodeNew(number);
Require(encoded.has_value(), "Serial encoding must fit");
std::string decoded;
for (size_t i = 0; i < encoded->size() && (*encoded)[i] != 0; ++i) {
const unsigned shift = i % 32;
const uint32_t key = shift == 0 ? 0x73B5DBFAu :
(0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift));
decoded += static_cast<char>((*encoded)[i] ^ static_cast<uint8_t>(key));
}
Require(decoded.find("SERNO=" + number + "\r\n") != std::string::npos &&
decoded.find("GAME=EU\r\n") != std::string::npos,
"Encoded NUL must not truncate settings");
sawExtraLf |= decoded.find("\r\n\n") != std::string::npos;
}
Require(sawExtraLf, "Exercise Dolphin LF escape path");
std::filesystem::remove_all(root);
std::cout << "NAND settings checks passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(root);
std::cerr << error.what() << '\n';
return 1;
}
}
+219
View File
@@ -0,0 +1,219 @@
// Verifies the expression engine against Dolphin's documented semantics,
// including the exact line from the user's GCPadNew.ini.
#include "input_expr.h"
#include <chrono>
#include <cmath>
#include <cstdio>
#include <map>
#include <string>
#include <thread>
static int g_failures = 0;
static std::map<std::string, double> g_inputs;
static InputExpr::InputSource Source() {
return [](const std::string& name) {
const auto it = g_inputs.find(name);
return it == g_inputs.end() ? 0.0 : it->second;
};
}
static void Check(bool ok, const std::string& what) {
if (!ok) {
std::printf(" FAIL: %s\n", what.c_str());
++g_failures;
}
}
static InputExpr::Expression Compile(const std::string& text) {
InputExpr::Expression expr;
std::string error;
if (!InputExpr::Expression::Parse(text, expr, error)) {
std::printf(" FAIL: parse '%s': %s\n", text.c_str(), error.c_str());
++g_failures;
}
return expr;
}
static bool Pressed(const InputExpr::Expression& e) {
return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
}
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
int main() {
std::printf("Dolphin expression engine\n");
// Operators: & is min, | is max, ! is 1-x, matching Dolphin.
g_inputs["A"] = 1.0;
g_inputs["B"] = 0.0;
Check(Pressed(Compile("`A`")), "bare input");
Check(!Pressed(Compile("!`A`")), "not");
Check(!Pressed(Compile("`A` & `B`")), "and is min");
Check(Pressed(Compile("`A` | `B`")), "or is max");
Check(Pressed(Compile("`A` ^ `B`")), "xor");
Check(!Pressed(Compile("`A` ^ `A`")), "xor of equal inputs is false");
// Precedence: & binds tighter than |, so this is A | (B & A).
g_inputs["B"] = 0.0;
Check(Pressed(Compile("`A` | `B` & `A`")), "& binds tighter than |");
// Parens and numeric literals.
Check(Pressed(Compile("(`B` | 1)")), "literal");
Check(Pressed(Compile("min(1, `A`)")), "min");
Check(!Pressed(Compile("min(0, `A`)")), "min with zero");
Check(Pressed(Compile("if(`A`, 1, 0)")), "if");
Check(Pressed(Compile("clamp(5, 0, 1)")), "clamp");
// toggle flips on each rising edge and holds between them.
auto toggle = Compile("toggle(`T`)");
g_inputs["T"] = 0.0;
toggle.Evaluate(Source());
g_inputs["T"] = 1.0;
Check(Pressed(toggle), "toggle on after first press");
g_inputs["T"] = 0.0;
Check(Pressed(toggle), "toggle stays on after release");
g_inputs["T"] = 1.0;
Check(!Pressed(toggle), "toggle off on second press");
// hold requires the input to be down for the full duration.
auto hold = Compile("hold(`H`, 0.05)");
g_inputs["H"] = 1.0;
Check(!Pressed(hold), "hold not satisfied immediately");
Sleep(70);
Check(Pressed(hold), "hold satisfied after the interval");
g_inputs["H"] = 0.0;
Check(!Pressed(hold), "hold clears on release");
// pulse fires for the given duration after a rising edge.
auto pulse = Compile("pulse(`P`, 0.05)");
g_inputs["P"] = 0.0;
pulse.Evaluate(Source());
g_inputs["P"] = 1.0;
Check(Pressed(pulse), "pulse fires on rising edge");
Sleep(80);
Check(!Pressed(pulse), "pulse expires");
// The timing-window idiom seen in shared Dolphin configs.
auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)");
g_inputs["W"] = 0.0;
window.Evaluate(Source());
g_inputs["W"] = 1.0;
Check(!Pressed(window), "window closed before its start");
Sleep(90);
Check(Pressed(window), "window open between the two pulses");
Sleep(90);
Check(!Pressed(window), "window closed after its end");
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
auto timer = Compile("`X` & timer(0.1)");
g_inputs["X"] = 1.0;
int high = 0;
int low = 0;
for (int i = 0; i < 40; ++i) {
(Pressed(timer) ? high : low)++;
Sleep(5);
}
Check(high > 5 && low > 5, "timer alternates high and low");
// The exact D-Pad/Up line from the user's GCPadNew.ini.
auto dolphinLine = Compile("`Hat 0 N` | `Button 4` & timer(0.01)");
g_inputs["Hat 0 N"] = 0.0;
g_inputs["Button 4"] = 0.0;
Check(!Pressed(dolphinLine), "idle with nothing held");
g_inputs["Hat 0 N"] = 1.0;
Check(Pressed(dolphinLine), "hat alone presses");
g_inputs["Hat 0 N"] = 0.0;
g_inputs["Button 4"] = 1.0;
high = low = 0;
for (int i = 0; i < 60; ++i) {
(Pressed(dolphinLine) ? high : low)++;
Sleep(2);
}
Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
// Regression tests for the CodeRabbit findings on PR #89.
g_inputs["A"] = 1.0;
// clamp with reversed bounds: std::clamp is UB when lo > hi.
Check(Compile("clamp(0.5, 1, 0)").Evaluate(Source()) == 0.5, "clamp tolerates reversed bounds");
// deadzone(v, 1) would divide by zero.
{
const double v = Compile("deadzone(`A`, 1)").Evaluate(Source());
Check(std::isfinite(v), "deadzone with dz=1 stays finite");
}
// timer with a zero or negative period would produce inf or NaN.
for (const char* text : {"timer(0)", "timer(-1)"}) {
const double v = Compile(text).Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " stays finite");
}
// Any non-finite result is squashed before it can reach the uint8_t cast.
for (const char* text : {"sqrt(0 - 1)", "pow(10, 10000)", "tan(1.5707963267948966)"}) {
const double v = Compile(text).Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " is sanitised at the boundary");
}
// tap count is user authored; negative, huge and non-finite must not reach
// the unsigned conversion.
for (const char* text : {"tap(`A`, 0.2, -1)", "tap(`A`, 0.2, 999999999)", "tap(`A`, 0.2, 0)"}) {
InputExpr::Expression e;
std::string err;
Check(InputExpr::Expression::Parse(text, e, err), std::string("parse ") + text);
const double v = e.Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " evaluates without UB");
}
// Exponent notation is not part of the number syntax, matching Dolphin's
// lexer; it is rejected rather than silently misparsed.
{
InputExpr::Expression e;
std::string err;
Check(!InputExpr::Expression::Parse("tap(`A`, 0.2, 1e30)", e, err), "exponent notation rejected");
}
// A zero divisor must not skip the left subtree: stateful functions there
// still need their per-frame update.
{
auto divToggle = Compile("toggle(`D`) / `Z`");
g_inputs["Z"] = 0.0;
g_inputs["D"] = 0.0;
divToggle.Evaluate(Source());
g_inputs["D"] = 1.0;
divToggle.Evaluate(Source()); // rising edge seen even though rhs is 0
g_inputs["D"] = 0.0;
g_inputs["Z"] = 1.0;
Check(divToggle.Evaluate(Source()) > InputExpr::kConditionThreshold,
"toggle still latched while the divisor was zero");
}
// smooth with a zero rate divides 0 by 0; NaN must not stick in the node.
{
auto sm = Compile("smooth(`A`, 0)");
g_inputs["A"] = 1.0;
sm.Evaluate(Source());
Sleep(5);
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
}
// Referenced inputs, used for diagnostics in the UI.
const auto refs = dolphinLine.ReferencedInputs();
Check(refs.size() == 2, "two referenced inputs");
// Errors are reported, not silently swallowed.
InputExpr::Expression bad;
std::string error;
Check(!InputExpr::Expression::Parse("`A` & ", bad, error), "trailing operator rejected");
Check(!InputExpr::Expression::Parse("nope(1)", bad, error), "unknown function rejected");
Check(!InputExpr::Expression::Parse("(`A`", bad, error), "missing paren rejected");
Check(!InputExpr::Expression::Parse("`A", bad, error), "unterminated backtick rejected");
Check(InputExpr::Expression::Parse("", bad, error) && bad.Empty(), "empty parses to empty");
Check(!InputExpr::Expression::Parse("hold(`A`)", bad, error), "wrong arg count rejected");
if (g_failures == 0) {
std::printf("all checks passed\n");
return 0;
}
std::printf("%d check(s) failed\n", g_failures);
return 1;
}