26 Commits

Author SHA1 Message Date
patchzyy d4c53dad4b Use a deterministic clock for input expression tests 2026-09-07 23:17:14 +02:00
patchzyy e05e15bce5 Implement Linux executable directory lookup 2026-09-07 23:14:51 +02:00
patchzyy 9066cb3560 Run the shared C++ runtime test suite through CTest 2026-09-07 23:12:18 +02:00
patchzyy 05d6a0b1ab Report NAND move destination conflicts correctly 2026-09-07 23:03:41 +02:00
patchzyy afb8f49866 Keep NAND moves internal and prevent destination overwrite races 2026-09-07 22:50:13 +02:00
patchzyy 58bfd32022 Fix NAND file moves across filesystems 2026-09-07 22:33:52 +02:00
Wubbzee a135beb201 Reduce CI time using caching (#180)
* caching a little bit

* provide CMake with the explicit path to sccache.exe

* map ACTIONS_RESULTS_URL to ACTIONS_CACHE_URL so sccache can upload the
files...

* i removed the parallel oops

* small change

* doing a little bit of flag editing

* update sccache and cache nuget stuff
2026-09-07 09:18:53 +02:00
patchzyy 88b990b060 update 2026-09-06 15:38:14 +02:00
patchzyy e0e362bd99 csnum fix 2026-09-06 15:28:56 +02:00
patchzyy 5654d8f21b Merge branch 'main' of https://github.com/patchzyy/Wiicompiled 2026-09-06 11:23:13 +02:00
patchzyy 730e3122d5 version 0.2.30 2026-09-06 11:22:57 +02:00
Wubbzee f424536d3b Treat empty MKW save as missing rather than corrupt (#168)
* 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.

* shorten

* I dont really want to change this to be honest.

* extra safety

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-09-06 11:20:51 +02:00
patchzyy 2d9dc4e0f2 import setting.txt (#169)
* import setting.txt

* coderabbit ugh
2026-09-06 11:14:54 +02: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
50 changed files with 3216 additions and 305 deletions
+31
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
@@ -26,6 +30,14 @@ jobs:
with:
dotnet-version: '8.0.x'
- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('translator/Translator.sln', '**/*.csproj', '**/*.props', '**/*.targets', '**/packages.lock.json', 'global.json', 'NuGet.config', 'nuget.config') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore translator/Translator.sln
@@ -34,3 +46,22 @@ jobs:
- name: Test
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
cpp:
name: C++ runtime tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-14]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Configure
run: cmake -S runtime/tests -B build/tests -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build/tests --config Release --parallel
- name: Test
run: ctest --test-dir build/tests -C Release --output-on-failure --no-tests=error
+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
+61
View File
@@ -0,0 +1,61 @@
name: Synthetic recompilation
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
name: Windows runtime (synthetic DOL)
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
# Cache downloads only. Preparation still validates pins, and every run
# compiles current Aurora, runtime, and generated sources from scratch.
- uses: actions/cache@v5
with:
path: Launcher/artifacts/downloads
key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }}
# Install sccache.
- name: Run sccache-action
uses: mozilla/sccache-action@v0.0.11
# Tell CMake to use sccache and use GitHub's API.
- name: Configure sccache environment
shell: pwsh
run: |
"SCCACHE_GHA_ENABLED=true" | Add-Content -Path $env:GITHUB_ENV
"ACTIONS_CACHE_SERVICE_V2=on" | Add-Content -Path $env:GITHUB_ENV
"CMAKE_C_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
"CMAKE_CXX_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV
- name: Prepare the shipped Windows toolchain
shell: pwsh
run: ./Launcher/Prepare-PortableTools.ps1
- name: Prepare pinned native dependencies
shell: pwsh
run: ./Launcher/Prepare-Dependencies.ps1
# Expose cache token context to the build script.
- name: Translate, compile the full runtime, and link
shell: pwsh
run: ./Launcher/Test-Recompilation.ps1 -Parallel 4
# Print cache results (even if the build fails)
- name: Show sccache stats
if: always()
shell: pwsh
run: sccache --show-stats
+4
View File
@@ -26,6 +26,8 @@ Code.pul
/build/
/build-*/
/native-build/
/native-build-macos/
/local-products/
/dist/
/out/
[Bb]in/
@@ -70,3 +72,5 @@ project.lock.json
*.log
output.txt
# Operating System
.DS_Store
+1 -1
View File
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
ProductVersion = '0.2.27'
ProductVersion = '0.2.31'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
+14 -8
View File
@@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) {
}
function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') {
[string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) {
<#
Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait
is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process
doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
Runs a build tool and turns a non-zero exit code into a described failure. By default,
Start-Process -Wait waits for the whole process tree, since a .NET single-file bundle host may
hand off to an extracted child that PowerShell's call operator would not wait for. Callers that
need to avoid waiting on unrelated descendants can opt into the call-operator path.
Start-Process doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
-StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in
WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line.
#>
@@ -132,9 +133,14 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri
if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
'"' + $_ + '"'
})
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
-NoNewWindow -Wait -PassThru
$exitCode = $process.ExitCode
if ($WaitForProcessTree) {
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
-NoNewWindow -Wait -PassThru
$exitCode = $process.ExitCode
} else {
& $FilePath @Arguments
$exitCode = $LASTEXITCODE
}
$global:LASTEXITCODE = $exitCode
if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
}
+1 -1
View File
@@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja"
assert_file "$cc" "Portable C compiler"
assert_file "$cxx" "Portable C++ compiler"
assert_dir "$aurora_source" "aurora-main source tree"
clang_binary=$(normalize "$toolchain_dir/bin/clang-23")
clang_binary=$(normalize "$toolchain_dir/bin/clang-22")
assert_file "$clang_binary" "Portable clang driver binary"
(( parallel > 0 )) || parallel=$(nproc)
+148
View File
@@ -0,0 +1,148 @@
# Build the real Windows runtime with translated, entirely synthetic PowerPC code.
# No game dump, game symbol map, REL, mod download, or existing generated/ output is used.
[CmdletBinding()]
param(
[string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
[string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
[string]$StageDirectory = 'build/recomp-test',
[ValidateRange(1, 64)] [int]$Parallel = 3
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
function Full([string]$Path) {
if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
$portableTools = Full $PortableToolsDirectory
$dependencies = Full $DependencySourceDirectory
$stage = Full $StageDirectory
$dotnet = (Get-Command dotnet -CommandType Application).Source
$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe'
$ninja = Join-Path $portableTools 'Ninja/ninja.exe'
$compilerBin = Join-Path $portableTools 'llvm-mingw/bin'
Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)'
Assert-File $ninja 'Pinned Ninja'
Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)'
# Refuse reuse so a developer's game translation or an earlier build cannot make
# the test pass. Keep the staging tree after the run for diagnostics.
if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" }
[IO.Directory]::CreateDirectory($stage) | Out-Null
Write-Host "Synthetic recompilation workspace: $stage"
# Copy current sources, including uncommitted edits, but no ignored build output.
# An isolated workspace preserves the developer's real generated/ directory.
$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main
if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' }
foreach ($relative in $sourceFiles | Sort-Object -Unique) {
$destination = Join-Path $stage $relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination
}
# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr.
# Native HLE wrappers also call these eight guest symbols directly. Give each
# its own generated blr function so the real product can link without game code.
# Keep this list explicit: a new unresolved guest dependency must fail the test.
[uint32]$entry = 0x80001000L
[uint32[]]$guestCallbacks = @(
0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL,
0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL
)
$textSize = [int]($guestCallbacks[-1] - $entry + 4)
$dataOffset = 0x100 + $textSize
$dol = [byte[]]::new($dataOffset + 4)
function Write-BigEndian32([int]$Offset, [uint32]$Value) {
$dol[$Offset] = [byte](($Value -shr 24) -band 255)
$dol[$Offset + 1] = [byte](($Value -shr 16) -band 255)
$dol[$Offset + 2] = [byte](($Value -shr 8) -band 255)
$dol[$Offset + 3] = [byte]($Value -band 255)
}
Write-BigEndian32 0x00 0x100 # text[0] file offset
Write-BigEndian32 0x48 $entry # text[0] guest address
Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero)
Write-BigEndian32 0x1C $dataOffset # data[0] file offset
Write-BigEndian32 0x64 0x80600000L # data[0] guest address
Write-BigEndian32 0xAC 4 # data[0] length
Write-BigEndian32 0xD8 0x80601000L # BSS address
Write-BigEndian32 0xDC 32 # BSS length
Write-BigEndian32 0xE0 $entry # entry point
Write-BigEndian32 0x100 0x38600028 # li r3,40
Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2
Write-BigEndian32 0x108 0x60000000 # nop
Write-BigEndian32 0x10C 0x4E800020 # blr
foreach ($address in $guestCallbacks) {
Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020
}
Write-BigEndian32 $dataOffset 0x12345678
[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol)
$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', '
$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n"
[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap)
$manifest = Join-Path $stage 'recomp.yml'
[IO.File]::WriteAllText($manifest, @"
schema_version: 1
workspace_root: .
project:
id: ci-synthetic-dol
display_name: CI Synthetic DOL
memory:
base: 0x80000000
size: 0x01800000
sda_base: 0x80600000
sda2_base: 0x80600000
inputs:
dol:
path: synthetic.dol
translation:
entry_points: [$entryPoints]
function_map:
path: synthetic-functions.txt
allow_unsupported_instructions: false
runtime:
native_abi_directories: []
native_registration_root: runtime/src
output:
root: generated
"@)
$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj'
Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator'
$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll'
$metadata = Join-Path $stage 'generated/base_translation_output.json'
Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest,
'--output-metadata', $metadata, '--threads', "$Parallel") `
'Translating the synthetic DOL'
# Function-map seeds can be skipped by discovery; do not accept a partial fixture.
$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json
foreach ($address in @($entry) + $guestCallbacks) {
if ($address -notin $translated.functions.entryPoint) {
throw ('Synthetic function 0x{0:X8} was not translated.' -f $address)
}
}
Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration'
Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph'
$nativeBuild = Join-Path $stage 'native-build'
$oldPath = $env:PATH
try {
$env:PATH = Get-MkwToolchainPath $portableTools
$configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild `
-Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') `
-CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') `
-ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') `
-DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON')
Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' `
-WaitForProcessTree $false
Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") `
'Compiling and linking the synthetic product with the full runtime' `
-WaitForProcessTree $false
Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product'
} finally {
$env:PATH = $oldPath
}
Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).'
@@ -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.31</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.31</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.31";
}
/// <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.31</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.27";
public const string Version = "0.2.31";
/// <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.27</Version>
<Version>0.2.31</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
+19 -19
View File
@@ -25,7 +25,7 @@ set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
workspace=$(cd "$script_dir/.." && pwd)
llvm_version=23.1.0
llvm_version=22.1.8
cmake_version=4.3.3
ninja_version=1.13.2
destination="$script_dir/artifacts/portable-tools"
@@ -52,13 +52,13 @@ done
case "$arch" in
x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu
llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c
llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384
cmake_release_arch=x86_64
cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802
ninja_asset=ninja-linux.zip
ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;;
aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu
llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4
llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34
cmake_release_arch=aarch64
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
ninja_asset=ninja-linux-aarch64.zip
@@ -120,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain..
# clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke.
# Stripped: debug symbols are dead weight for a bundled compiler nobody will debug.
cp -a "$src/bin/clang-23" "$work/bin/"
strip "$work/bin/clang-23"
ln -s clang-23 "$work/bin/clang"
cp -a "$src/bin/clang-22" "$work/bin/"
strip "$work/bin/clang-22"
ln -s clang-22 "$work/bin/clang"
ln -s clang "$work/bin/clang++"
# lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first -
@@ -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;
+1 -47
View File
@@ -269,54 +269,8 @@ target_include_directories(mkw_platform PUBLIC "${CMAKE_CURRENT_LIST_DIR}/includ
target_compile_features(mkw_platform PUBLIC cxx_std_17)
set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
# project's host-platform contracts, not Aurora's third-party test suite.
enable_testing()
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp")
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)
# 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.
if(MKW_PLATFORM_LINUX)
add_executable(mkw_linux_host_context_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp")
target_include_directories(mkw_linux_host_context_tests PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_LIST_DIR}/third_party/libco")
target_compile_features(mkw_linux_host_context_tests PRIVATE cxx_std_17)
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
add_test(NAME mkw_linux_host_context_tests COMMAND mkw_linux_host_context_tests)
endif()
if(MKW_PLATFORM_MACOS)
# Exercise the Apple Silicon context ABI and the public host-memory
# contracts separately from translated products.
enable_language(ASM)
add_executable(mkw_macos_context_abi_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests)
add_executable(mkw_macos_host_context_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_macos_host_context_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_host_context_tests COMMAND mkw_macos_host_context_tests)
add_executable(mkw_macos_guest_flat_memory_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_guest_flat_memory_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp")
target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests)
endif()
add_subdirectory(tests)
# The translator emits the complete, content-addressed source graph. Consuming
# this one manifest keeps configure independent of the 28k generated function
+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
+220
View File
@@ -0,0 +1,220 @@
#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.
}
// Atomically claim our own scratch directory. A collision belongs to another
// launch (or a previous crashed launch); leave it untouched and try another name.
inline std::optional<std::filesystem::path> CreateScratchDirectory(
const std::filesystem::path& parent, const std::string& token, std::error_code& ec) {
for (unsigned attempt = 0; attempt < 128; ++attempt) {
const auto candidate = parent / (".setting-init-" + token + "-" + std::to_string(attempt));
ec.clear();
if (std::filesystem::create_directory(candidate, ec)) return candidate;
if (ec && ec != std::errc::file_exists) return std::nullopt;
}
ec = std::make_error_code(std::errc::file_exists);
return std::nullopt;
}
// 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};
#ifdef _WIN32
const auto processId = GetCurrentProcessId();
#else
const auto processId = getpid();
#endif
const auto scratch = CreateScratchDirectory(path.parent_path(),
std::to_string(processId) + "-" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()) + "-" +
std::to_string(sequence++), ec);
if (!scratch) {
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
+33
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;
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <charconv>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <system_error>
namespace RuntimeScSerial {
// SCGetProductSN's output is a u32, not a character buffer. DWC loads
// that word and formats it with the product code to construct csnum.
template <typename RangeValidator, typename WordWriter>
uint32_t Write(std::string_view serial, uint32_t address,
RangeValidator&& contains, WordWriter&& write32) {
if (serial.empty() || serial.size() > 9 ||
serial.find_first_not_of("0123456789") != std::string_view::npos) return 0;
uint32_t number = 0;
const auto parsed = std::from_chars(serial.data(), serial.data() + serial.size(), number);
if (parsed.ec != std::errc{} || parsed.ptr != serial.data() + serial.size() ||
!address || !contains(address, sizeof(uint32_t))) return 0;
write32(address, number);
return 1;
}
} // namespace RuntimeScSerial
+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);
+33 -21
View File
@@ -1,6 +1,7 @@
#include "hle_stubs.h"
#include "console_identity.h"
#include "sc_serial_contract.h"
#include <cstdlib>
#include <cstddef>
#include <cstdint>
@@ -12,7 +13,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 +69,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 +83,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;
}
@@ -82,21 +98,17 @@ PPC_NATIVE_OVERRIDE(801B2424, SCGetProductCode_HLE, uint32_t, (), ());
extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress)
{
const std::string& serial = RuntimeConsoleIdentity::Current().serial;
if (!serialAddress || !Memory::Contains(serialAddress, serial.size() + 1)) {
return 0;
}
std::memcpy(Memory::GetPointer(serialAddress, serial.size() + 1),
serial.c_str(), serial.size() + 1);
return 1;
return RuntimeScSerial::Write(serial, serialAddress,
[](uint32_t address, size_t size) { return Memory::Contains(address, size); },
[](uint32_t address, uint32_t value) { Memory::Write32(address, value); });
}
PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress));
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, (), ());
+9 -1
View File
@@ -3,6 +3,7 @@
// Shared state and helpers live in nand_internal.h.
#include "nand_internal.h"
#include "nand_file_ops.h"
// ============================================================================
// Local helpers
@@ -93,6 +94,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.
@@ -375,11 +379,15 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
}
std::error_code ec;
std::filesystem::rename(srcHost, dstHost, ec);
NandMove(srcHost, dstHost, ec);
if (!ec) {
return NAND_RESULT_OK;
}
if (ec == std::errc::file_exists) {
return NAND_RESULT_EXISTS;
}
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
return NAND_RESULT_UNKNOWN;
}
+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");
+115
View File
@@ -0,0 +1,115 @@
#include "nand_file_ops.h"
#include <atomic>
#include <cerrno>
#include <chrono>
#include <filesystem>
#include <string>
#include <system_error>
#ifdef _WIN32
#include <windows.h>
#elif defined(__linux__)
#include <fcntl.h>
#include <linux/fs.h>
#include <sys/syscall.h>
#include <unistd.h>
#elif defined(__APPLE__)
#include <stdio.h>
#endif
namespace {
// Unlike std::filesystem::rename on POSIX, this cannot overwrite a destination
// created by another writer between checking it and publishing the move.
void RenameNoReplace(const std::filesystem::path& source,
const std::filesystem::path& destination, std::error_code& ec) {
#ifdef _WIN32
if (MoveFileExW(source.c_str(), destination.c_str(), 0)) ec.clear();
else ec = std::error_code(GetLastError(), std::system_category());
#else
#ifdef __linux__
const auto result = syscall(SYS_renameat2, AT_FDCWD, source.c_str(),
AT_FDCWD, destination.c_str(), RENAME_NOREPLACE);
#else
const auto result = renamex_np(source.c_str(), destination.c_str(), RENAME_EXCL);
#endif
if (result == 0) {
ec.clear();
return;
}
ec = std::error_code(errno, std::generic_category());
if (ec != std::errc::function_not_supported && ec != std::errc::invalid_argument &&
ec != std::errc::operation_not_supported) return;
// Older filesystems may lack exclusive rename. Linking also publishes
// without replacement; never fall back to an overwriting rename.
std::filesystem::create_hard_link(source, destination, ec);
if (!ec) std::filesystem::remove(source, ec);
#endif
}
struct StagedMove {
std::filesystem::path directory;
std::filesystem::path file;
~StagedMove() {
std::error_code ignored;
std::filesystem::remove(file, ignored);
std::filesystem::remove(directory, ignored);
}
};
} // namespace
// NANDMove does not replace an existing entry. Riivolution save redirects can
// put the destination on a different filesystem from the guest's /tmp files.
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
std::error_code& ec) {
namespace fs = std::filesystem;
const auto status = fs::symlink_status(destination, ec);
if (ec && ec != std::errc::no_such_file_or_directory) return;
ec.clear();
if (fs::exists(status)) {
ec = std::make_error_code(std::errc::file_exists);
return;
}
RenameNoReplace(source, destination, ec);
if (ec != std::errc::cross_device_link) return;
// Do not turn a directory move into a partially completed recursive copy,
// or follow a symlink and delete the link after copying its target.
const auto sourceStatus = fs::symlink_status(source, ec);
if (ec) return;
if (!fs::is_regular_file(sourceStatus)) {
ec = std::make_error_code(std::errc::cross_device_link);
return;
}
static std::atomic<unsigned long long> sequence{0};
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
fs::path stagingDirectory;
bool created = false;
for (int attempt = 0; attempt < 64; ++attempt) {
stagingDirectory = destination.parent_path() /
(".nand-move-" + std::to_string(stamp) + "-" + std::to_string(sequence++));
created = fs::create_directory(stagingDirectory, ec);
if (created) break;
if (ec && ec != std::errc::file_exists) return;
}
if (!created) {
ec = std::make_error_code(std::errc::file_exists);
return;
}
const StagedMove staging{stagingDirectory, stagingDirectory / "data"};
const auto& staged = staging.file;
fs::copy_file(source, staged, fs::copy_options::none, ec);
if (!ec) {
// Publication is a same-filesystem rename: readers never see a partial
// copy. Keep the source until the complete destination is in place.
RenameNoReplace(staged, destination, ec);
if (!ec) fs::remove(source, ec);
}
// A failed source removal leaves both complete copies and reports failure.
// Staging cleanup preserves ec, including on exceptions.
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <filesystem>
#include <system_error>
// Move without replacing an existing entry. Cross-filesystem regular files are
// staged at the destination before removing the source. On failure the source
// remains; a failed source removal can leave both complete copies.
// The caller must keep the source stable for the duration of the move; NAND HLE
// calls run synchronously on the cooperative guest thread without yielding.
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
std::error_code& ec);
+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
+644
View File
@@ -0,0 +1,644 @@
#include "input_expr.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <unordered_map>
namespace InputExpr {
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
// Only the standalone test target supplies this clock; runtime builds use the
// steady clock directly, with no mutable override or extra runtime state.
std::chrono::steady_clock::time_point TestClockNow();
#endif
namespace {
using Clock = std::chrono::steady_clock;
using FSec = std::chrono::duration<double>;
static Clock::time_point Now() {
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
return TestClockNow();
#else
return Clock::now();
#endif
}
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 = 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 = 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 = 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 = 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 = 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 = 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
+7
View File
@@ -46,6 +46,13 @@ std::optional<std::filesystem::path> ExecutableDirectory() noexcept {
std::error_code ec;
const auto resolved = std::filesystem::weakly_canonical(path, ec);
return (ec ? std::filesystem::path(path) : resolved).parent_path();
#elif defined(__linux__)
std::error_code ec;
const auto executable = std::filesystem::read_symlink("/proc/self/exe", ec);
if (ec) {
return std::nullopt;
}
return executable.parent_path();
#else
return std::nullopt;
#endif
+134 -86
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) {
@@ -393,10 +334,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
}
ImGui::EndDisabled();
ImGui::SameLine();
if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) {
if (WiiRemoteInput::IsScanning()) {
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
} else if (WiiRemoteInput::IsScanning()) {
ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote");
} else {
ImGui::TextDisabled("Not scanning");
}
@@ -457,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);
@@ -545,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");
@@ -640,6 +681,8 @@ void DrawControllerSettings() {
ImGui::TextUnformatted(kControllerButtons[i].label);
ImGui::PopID();
}
DrawExpressionSettings();
DrawRumbleSettings();
}
void DrawAudioSettings() {
@@ -994,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);
@@ -1014,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 {
@@ -1056,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();
}
+64
View File
@@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.16)
# Share the same test registrations with the full runtime build, while allowing
# CI and developers to run them without translated game files or Aurora.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
project(WiiCompiledRuntimeTests LANGUAGES C CXX)
enable_testing()
endif()
get_filename_component(runtime_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
find_package(Threads REQUIRED)
function(mkw_add_test name)
add_executable(${name} ${ARGN})
target_include_directories(${name} PRIVATE "${runtime_root}/include")
target_compile_features(${name} PRIVATE cxx_std_17)
target_link_libraries(${name} PRIVATE Threads::Threads)
add_test(NAME ${name} COMMAND ${name})
endfunction()
if(NOT TARGET mkw_platform)
add_library(mkw_platform STATIC "${runtime_root}/src/platform/host_platform.cpp")
target_include_directories(mkw_platform PUBLIC "${runtime_root}/include")
target_compile_features(mkw_platform PUBLIC cxx_std_17)
endif()
if(WIN32)
target_link_libraries(mkw_platform PUBLIC shell32 ole32)
endif()
mkw_add_test(mkw_platform_paths_tests platform_paths_tests.cpp)
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
mkw_add_test(mkw_nand_save_tests nand_save_tests.cpp)
mkw_add_test(mkw_nand_move_tests nand_move_tests.cpp
"${runtime_root}/src/hle/storage/nand_file_ops.cpp")
target_include_directories(mkw_nand_move_tests PRIVATE "${runtime_root}/src/hle/storage")
mkw_add_test(mkw_nand_settings_tests nand_settings_tests.cpp)
mkw_add_test(mkw_sc_serial_tests sc_serial_tests.cpp)
mkw_add_test(mkw_input_expr_tests test_expr.cpp "${runtime_root}/src/input_expr.cpp")
target_compile_definitions(mkw_input_expr_tests PRIVATE MKW_INPUT_EXPR_TEST_CLOCK)
if(WIN32)
mkw_add_test(mkw_windows_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
if(NOT TARGET mkw::libco)
add_library(mkw_libco STATIC "${runtime_root}/third_party/libco/libco.c")
add_library(mkw::libco ALIAS mkw_libco)
target_include_directories(mkw_libco PUBLIC "${runtime_root}/third_party/libco")
endif()
mkw_add_test(mkw_linux_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp")
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
elseif(APPLE)
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64|aarch64)$")
message(FATAL_ERROR "The macOS runtime tests require Apple Silicon")
endif()
enable_language(ASM)
set(context_asm "${runtime_root}/src/platform/macos/co_switch.S")
mkw_add_test(mkw_macos_context_abi_tests macos_context_abi_tests.cpp "${context_asm}")
mkw_add_test(mkw_macos_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp" "${context_asm}")
mkw_add_test(mkw_macos_guest_flat_memory_tests macos_guest_flat_memory_tests.cpp
"${runtime_root}/src/guest_flat_memory_macos.cpp")
endif()
+181
View File
@@ -0,0 +1,181 @@
#include "nand_file_ops.h"
#include <atomic>
#include <chrono>
#include <fstream>
#include <functional>
#include <iterator>
#include <string>
#include <thread>
#include <iostream>
#include <stdexcept>
#ifdef __linux__
#include <csignal>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace fs = std::filesystem;
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& data) {
std::ofstream output(path, std::ios::binary);
output << data;
output.close();
Require(bool(output), "Fixture write failed");
}
static std::string Read(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
Require(bool(input), "Fixture read failed");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
// Competing moves must have exactly one winner, retain the losing source, and
// publish the winner's complete contents. Exercise both direct and staged moves.
static void CheckCompetingMoves(const fs::path& sourceRoot, const fs::path& destinationRoot) {
const auto left = sourceRoot / "race-left";
const auto right = sourceRoot / "race-right";
const auto target = destinationRoot / "race-target";
const std::string leftBytes(65536, 'L'), rightBytes(65536, 'R');
for (int attempt = 0; attempt < 128; ++attempt) {
Write(left, leftBytes);
Write(right, rightBytes);
std::atomic<int> ready{0};
std::error_code leftError, rightError;
auto move = [&](const fs::path& source, std::error_code& ec) {
++ready;
while (ready.load() != 2) std::this_thread::yield();
NandMove(source, target, ec);
};
std::thread first(move, std::cref(left), std::ref(leftError));
std::thread second(move, std::cref(right), std::ref(rightError));
first.join();
second.join();
Require(bool(leftError) != bool(rightError), "Competing moves must have exactly one winner");
const bool leftWon = !leftError;
Require((leftWon ? rightError : leftError) == std::errc::file_exists,
"Losing move must report an existing destination");
Require(Read(target) == (leftWon ? leftBytes : rightBytes), "Winner's contents were overwritten");
Require(!fs::exists(leftWon ? left : right), "Winning source was not removed");
Require(Read(leftWon ? right : left) == (leftWon ? rightBytes : leftBytes),
"Losing source must remain intact");
fs::remove(leftWon ? right : left);
fs::remove(target);
}
}
int main() {
const auto name = "wiicomp-nand-move-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto root = fs::temp_directory_path() / name;
fs::path other;
try {
fs::create_directory(root);
const auto source = root / "banner.bin";
const auto destination = root / "moved.bin";
std::error_code ec;
Write(source, "banner");
NandMove(source, destination, ec);
Require(!ec && !fs::exists(source) && Read(destination) == "banner", "Same-device move failed");
CheckCompetingMoves(root, root);
Write(source, "keep source");
NandMove(source, destination, ec);
Require(ec == std::errc::file_exists && Read(source) == "keep source" && Read(destination) == "banner",
"Existing destination must not be overwritten");
NandMove(root / "missing", root / "absent", ec);
Require(bool(ec) && !fs::exists(root / "absent"), "Missing source must fail");
NandMove(source, root / "missing-parent/file", ec);
Require(bool(ec) && Read(source) == "keep source", "Missing parent must preserve source");
fs::create_directory(root / "directory");
Write(root / "directory/child", "child");
NandMove(root / "directory", root / "renamed-directory", ec);
Require(!ec && Read(root / "renamed-directory/child") == "child", "Same-device directory move regressed");
#ifndef _WIN32
fs::create_symlink(root / "missing", root / "same-link");
NandMove(root / "same-link", root / "moved-link", ec);
Require(!ec && fs::is_symlink(fs::symlink_status(root / "moved-link")) &&
!fs::is_symlink(fs::symlink_status(root / "same-link")), "Same-device symlink move regressed");
#endif
#ifdef __linux__
// /dev/shm is a separate tmpfs on ordinary Linux systems, including CI
// and WSL. Fail rather than silently passing without exercising EXDEV.
other = fs::path("/dev/shm") / name;
fs::create_directory(other);
struct stat left{}, right{};
Require(::stat(root.c_str(), &left) == 0 && ::stat(other.c_str(), &right) == 0 && left.st_dev != right.st_dev,
"Cross-device test requires /tmp and /dev/shm on separate filesystems");
CheckCompetingMoves(root, other);
Require(fs::is_empty(other), "Competing moves left staging files behind");
const auto target = other / "banner.bin";
const std::string bytes = std::string(8192, '\0') + "banner payload";
Write(source, bytes);
fs::rename(source, target, ec);
Require(ec == std::errc::cross_device_link, "Fixture must reproduce the original EXDEV failure");
NandMove(source, target, ec);
Require(!ec && !fs::exists(source) && Read(target) == bytes, "Cross-device move must preserve every byte");
Write(source, "do not overwrite");
NandMove(source, target, ec);
Require(ec == std::errc::file_exists && Read(source) == "do not overwrite" && Read(target) == bytes,
"Cross-device move must preserve an existing destination");
fs::remove(target);
fs::create_symlink(other / "missing", target);
NandMove(source, target, ec);
Require(ec == std::errc::file_exists && fs::is_symlink(target) && Read(source) == "do not overwrite",
"Dangling destination symlink must not be replaced");
fs::remove(target);
NandMove(root / "renamed-directory", other / "directory", ec);
Require(ec == std::errc::cross_device_link && Read(root / "renamed-directory/child") == "child" &&
!fs::exists(other / "directory"), "Unsupported directory move must leave source intact");
fs::create_symlink(source, root / "link");
NandMove(root / "link", other / "link", ec);
Require(ec == std::errc::cross_device_link && fs::is_symlink(root / "link") && !fs::exists(other / "link"),
"Cross-device source symlinks must not be dereferenced");
// Force a real write failure after a partial copy without filling disk.
Write(source, bytes);
struct rlimit saved{}, limited{};
Require(getrlimit(RLIMIT_FSIZE, &saved) == 0, "Cannot read file-size limit");
limited = saved;
limited.rlim_cur = 1024;
const auto oldHandler = std::signal(SIGXFSZ, SIG_IGN);
Require(setrlimit(RLIMIT_FSIZE, &limited) == 0, "Cannot set file-size limit");
NandMove(source, target, ec);
const auto copyError = ec;
const auto restored = setrlimit(RLIMIT_FSIZE, &saved);
std::signal(SIGXFSZ, oldHandler);
Require(restored == 0, "Cannot restore file-size limit");
Require(bool(copyError) && Read(source) == bytes && !fs::exists(target) && fs::is_empty(other),
"Failed copy must retain source and remove partial staging files");
Require(geteuid() != 0, "Run permission tests as an unprivileged user");
fs::permissions(root, fs::perms::owner_read | fs::perms::owner_exec);
NandMove(source, target, ec);
fs::permissions(root, fs::perms::owner_all);
Require(bool(ec) && Read(source) == bytes && Read(target) == bytes,
"Failed source deletion must leave both complete copies");
Require(std::distance(fs::directory_iterator(other), fs::directory_iterator{}) == 1,
"Move must clean up staging directory");
fs::remove_all(other);
#endif
fs::remove_all(root);
std::cout << "NAND move tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
std::error_code ignored;
fs::permissions(root, fs::perms::owner_all, ignored);
fs::remove_all(root, ignored);
if (!other.empty()) fs::remove_all(other, ignored);
return 1;
}
}
+142
View File
@@ -0,0 +1,142 @@
#include "nand_save_probe.h"
#include <algorithm>
#include <chrono>
#include <iostream>
#include <sstream>
#include <stdexcept>
#ifdef _WIN32
#include <windows.h>
#endif
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());
// Save inspection must leave unrelated NAND data alone. Settings
// initialization is covered separately by nand_settings_tests.
const auto settingsPath = root / "title/00000001/00000002/data/setting.txt";
const std::string identity(256, '\x5a');
Write(settingsPath, identity);
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(Read(settingsPath) == identity, "Save inspection must not change NAND settings");
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;
}
}
+183
View File
@@ -0,0 +1,183 @@
#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 auto scratchParent = root / "scratch-collisions";
std::filesystem::create_directories(scratchParent / ".setting-init-fixed-0");
const auto sentinel = scratchParent / ".setting-init-fixed-0" / "setting.txt";
{ std::ofstream output(sentinel); output << "another launch owns this"; }
const auto occupiedFile = scratchParent / ".setting-init-fixed-1";
{ std::ofstream output(occupiedFile); output << "leave this file alone"; }
std::error_code scratchError;
const auto claimed = CreateScratchDirectory(scratchParent, "fixed", scratchError);
Require(claimed && *claimed == scratchParent / ".setting-init-fixed-2" && !scratchError,
"Retry collisions with both existing directories and files");
Require(ReadBytes(sentinel) == "another launch owns this" &&
ReadBytes(occupiedFile) == "leave this file alone", "Never modify another launch's scratch data");
Require(!CreateScratchDirectory(occupiedFile / "not-a-directory", "fixed", scratchError) && scratchError,
"Real filesystem errors must fail rather than retry indefinitely");
// Force all claimants to use the same token; this deterministically
// exercises the collision path even when host clock precision is high.
std::array<std::optional<std::filesystem::path>, 16> claims;
std::vector<std::thread> claimants;
for (size_t i = 0; i < claims.size(); ++i) {
claimants.emplace_back([&, i] {
std::error_code ec;
claims[i] = CreateScratchDirectory(scratchParent, "shared", ec);
});
}
for (auto& claimant : claimants) claimant.join();
for (size_t i = 0; i < claims.size(); ++i) {
Require(claims[i].has_value(), "Every concurrent claimant must acquire a scratch directory");
for (size_t j = 0; j < i; ++j) {
Require(claims[i] != claims[j], "Concurrent claimants must own different scratch directories");
}
}
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;
}
}
+81
View File
@@ -0,0 +1,81 @@
#include "sc_serial_contract.h"
#include "nand_settings.h"
#include <algorithm>
#include <array>
#include <iostream>
#include <iomanip>
#include <stdexcept>
#include <string>
static void Require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
static uint32_t ReadWord(const unsigned char* bytes) {
return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) |
(uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
}
int main(int argc, char** argv) {
try {
// Feed real generator + SC ABI outputs to the upstream bot decoder.
// Usage: mkw_sc_serial_tests --timestamp-vectors <unix-seconds> ...
if (argc > 1 && std::string(argv[1]) == "--timestamp-vectors") {
for (int i = 2; i < argc; ++i) {
const auto timestamp = std::stoll(argv[i]);
const auto serial = RuntimeNandSettings::GenerateSerial(static_cast<std::time_t>(timestamp));
std::array<unsigned char, 4> output{};
Require(RuntimeScSerial::Write(serial, 4,
[](uint32_t address, size_t size) { return address == 4 && size == 4; },
[&](uint32_t, uint32_t value) {
for (unsigned j = 0; j < 4; ++j)
output[j] = static_cast<unsigned char>(value >> (24 - 8 * j));
}) == 1, "Generated serial must pass SC ABI");
std::cout << timestamp << '\t' << serial << "\tLEH"
<< std::setfill('0') << std::setw(9) << ReadWord(output.data()) << '\n';
}
return 0;
}
// Reproduce the reported csnums from the old string-writing override.
const unsigned char old7886[] = {'7', '8', '8', '6'};
const unsigned char old7618[] = {'7', '6', '1', '8'};
Require(ReadWord(old7886) == 926431286, "Reproduce shared LEH926431286");
Require(ReadWord(old7618) == 926298424, "Reproduce shared LEH926298424");
std::array<unsigned char, 16> memory;
size_t available = 4;
unsigned writes = 0;
const auto contains = [&](uint32_t address, size_t size) {
return address == 4 && size <= available;
};
const auto write32 = [&](uint32_t address, uint32_t value) {
++writes;
for (unsigned i = 0; i < 4; ++i)
memory[address + i] = static_cast<unsigned char>(value >> (24 - 8 * i));
};
for (const auto& pair : {std::pair{"788600001", 788600001u}, {"788699999", 788699999u},
{"761800001", 761800001u}, {"761899999", 761899999u},
{"012345678", 12345678u}, {"000000001", 1u}, {"999999999", 999999999u}}) {
memory.fill(0xa5);
writes = 0;
Require(RuntimeScSerial::Write(pair.first, 4, contains, write32) == 1, "Accept an exactly four-byte output buffer");
Require(writes == 1 && ReadWord(memory.data() + 4) == pair.second, "Return full numeric serial, including digits after common prefix");
for (size_t i = 0; i < memory.size(); ++i)
if (i < 4 || i >= 8) Require(memory[i] == 0xa5, "Do not overwrite adjacent guest stack data");
}
for (const char* serial : {"", "1234567890", "7886x1234", "-12345678", "+12345678"}) {
writes = 0;
Require(RuntimeScSerial::Write(serial, 4, contains, write32) == 0 && writes == 0, "Reject malformed serial without a write");
}
writes = 0;
Require(RuntimeScSerial::Write("788600001", 0, contains, write32) == 0 && writes == 0, "Reject null output");
available = 3;
Require(RuntimeScSerial::Write("788600001", 4, contains, write32) == 0 && writes == 0, "Reject undersized output");
std::cout << "SC serial collision reproduction, numeric output and memory-boundary tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+226
View File
@@ -0,0 +1,226 @@
// 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>
#ifndef MKW_INPUT_EXPR_TEST_CLOCK
#error "Build this test through CMake so the deterministic clock is enabled"
#endif
static std::chrono::steady_clock::time_point g_now{};
namespace InputExpr {
std::chrono::steady_clock::time_point TestClockNow() { return g_now; }
}
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 AdvanceTime(int ms) { g_now += 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");
AdvanceTime(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");
AdvanceTime(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");
AdvanceTime(90);
Check(Pressed(window), "window open between the two pulses");
AdvanceTime(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)++;
AdvanceTime(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)++;
AdvanceTime(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());
AdvanceTime(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;
}