diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f258e14..a156710 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,10 @@ concurrency: cancel-in-progress: true jobs: + recompilation: + name: Recompilation test + uses: ./.github/workflows/recomp-test.yml + translator: name: Translator (build + test) runs-on: windows-latest @@ -26,6 +30,14 @@ jobs: with: dotnet-version: '8.0.x' + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('translator/Translator.sln', '**/*.csproj', '**/*.props', '**/*.targets', '**/packages.lock.json', 'global.json', 'NuGet.config', 'nuget.config') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Restore run: dotnet restore translator/Translator.sln diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 6df88da..b97c62a 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -20,6 +20,10 @@ concurrency: cancel-in-progress: true jobs: + recompilation: + name: Recompilation test + uses: ./.github/workflows/recomp-test.yml + linux-appimage: name: Linux (AppImage, ${{ matrix.arch }}) strategy: @@ -42,12 +46,10 @@ jobs: # appimagetool ships as an AppImage itself and needs to self-mount via FUSE to run directly; # GitHub's ubuntu-latest runners don't have libfuse2 preinstalled. - # patchelf: prepare-portable-tools.sh bundles the host libraries the llvm.org binaries link - # against and rewrites their RPATH to find them. - - name: Install dependencies (required by appimagetool, SDL3 build and toolchain bundling) + - name: Install dependencies (required by appimagetool and SDL3 build) run: | sudo apt-get update - sudo apt-get install -y libfuse2 patchelf build-essential git make \ + sudo apt-get install -y libfuse2 build-essential git make \ pkg-config cmake ninja-build gnome-desktop-testing libasound2-dev libpulse-dev \ libaudio-dev libfribidi-dev libjack-dev libsndio-dev libx11-dev libxext-dev \ libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev \ @@ -95,7 +97,7 @@ jobs: release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [linux-appimage, windows-installer] + needs: [linux-appimage, windows-installer, recompilation] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/recomp-test.yml b/.github/workflows/recomp-test.yml new file mode 100644 index 0000000..3a1244a --- /dev/null +++ b/.github/workflows/recomp-test.yml @@ -0,0 +1,61 @@ +name: Synthetic recompilation + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + name: Windows runtime (synthetic DOL) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + # Cache downloads only. Preparation still validates pins, and every run + # compiles current Aurora, runtime, and generated sources from scratch. + - uses: actions/cache@v5 + with: + path: Launcher/artifacts/downloads + key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }} + + # Install sccache. + - name: Run sccache-action + uses: mozilla/sccache-action@v0.0.11 + + # Tell CMake to use sccache and use GitHub's API. + - name: Configure sccache environment + shell: pwsh + run: | + "SCCACHE_GHA_ENABLED=true" | Add-Content -Path $env:GITHUB_ENV + "ACTIONS_CACHE_SERVICE_V2=on" | Add-Content -Path $env:GITHUB_ENV + "CMAKE_C_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV + "CMAKE_CXX_COMPILER_LAUNCHER=$env:SCCACHE_PATH" | Add-Content -Path $env:GITHUB_ENV + + - name: Prepare the shipped Windows toolchain + shell: pwsh + run: ./Launcher/Prepare-PortableTools.ps1 + + - name: Prepare pinned native dependencies + shell: pwsh + run: ./Launcher/Prepare-Dependencies.ps1 + + # Expose cache token context to the build script. + - name: Translate, compile the full runtime, and link + shell: pwsh + run: ./Launcher/Test-Recompilation.ps1 -Parallel 4 + + # Print cache results (even if the build fails) + - name: Show sccache stats + if: always() + shell: pwsh + run: sccache --show-stats diff --git a/.gitignore b/.gitignore index a7bb2a2..e9818b5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ Code.pul /build/ /build-*/ /native-build/ +/native-build-macos/ +/local-products/ /dist/ /out/ [Bb]in/ @@ -71,3 +73,5 @@ project.lock.json *.log output.txt +# Operating System +.DS_Store diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 112018c..3e12790 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -282,7 +282,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.26' + ProductVersion = '0.2.31' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 diff --git a/Launcher/NativeBuildFlags.ps1 b/Launcher/NativeBuildFlags.ps1 index 5402643..b288b2e 100644 --- a/Launcher/NativeBuildFlags.ps1 +++ b/Launcher/NativeBuildFlags.ps1 @@ -117,12 +117,13 @@ function Get-MkwProjectPins([string]$ProjectFile) { } function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description, - [string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') { + [string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '', [bool]$WaitForProcessTree = $true) { <# - Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait - is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may - hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process - doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it. + Runs a build tool and turns a non-zero exit code into a described failure. By default, + Start-Process -Wait waits for the whole process tree, since a .NET single-file bundle host may + hand off to an extracted child that PowerShell's call operator would not wait for. Callers that + need to avoid waiting on unrelated descendants can opt into the call-operator path. + Start-Process doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it. -StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line. #> @@ -132,9 +133,14 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" } '"' + $_ + '"' }) - $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments ` - -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + if ($WaitForProcessTree) { + $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments ` + -NoNewWindow -Wait -PassThru + $exitCode = $process.ExitCode + } else { + & $FilePath @Arguments + $exitCode = $LASTEXITCODE + } $global:LASTEXITCODE = $exitCode if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." } } diff --git a/Launcher/Test-Recompilation.ps1 b/Launcher/Test-Recompilation.ps1 new file mode 100644 index 0000000..789e4dd --- /dev/null +++ b/Launcher/Test-Recompilation.ps1 @@ -0,0 +1,148 @@ +# Build the real Windows runtime with translated, entirely synthetic PowerPC code. +# No game dump, game symbol map, REL, mod download, or existing generated/ output is used. +[CmdletBinding()] +param( + [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools', + [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies', + [string]$StageDirectory = 'build/recomp-test', + [ValidateRange(1, 64)] [int]$Parallel = 3 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +function Full([string]$Path) { + if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) } + return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) +} +$portableTools = Full $PortableToolsDirectory +$dependencies = Full $DependencySourceDirectory +$stage = Full $StageDirectory +$dotnet = (Get-Command dotnet -CommandType Application).Source +$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe' +$ninja = Join-Path $portableTools 'Ninja/ninja.exe' +$compilerBin = Join-Path $portableTools 'llvm-mingw/bin' +Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)' +Assert-File $ninja 'Pinned Ninja' +Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)' + +# Refuse reuse so a developer's game translation or an earlier build cannot make +# the test pass. Keep the staging tree after the run for diagnostics. +if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" } +[IO.Directory]::CreateDirectory($stage) | Out-Null +Write-Host "Synthetic recompilation workspace: $stage" + +# Copy current sources, including uncommitted edits, but no ignored build output. +# An isolated workspace preserves the developer's real generated/ directory. +$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main +if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' } +foreach ($relative in $sourceFiles | Sort-Object -Unique) { + $destination = Join-Path $stage $relative + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination +} + +# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr. +# Native HLE wrappers also call these eight guest symbols directly. Give each +# its own generated blr function so the real product can link without game code. +# Keep this list explicit: a new unresolved guest dependency must fail the test. +[uint32]$entry = 0x80001000L +[uint32[]]$guestCallbacks = @( + 0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL, + 0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL +) +$textSize = [int]($guestCallbacks[-1] - $entry + 4) +$dataOffset = 0x100 + $textSize +$dol = [byte[]]::new($dataOffset + 4) +function Write-BigEndian32([int]$Offset, [uint32]$Value) { + $dol[$Offset] = [byte](($Value -shr 24) -band 255) + $dol[$Offset + 1] = [byte](($Value -shr 16) -band 255) + $dol[$Offset + 2] = [byte](($Value -shr 8) -band 255) + $dol[$Offset + 3] = [byte]($Value -band 255) +} +Write-BigEndian32 0x00 0x100 # text[0] file offset +Write-BigEndian32 0x48 $entry # text[0] guest address +Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero) +Write-BigEndian32 0x1C $dataOffset # data[0] file offset +Write-BigEndian32 0x64 0x80600000L # data[0] guest address +Write-BigEndian32 0xAC 4 # data[0] length +Write-BigEndian32 0xD8 0x80601000L # BSS address +Write-BigEndian32 0xDC 32 # BSS length +Write-BigEndian32 0xE0 $entry # entry point +Write-BigEndian32 0x100 0x38600028 # li r3,40 +Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2 +Write-BigEndian32 0x108 0x60000000 # nop +Write-BigEndian32 0x10C 0x4E800020 # blr +foreach ($address in $guestCallbacks) { + Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020 +} +Write-BigEndian32 $dataOffset 0x12345678 +[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol) +$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', ' +$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n" +[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap) +$manifest = Join-Path $stage 'recomp.yml' +[IO.File]::WriteAllText($manifest, @" +schema_version: 1 +workspace_root: . +project: + id: ci-synthetic-dol + display_name: CI Synthetic DOL +memory: + base: 0x80000000 + size: 0x01800000 + sda_base: 0x80600000 + sda2_base: 0x80600000 +inputs: + dol: + path: synthetic.dol +translation: + entry_points: [$entryPoints] + function_map: + path: synthetic-functions.txt + allow_unsupported_instructions: false +runtime: + native_abi_directories: [] + native_registration_root: runtime/src +output: + root: generated +"@) + +$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj' +Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator' +$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll' +$metadata = Join-Path $stage 'generated/base_translation_output.json' +Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest, + '--output-metadata', $metadata, '--threads', "$Parallel") ` + 'Translating the synthetic DOL' +# Function-map seeds can be skipped by discovery; do not accept a partial fixture. +$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json +foreach ($address in @($entry) + $guestCallbacks) { + if ($address -notin $translated.functions.entryPoint) { + throw ('Synthetic function 0x{0:X8} was not translated.' -f $address) + } +} +Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration' +Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph' + +$nativeBuild = Join-Path $stage 'native-build' +$oldPath = $env:PATH +try { + $env:PATH = Get-MkwToolchainPath $portableTools + $configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild ` + -Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') ` + -CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') ` + -ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') ` + -DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON') + Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' ` + -WaitForProcessTree $false + Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") ` + 'Compiling and linking the synthetic product with the full runtime' ` + -WaitForProcessTree $false + Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product' +} finally { + $env:PATH = $oldPath +} +Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).' diff --git a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj index b22d680..97e8ecb 100644 --- a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj +++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj @@ -6,7 +6,7 @@ enable WiiCompiled.Setup.Common.Cli WiiCompiled.Setup.Common.Cli - 0.2.22 + 0.2.31 patchzy WiiCompiled Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1 diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj index 91fbef7..aebdc1b 100644 --- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj +++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj @@ -5,7 +5,7 @@ enable WiiCompiled.Setup.Common WiiCompiled.Setup.Common - 0.2.22 + 0.2.31 patchzy WiiCompiled Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs index b9c9671..b8f8ea6 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Models.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs @@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux; internal static class ProductInfo { public const string Name = "WiiCompiled"; - public const string Version = "0.2.22"; + public const string Version = "0.2.31"; } /// One installed product's record inside install-state.json. diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj index 7424382..84d3269 100644 --- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj +++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj @@ -6,7 +6,7 @@ enable WiiCompiled.Setup.Linux WiiCompiled.Setup.Linux - 0.2.22 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs index fce86f6..fb96d3c 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs @@ -22,6 +22,9 @@ internal sealed class InstallerEngine var existing = new Installation(installDirectory); PortableInstallHealing.HealMovedInstall(existing, _reporter); + // Capture this before publishing anything. An existing installation may be repaired or + // updated by this invocation, but those operations must not recreate the user's shortcuts. + var firstInstall = !existing.IsPresent; var previousState = existing.ReadInstallState(); using var scratch = Directory.Exists(installDirectory) ? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter) @@ -139,7 +142,7 @@ internal sealed class InstallerEngine updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint, remainingCancellation); Publish(staging, installDirectory, canonicalRetroRoot, updatedState, - releaseEntries, remainingCancellation); + releaseEntries, remainingCancellation, createShortcuts: firstInstall); return; } @@ -161,7 +164,8 @@ internal sealed class InstallerEngine await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest, previousState, options, canonicalRetroRoot, retroCompileInputs, - publishGameAssets: reusableGameAssets is null, cancellationToken); + publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall, + cancellationToken: cancellationToken); } @@ -186,7 +190,7 @@ internal sealed class InstallerEngine string stagedWorkspace, PayloadManifest manifest, InstallState? previousState, InstallOptions options, string? canonicalRetroRoot, RetroRewindCompileInputs? retroCompileInputs, - bool publishGameAssets, CancellationToken cancellationToken) + bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken) { var installDirectory = existing.Root; @@ -214,7 +218,8 @@ internal sealed class InstallerEngine if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets"); Publish(staging, installDirectory, canonicalRetroRoot, state, - entries, cancellationToken, progressPercent: 8, completionPercent: 10); + entries, cancellationToken, progressPercent: 8, completionPercent: 10, + createShortcuts: createShortcuts); _reporter.Progress(InstallStages.BuildBase, "Producing the installed products with the published toolkit...", 11); @@ -277,7 +282,7 @@ internal sealed class InstallerEngine private void Publish(string staging, string installDirectory, string? canonicalRetroRoot, InstallState state, List entries, CancellationToken cancellationToken, - int progressPercent = 95, int completionPercent = 99) + int progressPercent = 95, int completionPercent = 99, bool createShortcuts = false) { entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"), Path.Combine(installDirectory, "licenses"))); @@ -322,7 +327,8 @@ internal sealed class InstallerEngine { ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled); } - ShellIntegration.CreateShortcuts(installDirectory); + if (createShortcuts) + ShellIntegration.CreateShortcuts(installDirectory); } catch (Exception ex) { diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 3caeff9..7995f17 100644 --- a/Launcher/WiiCompiled.Setup.Windows/Program.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs @@ -121,7 +121,7 @@ internal static class PlatformChecks internal static class ProductInfo { public const string Name = "WiiCompiled"; - public const string Version = "0.2.26"; + public const string Version = "0.2.31"; /// /// The setup executable is copied into the installation under this name. It is the launcher and diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj index 08c9a6d..0efaa73 100644 --- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj +++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj @@ -7,7 +7,7 @@ WiiCompiled.Setup WiiCompiled.Setup.Windows app.manifest - 0.2.26 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh index 9602345..69ea00d 100755 --- a/Launcher/prepare-portable-tools.sh +++ b/Launcher/prepare-portable-tools.sh @@ -91,17 +91,11 @@ download_verified() { mv "$tmp" "$dest" } -# lib/bundled is part of the check so a toolchain prepared before runtime-library bundling existed -# is rebuilt rather than reused. -if [[ -x "$toolchain_dir/bin/clang" && -x "$toolchain_dir/bin/ninja" && -x "$toolchain_dir/bin/cmake" && -d "$toolchain_dir/lib/bundled" ]]; then +if [[ -x "$toolchain_dir/bin/clang" && -x "$toolchain_dir/bin/ninja" && -x "$toolchain_dir/bin/cmake" ]]; then echo "prepare-portable-tools.sh: reusing existing toolchain at $toolchain_dir" exit 0 fi -for tool in patchelf ldd strip; do - command -v "$tool" >/dev/null || { echo "prepare-portable-tools.sh: '$tool' is required (apt-get install patchelf binutils libc-bin)" >&2; exit 1; } -done - work="$destination/.building-toolchain-$arch" rm -rf "$work" mkdir -p "$work/bin" "$work/lib/$target_triple" "$work/include/$target_triple/c++/v1" @@ -190,98 +184,6 @@ echo "prepare-portable-tools.sh: staging ninja $ninja_version..." unzip -oq "$ninja_archive" -d "$work/bin" chmod +x "$work/bin/ninja" -# --- runtime libraries the llvm.org binaries expect the host to provide --- -# -# The official LLVM release binaries are dynamically linked against a few host libraries beyond -# glibc: lld needs libxml2, clang/lld need zlib and zstd, and whatever those pull in. A user's -# distro is not guaranteed to have them under the same soname - libxml2 2.14 renamed -# libxml2.so.2 to libxml2.so.16, so on current Arch/Fedora a fresh install died with -# "ld.lld: error while loading shared libraries: libxml2.so.2". The smoke test below never caught -# it because the build machine has every one of them. So: copy the transitive closure of non-core -# libraries next to the binaries and point the binaries at that directory through an RPATH, the -# way AppImages ship any dynamically linked tool. glibc and its companions stay on the host: they -# exist everywhere, and a bundled copy could only ever be older than the host's own. -bundled_lib_dir="$work/lib/bundled" -mkdir -p "$bundled_lib_dir" - -is_host_provided_lib() { - case "$1" in - linux-vdso*|ld-linux*|libc.so*|libm.so*|libdl.so*|libpthread.so*|librt.so*|libresolv.so*|libutil.so*|\ - libgcc_s.so*|libstdc++.so*) return 0 ;; - *) return 1 ;; - esac -} - -# Prints "soname path" for every dynamically resolved dependency of $1 that the host must not be -# relied on for. ldd already reports the transitive closure, so no recursion is needed. -non_host_deps() { - local line soname path - ldd "$1" 2>/dev/null | while read -r line; do - [[ "$line" == *"=>"* ]] || continue - soname=${line%% =>*}; soname=${soname## } - path=${line#*=> }; path=${path%% (*} - is_host_provided_lib "$soname" && continue - echo "$soname $path" - done -} - -# A dependency the build machine itself lacks cannot be bundled; say so instead of shipping a -# toolchain that is broken everywhere. (Checked separately because a failure inside the -# process-substituted reader above could not stop the script.) -assert_deps_resolved() { - if ldd "$1" 2>/dev/null | grep -q "not found"; then - echo "prepare-portable-tools.sh: $(basename "$1") has dependencies missing on this machine:" >&2 - ldd "$1" | grep "not found" >&2 - exit 1 - fi -} - -# Copies every non-core library $1 needs into lib/bundled, then does the same for each copy, so -# a library's own dependencies (libxml2 -> ICU, ...) are bundled even if ldd's listing for the -# executable did not already spell out the whole closure. The "already bundled" check ends the -# recursion. -bundle_deps_of() { - local file=$1 soname path - assert_deps_resolved "$file" - while read -r soname path; do - [[ -n "$soname" ]] || continue - [[ -e "$bundled_lib_dir/$soname" ]] && continue - cp -L "$path" "$bundled_lib_dir/$soname" - chmod u+w "$bundled_lib_dir/$soname" - # A bundled library must find its own dependencies beside itself: RUNPATH is not - # inherited from the executable that loaded it. - patchelf --set-rpath '$ORIGIN' "$bundled_lib_dir/$soname" - bundle_deps_of "$bundled_lib_dir/$soname" - done < <(non_host_deps "$file") -} - -bundle_runtime_libs() { - local file=$1 existing - bundle_deps_of "$file" - # Prepend, keeping whatever RPATH the release build set (typically $ORIGIN/../lib). - existing=$(patchelf --print-rpath "$file" 2>/dev/null || true) - patchelf --set-rpath "\$ORIGIN/../lib/bundled${existing:+:$existing}" "$file" -} - -echo "prepare-portable-tools.sh: bundling the host runtime libraries the toolchain binaries depend on..." -for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake"; do - bundle_runtime_libs "$binary" -done -echo "prepare-portable-tools.sh: bundled $(ls "$bundled_lib_dir" | wc -l) librar(y/ies): $(ls "$bundled_lib_dir" | tr '\n' ' ')" - -# Fail here, on the build machine, if any non-core dependency still resolves to a host path: -# that is exactly the file a user's machine may lack. -for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake" "$bundled_lib_dir"/*; do - [[ -f "$binary" ]] || continue - while read -r soname path; do - [[ -n "$soname" ]] || continue - if [[ "$path" != "$bundled_lib_dir/"* ]]; then - echo "prepare-portable-tools.sh: $(basename "$binary") still resolves $soname from the host ($path); bundling failed" >&2 - exit 1 - fi - done < <(non_host_deps "$binary") -done - cat > "$work/LICENSE.txt" < "$smoke_dir/t.cpp" <<'EOF' +echo "prepare-portable-tools.sh: testing the toolchain..." +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cat > "$test_dir/t.cpp" <<'EOF' #include #include int main() { @@ -317,23 +214,23 @@ int main() { return sum == 6 ? 0 : 1; } EOF -"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t" -"$smoke_dir/t" +"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t" +"$test_dir/t" # Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain # clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that # can't find the compiler. -cat > "$smoke_dir/CMakeLists.txt" <<'EOF' +cat > "$test_dir/CMakeLists.txt" <<'EOF' cmake_minimum_required(VERSION 3.16) -project(smoke CXX) -add_executable(smoke t.cpp) +project(test CXX) +add_executable(test t.cpp) EOF -"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \ +"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \ -DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null -"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null -"$smoke_dir/build/smoke" +"$work/bin/cmake" --build "$test_dir/build" >/dev/null +"$test_dir/build/test" -rm -rf "$smoke_dir" +rm -rf "$test_dir" trap - EXIT mv "$work" "$toolchain_dir" diff --git a/README.md b/README.md index 19c0caf..0d871a5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ Press **F10** while the game window has focus: - Internal resolution - FPS counter - Controller assignment for all four ports -- Full per-controller button mapping +- Full per-controller button mapping, including the bumpers +- Dolphin-syntax input expressions and GCPadNew.ini import +- Controller vibration on/off - Volume, instant mute, and the music ducking toggle Everything you change is saved to `Config.toml` on the spot and restored next launch. @@ -63,25 +65,20 @@ Controllers are fed to the game as a GameCube controller. Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra inputs like paddles, touchpads and share buttons show up when the hardware reports them. + +**Dolphin-compatible input expressions.** +Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators +and the same functions. +A Dolphin `GCPadNew.ini` can be imported directly from the F10 bar. + +**Vibration toggle.** +Force feedback can be turned off for every port at once. The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the adapter must be switched to the WinUSB driver once (Zadig). **Real Wii Remotes over Bluetooth.** Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the -PIN empty) and the game reads it as an actual Wii Remote through KPAD: Wii Remote icons and -prompts, Wii Wheel tilt steering, wheelies and tricks all come from the game's own motion code. -Nunchuk and Classic Controller are real Wii extensions too: the game gets the Nunchuk's stick, -C/Z and accelerometer, and the Classic Controller through `KPADGetUnifiedWpadStatus` with its own -layout and icons, so its buttons do what the game says they do and no mapping is involved. Plug an -extension in or pull it out mid-game and the game switches control scheme like on the console -(the runtime patches SDL's Wii driver, which otherwise loses the remote for good on an extension -change). Only the Wii U Pro Controller, which has no Wii-era equivalent, is fed to the game as a -GameCube pad with Nintendo's layout. If a remote drops out or was switched on after launch, the -runtime keeps rescanning Bluetooth until it comes back (F10 > Controller settings > Wii Remotes). SDL's read of -the remote's factory accelerometer calibration often times out over Bluetooth (`console.log` -then says "Using fallback accelerometer calibration") and it falls back to a nominal zero point, -so the same menu has a one-button calibration (remote flat, buttons up) that removes the small -tilt offset some remotes show. +PIN empty) Known limitations of the Wii Remote path: - No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as diff --git a/aurora-main/lib/input.cpp b/aurora-main/lib/input.cpp index 8884f1a..6c9418b 100644 --- a/aurora-main/lib/input.cpp +++ b/aurora-main/lib/input.cpp @@ -401,6 +401,8 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { return -1; } controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337; + const char* serial = SDL_GetGamepadSerial(ctrl); + controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial; if (controller.m_isGameCube || (SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) { controller.m_deadZones.emulateTriggers = false; @@ -481,6 +483,13 @@ bool controller_has_rumble(Uint32 instance) noexcept { void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity, uint16_t duration_ms) noexcept { if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) { + // GC Pocket+ has been observed continuing to vibrate after a hard stop; + // an ordinary stop cleared it. With GAMECUBE_RUMBLE_BRAKE enabled, SDL + // encodes (0, 1) as adapter command 0, whereas (0, 0) sends command 2. + // Apply the workaround here so shutdown uses the same stop as PAD calls. + if (it->second.m_gameCubeUseOrdinaryStop && low_freq_intensity == 0 && high_freq_intensity == 0) { + high_freq_intensity = 1; + } SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms); } } diff --git a/aurora-main/lib/input.hpp b/aurora-main/lib/input.hpp index 1c6f24e..ae083b1 100644 --- a/aurora-main/lib/input.hpp +++ b/aurora-main/lib/input.hpp @@ -17,6 +17,7 @@ extern Module Log; struct GameController { SDL_Gamepad* m_controller = nullptr; bool m_isGameCube = false; + bool m_gameCubeUseOrdinaryStop = false; Sint32 m_index = -1; Sint32 m_playerIndex = -1; bool m_hasRumble = false; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 87da111..5894a05 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -342,6 +342,32 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17) add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests) +add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp") +target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17) +add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests) + +add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp") +find_package(Threads REQUIRED) +target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads) +target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17) +add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests) + +add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp") +target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17) +add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests) + +# The input expression engine is self-contained, so it can be exercised without +# linking the runtime or SDL. +add_executable(mkw_input_expr_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp") +target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17) +add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests) + # HostContext deliberately keeps the platform-specific context primitive out # of fiber_manager.cpp. Exercise the Linux libco handoff directly so future # refactors cannot silently remove its headers, implementation, or link edge. diff --git a/runtime/include/console_identity.h b/runtime/include/console_identity.h index ef46679..c07fc30 100644 --- a/runtime/include/console_identity.h +++ b/runtime/include/console_identity.h @@ -1,38 +1,28 @@ #pragma once -#include "runtime_config.h" +#include "nand_path.h" +#include "nand_settings.h" -#include #include -#include #include #include -#include -#include #include -#include -#include #include #include -#include #include namespace RuntimeConsoleIdentity { struct Identity { std::string serial; + std::string productCode; + std::string area; + std::string gameRegion; std::array mac; }; -inline bool IsValidSerial(const std::string& serial) { - return serial.size() == 9 && - serial != "000000000" && - std::all_of(serial.begin(), serial.end(), - [](unsigned char value) { return std::isdigit(value) != 0; }); -} - inline Identity FromSerial(std::string serial) { - // Keep Nintendo's Wii OUI. The suffix is derived from the persisted serial + // Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial // so every API exposes one coherent, stable virtual-console identity. uint32_t hash = 2166136261u; for (const unsigned char value : serial) { @@ -46,6 +36,7 @@ inline Identity FromSerial(std::string serial) { return { std::move(serial), + {}, {}, {}, { 0x00, 0x09, @@ -57,83 +48,23 @@ inline Identity FromSerial(std::string serial) { }; } -inline std::optional ReadSerial(const std::filesystem::path& path) { - std::ifstream input(path); - std::string line; - if (!input || !std::getline(input, line)) { - return std::nullopt; +inline Identity LoadFromNand() { + const auto root = RuntimeNandPath::DiscoverNandRootPath(); + const auto settings = RuntimeNandSettings::Read(root); + if (!settings || !RuntimeNandSettings::HasIdentity(*settings)) { + RuntimeNandPath::FailNandRoot( + "NAND setting.txt is missing or has invalid console identity fields (SERNO, CODE, AREA, GAME)", + root / "title/00000001/00000002/data/setting.txt"); } - constexpr std::string_view prefix = "serial="; - if (line.rfind(prefix, 0) != 0) { - return std::nullopt; - } - std::string serial = line.substr(prefix.size()); - if (!IsValidSerial(serial)) { - return std::nullopt; - } - return serial; -} - -inline bool WriteSerial(const std::filesystem::path& path, const std::string& serial) { - std::error_code ec; - std::filesystem::create_directories(path.parent_path(), ec); - if (ec) { - return false; - } - - std::filesystem::path temporary = path; - temporary += ".tmp"; - { - std::ofstream output(temporary, std::ios::trunc); - if (!output) { - return false; - } - output << "serial=" << serial << '\n'; - output.close(); - if (!output) { - return false; - } - } - - std::filesystem::rename(temporary, path, ec); - if (!ec) { - return true; - } - std::filesystem::remove(temporary, ec); - return false; -} - -inline std::string GenerateSerial() { - std::random_device entropy; - std::seed_seq seed{ - entropy(), - entropy(), - entropy(), - entropy(), - }; - std::mt19937 generator(seed); - std::uniform_int_distribution distribution(100000000u, 999999999u); - return std::to_string(distribution(generator)); -} - -inline Identity LoadOrCreate(const std::filesystem::path& path) { - if (const auto serial = ReadSerial(path)) { - return FromSerial(*serial); - } - - const std::string generated = GenerateSerial(); - if (WriteSerial(path, generated)) { - return FromSerial(generated); - } - - // Remain operational in a read-only environment. This fallback matches - // Dolphin's deterministic serial while keeping the same valid identity shape. - return FromSerial("123456789"); + Identity identity = FromSerial(settings->at("SERNO")); + identity.productCode = settings->at("CODE"); + identity.area = settings->at("AREA"); + identity.gameRegion = settings->at("GAME"); + return identity; } inline const Identity& Current() { - static const Identity identity = - LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt"); + static const Identity identity = LoadFromNand(); return identity; } diff --git a/runtime/include/controller_button_names.h b/runtime/include/controller_button_names.h new file mode 100644 index 0000000..d0d2b81 --- /dev/null +++ b/runtime/include/controller_button_names.h @@ -0,0 +1,159 @@ +#pragma once + +// The single vocabulary shared by everything that has to turn a Config.toml +// controller name into a real button: the F10 settings bar, the macro engine, +// and the startup mapping pass. Keeping one table here means a name that the +// settings bar offers is always a name the config parser accepts, and vice +// versa; the two used to drift because each side carried its own copy. + +#include +#include +#include +#include +#include + +#include +#include + +namespace ControllerNames { + +// A GameCube button as the game sees it, with the Config.toml key that selects +// it. Order matches RuntimeConfigFile::kControllerButtonKeys. +struct GameCubeButtonItem { + const char* configKey; + const char* label; + PADButton padButton; +}; + +inline constexpr std::array kGameCubeButtons = {{ + {"a", "A", PAD_BUTTON_A}, + {"b", "B", PAD_BUTTON_B}, + {"x", "X", PAD_BUTTON_X}, + {"y", "Y", PAD_BUTTON_Y}, + {"start", "Start", PAD_BUTTON_START}, + {"z", "Z", PAD_TRIGGER_Z}, + {"l", "L", PAD_TRIGGER_L}, + {"r", "R", PAD_TRIGGER_R}, + {"up", "D-pad Up", PAD_BUTTON_UP}, + {"down", "D-pad Down", PAD_BUTTON_DOWN}, + {"left", "D-pad Left", PAD_BUTTON_LEFT}, + {"right", "D-pad Right", PAD_BUTTON_RIGHT}, +}}; + +// A physical button on the host pad. Names are positional (south/east/...) +// rather than Xbox-labelled so one config reads the same on any hardware. +struct NativeButtonItem { + const char* configName; + const char* label; + uint32_t nativeButton; +}; + +inline constexpr std::array kNativeButtons = {{ + {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID}, + {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH}, + {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST}, + {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST}, + {"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH}, + {"back", "Back / Select / Create", SDL_GAMEPAD_BUTTON_BACK}, + {"guide", "Guide / Home / PS", SDL_GAMEPAD_BUTTON_GUIDE}, + {"start", "Start / Options", SDL_GAMEPAD_BUTTON_START}, + {"left_stick", "Left stick click (L3)", SDL_GAMEPAD_BUTTON_LEFT_STICK}, + {"right_stick", "Right stick click (R3)", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + {"left_shoulder", "Left bumper (LB / L1)", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {"right_shoulder", "Right bumper (RB / R1)", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP}, + {"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + {"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + {"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {"misc1", "Misc 1 / Share / Mic", SDL_GAMEPAD_BUTTON_MISC1}, + {"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1}, + {"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1}, + {"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2}, + {"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2}, + {"touchpad", "Touchpad click", SDL_GAMEPAD_BUTTON_TOUCHPAD}, + {"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2}, + {"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3}, + {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4}, + {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5}, + {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6}, +}}; + +inline std::string TrimToken(std::string_view token) { + const size_t begin = token.find_first_not_of(" \t"); + if (begin == std::string_view::npos) { + return {}; + } + const size_t end = token.find_last_not_of(" \t"); + return std::string(token.substr(begin, end - begin + 1)); +} + +inline const NativeButtonItem* FindNativeButton(std::string_view configName) { + const std::string name = TrimToken(configName); + const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), + [&](const NativeButtonItem& item) { return name == item.configName; }); + return it == kNativeButtons.end() ? nullptr : &*it; +} + +// Falls back to the "unmapped" entry so callers always have a label to draw. +inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { + const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), + [&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; }); + return it == kNativeButtons.end() ? kNativeButtons.front() : *it; +} + +inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) { + const std::string key = TrimToken(configKey); + const auto it = std::find_if(kGameCubeButtons.begin(), kGameCubeButtons.end(), + [&](const GameCubeButtonItem& item) { return key == item.configKey; }); + return it == kGameCubeButtons.end() ? nullptr : &*it; +} + +// "up" or "up,a" -> the OR of those GC button bits. Unknown names are skipped so +// a typo costs one button instead of the whole macro. +inline uint16_t GameCubeMaskFromKeys(std::string_view keys) { + uint16_t mask = 0; + size_t begin = 0; + while (begin <= keys.size()) { + const size_t comma = keys.find(',', begin); + const std::string_view token = + keys.substr(begin, comma == std::string_view::npos ? std::string_view::npos : comma - begin); + if (const GameCubeButtonItem* item = FindGameCubeButton(token)) { + mask |= static_cast(item->padButton); + } + if (comma == std::string_view::npos) { + break; + } + begin = comma + 1; + } + return mask; +} + +inline std::string GameCubeKeysFromMask(uint16_t mask) { + std::string keys; + for (const auto& item : kGameCubeButtons) { + if ((mask & static_cast(item.padButton)) == 0) { + continue; + } + if (!keys.empty()) { + keys += ','; + } + keys += item.configKey; + } + return keys; +} + +inline std::string GameCubeLabelsFromMask(uint16_t mask) { + std::string labels; + for (const auto& item : kGameCubeButtons) { + if ((mask & static_cast(item.padButton)) == 0) { + continue; + } + if (!labels.empty()) { + labels += " + "; + } + labels += item.label; + } + return labels.empty() ? std::string("None") : labels; +} + +} // namespace ControllerNames diff --git a/runtime/include/input_bindings.h b/runtime/include/input_bindings.h new file mode 100644 index 0000000..37d664b3 --- /dev/null +++ b/runtime/include/input_bindings.h @@ -0,0 +1,67 @@ +#pragma once + +// Per-port expression bindings for the GameCube controls, plus import of a +// Dolphin GCPadNew.ini. + +#include +#include +#include + +#include + +namespace InputBindings { + +// The controls an expression can drive, in Dolphin's own naming so an +// imported config maps across without translation. +struct ControlInfo { + const char* dolphinName; + const char* label; + uint16_t padButton; // 0 for the analog-only controls below + int analog; // 0 none, 1 trigger L, 2 trigger R +}; + +inline constexpr std::array kControls = {{ + {"Buttons/A", "A", PAD_BUTTON_A, 0}, + {"Buttons/B", "B", PAD_BUTTON_B, 0}, + {"Buttons/X", "X", PAD_BUTTON_X, 0}, + {"Buttons/Y", "Y", PAD_BUTTON_Y, 0}, + {"Buttons/Z", "Z", PAD_TRIGGER_Z, 0}, + {"Buttons/Start", "Start", PAD_BUTTON_START, 0}, + {"D-Pad/Up", "D-pad Up", PAD_BUTTON_UP, 0}, + {"D-Pad/Down", "D-pad Down", PAD_BUTTON_DOWN, 0}, + {"D-Pad/Left", "D-pad Left", PAD_BUTTON_LEFT, 0}, + {"D-Pad/Right", "D-pad Right", PAD_BUTTON_RIGHT, 0}, + {"Triggers/L", "L", PAD_TRIGGER_L, 1}, + {"Triggers/R", "R", PAD_TRIGGER_R, 2}, + {"Triggers/L-Analog", "L analog", 0, 1}, + {"Triggers/R-Analog", "R analog", 0, 2}, +}}; + +void Reload() noexcept; + +// The pad library has PADBlockInput but no matching query, so the settings +// overlay reports its own state here. +void SetInputBlocked(bool blocked) noexcept; +bool InputBlocked() noexcept; + +// Mix expression output into a freshly read status set. Call once per guest +// PADRead, after every other input source has been merged. +void Apply(PADStatus* statuses) noexcept; + +std::string GetExpression(uint32_t port, size_t control) noexcept; +// Returns false and fills error if the text does not parse; the binding is +// left unchanged in that case. +bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept; + +// True while the control's expression is above the press threshold. +bool IsActive(uint32_t port, size_t control) noexcept; + +// The default Dolphin config location on Windows, then next to the executable. +std::string DefaultDolphinConfigPath() noexcept; + +// Imports [GCPad] into the given port. Returns the number of controls +// imported, or -1 on failure with error filled. +int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, + std::string& summary, std::string& error) noexcept; + +} // namespace InputBindings diff --git a/runtime/include/input_expr.h b/runtime/include/input_expr.h new file mode 100644 index 0000000..8fbb149 --- /dev/null +++ b/runtime/include/input_expr.h @@ -0,0 +1,53 @@ +#pragma once + +// Dolphin-compatible input expressions. +// +// Values are doubles in Dolphin's ControlState style; a control counts as +// pressed above kConditionThreshold. Timing matches Dolphin: wall-clock +// seconds on a steady clock, so an expression copied from GCPadNew.ini +// behaves the same here as it does there. + +#include +#include +#include +#include +#include + +namespace InputExpr { + +inline constexpr double kConditionThreshold = 0.5; + +// Resolves a backtick-quoted input name to its current value. +using InputSource = std::function; + +struct Node; + +class Expression { +public: + Expression(); + ~Expression(); + Expression(Expression&&) noexcept; + Expression& operator=(Expression&&) noexcept; + + // Returns false and fills error on a syntax problem. + static bool Parse(const std::string& text, Expression& out, std::string& error); + + bool Empty() const { return m_root == nullptr; } + double Evaluate(const InputSource& source) const; + + // Input names the expression references, for diagnostics. + std::vector ReferencedInputs() const; + +private: + std::unique_ptr m_root; +}; + +// Parses a Dolphin GCPadNew.ini and returns the expression text for each +// control of the requested pad, keyed by Dolphin's own control names +// ("Buttons/A", "D-Pad/Up", "Triggers/L", ...). Returns false if the file +// cannot be read or the section is missing. +bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex, + std::vector>& controls, + std::string& deviceName, std::string& error); + +} // namespace InputExpr diff --git a/runtime/include/nand_path.h b/runtime/include/nand_path.h index 709e4af..eb0fda8 100644 --- a/runtime/include/nand_path.h +++ b/runtime/include/nand_path.h @@ -1,6 +1,7 @@ #pragma once #include "runtime_config.h" +#include "nand_settings.h" #include "runtime_log.h" #include "system_bridge.h" @@ -163,7 +164,7 @@ inline std::filesystem::path CreateManagedNandRoot() { return root; } -inline std::filesystem::path DiscoverNandRootPath() { +inline std::filesystem::path ResolveNandRootPath() { const std::string configPath = RuntimeConfigFile::NandRoot(); if (!configPath.empty()) { const auto path = ResolveConfiguredPath(configPath); @@ -179,4 +180,16 @@ inline std::filesystem::path DiscoverNandRootPath() { return CreateManagedNandRoot(); } +inline std::filesystem::path DiscoverNandRootPath() { + static const auto root = [] { + const auto resolved = ResolveNandRootPath(); + std::string error; + if (!RuntimeNandSettings::Ensure(resolved, error)) { + FailNandRoot(error.c_str(), RuntimeNandSettings::FilePath(resolved)); + } + return resolved; + }(); + return root; +} + } // namespace RuntimeNandPath diff --git a/runtime/include/nand_save_probe.h b/runtime/include/nand_save_probe.h new file mode 100644 index 0000000..b00b7d8 --- /dev/null +++ b/runtime/include/nand_save_probe.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +namespace RuntimeNandSave { + +enum class Contents { Missing, Blank, Nonzero, Error }; +enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded }; + +// A failed read is not evidence that a save is blank. Check badbit before EOF: +// an I/O failure may set both, whereas a successful short final read sets EOF. +inline Contents InspectStream(std::istream& input) { + if (!input) return Contents::Error; + char block[4096]; + for (;;) { + input.read(block, sizeof(block)); + if (input.bad() || (input.fail() && !input.eof())) return Contents::Error; + for (std::streamsize i = 0; i < input.gcount(); ++i) { + if (block[i] != 0) return Contents::Nonzero; + } + if (input.eof()) return Contents::Blank; + } +} + +inline Contents InspectFile(const std::filesystem::path& path) { + std::error_code ec; + const auto status = std::filesystem::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error; + if (!std::filesystem::exists(status)) return Contents::Missing; + if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error; + std::ifstream input(path, std::ios::binary); + return InspectStream(input); +} + +// Probe only read-only opens of the actual save and its exact write shadow. +// No probe writes, removes, or repairs data, and backups are not save aliases. +inline ReadAction CheckRead(const std::filesystem::path& path, int mode) { + const auto name = path.filename(); + const bool isMain = name == "rksys.dat"; + if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed; + const auto contents = InspectFile(path); + if (contents == Contents::Error) return ReadAction::Error; + if (contents == Contents::Nonzero) return ReadAction::Proceed; + if (isMain) { + auto shadow = path; + shadow += ".nandsafe.tmp"; + const auto shadowContents = InspectFile(shadow); + if (shadowContents == Contents::Error) return ReadAction::Error; + // The next write normally discards an old shadow. Preserve a possible + // recovery source when there is no usable original, without promoting + // an uncommitted (and potentially incomplete) shadow to the real save. + if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded; + } + return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed; +} + +} // namespace RuntimeNandSave diff --git a/runtime/include/nand_settings.h b/runtime/include/nand_settings.h new file mode 100644 index 0000000..055f4c2 --- /dev/null +++ b/runtime/include/nand_settings.h @@ -0,0 +1,220 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +namespace RuntimeNandSettings { + +using Settings = std::map; + +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 Read(const std::filesystem::path& nandRoot) { + std::ifstream input(FilePath(nandRoot), std::ios::binary); + std::array bytes{}; + if (!input.read(reinterpret_cast(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(byte ^ static_cast(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> EncodeNew(const std::string& serial) { + const Settings identity{{"SERNO", serial}, {"CODE", "LEH"}, {"AREA", "EUR"}, {"GAME", "EU"}}; + if (!HasIdentity(identity)) { + return std::nullopt; + } + std::array bytes{}; + size_t position = 0; + uint32_t key = 0x73B5DBFAu; + const auto writeByte = [&](char value) { + bytes[position++] = static_cast(value) ^ static_cast(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 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 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(bytes->data()), bytes->size()); + output.close(); + written = static_cast(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 diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index fcf96d6..c180bfe 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -108,6 +109,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, 12> controllerButtons; + std::optional rumbleEnabled; + std::map controllerExpressions; }; namespace RuntimeConfigFile { @@ -507,6 +510,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { FindConfigValue(document, "controller", buttonKeys[index]); } + config.rumbleEnabled = FindConfigValue(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(document, "video", "widescreen"); config.windowPosX = FindConfigInt(document, "video", "window_x"); config.windowPosY = FindConfigInt(document, "video", "window_y"); @@ -927,6 +941,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; @@ -1054,7 +1087,7 @@ inline bool SetWiiRemotesEnabled(bool value) { } // Whether to keep rescanning Bluetooth while no Wii controller is connected. -inline bool WiiContinuousScanEnabled(bool fallback = true) { +inline bool WiiContinuousScanEnabled(bool fallback = false) { return Get().wiiContinuousScan.value_or(fallback); } diff --git a/runtime/include/sc_serial_contract.h b/runtime/include/sc_serial_contract.h new file mode 100644 index 0000000..32e3600 --- /dev/null +++ b/runtime/include/sc_serial_contract.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 +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 diff --git a/runtime/include/wii_remote_input.h b/runtime/include/wii_remote_input.h index d7c1c9f..986ec64 100644 --- a/runtime/include/wii_remote_input.h +++ b/runtime/include/wii_remote_input.h @@ -102,8 +102,10 @@ void HideRemotesFromPad(PADStatus* statuses, uint32_t count); void Poll(); // Forces one re-enumeration right now (settings overlay "Rescan now"). void RescanNow(); -// True while Poll() is actively rescanning (no Wii controller connected). +// True while Poll() is looking for a remote (no Wii controller connected). bool IsScanning(); +// True where looking means periodic rescans; elsewhere Poll() waits for hotplug. +bool PeriodicRescanEnabled(); // Rescans issued since a Wii controller was last seen. uint32_t ScanCount(); diff --git a/runtime/src/fiber_manager.cpp b/runtime/src/fiber_manager.cpp index fb4575d..9eee67c 100644 --- a/runtime/src/fiber_manager.cpp +++ b/runtime/src/fiber_manager.cpp @@ -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(static_cast(guestThreadAddr))); diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 1939a84..23ff800 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -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 +#include #include #include #include #include +#include #include namespace { +std::atomic 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(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(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(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)); diff --git a/runtime/src/hle/net/network_socket.cpp b/runtime/src/hle/net/network_socket.cpp index 898942f..95c6634 100644 --- a/runtime/src/hle/net/network_socket.cpp +++ b/runtime/src/hle/net/network_socket.cpp @@ -499,7 +499,7 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector& 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); diff --git a/runtime/src/hle/sc.cpp b/runtime/src/hle/sc.cpp index 62c4411..8e50ed1 100644 --- a/runtime/src/hle/sc.cpp +++ b/runtime/src/hle/sc.cpp @@ -1,6 +1,7 @@ #include "hle_stubs.h" #include "console_identity.h" +#include "sc_serial_contract.h" #include #include #include @@ -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(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, (), ()); diff --git a/runtime/src/hle/storage/nand_api.cpp b/runtime/src/hle/storage/nand_api.cpp index 723569c..6796b5b 100644 --- a/runtime/src/hle/storage/nand_api.cpp +++ b/runtime/src/hle/storage/nand_api.cpp @@ -93,6 +93,9 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t const std::filesystem::path hostPath = TranslateNandPath(path); + if (const auto result = NandCheckSystemSaveRead("NANDOpen", hostPath, mode)) + return *result; + // Existing-file write opens go through a shadow copy seeded from the original, so a // crash between NANDWrite and NANDClose cannot leave a torn file (the game patches // sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place. diff --git a/runtime/src/hle/storage/nand_async.cpp b/runtime/src/hle/storage/nand_async.cpp index ae9f3d9..aeb1962 100644 --- a/runtime/src/hle/storage/nand_async.cpp +++ b/runtime/src/hle/storage/nand_async.cpp @@ -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"); diff --git a/runtime/src/hle/storage/nand_fs.cpp b/runtime/src/hle/storage/nand_fs.cpp index 5b35e16..4dba4f0 100644 --- a/runtime/src/hle/storage/nand_fs.cpp +++ b/runtime/src/hle/storage/nand_fs.cpp @@ -411,6 +411,26 @@ bool IsFaceLibResourcePath(const char* path) { return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0; } +std::optional 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()) { diff --git a/runtime/src/hle/storage/nand_internal.h b/runtime/src/hle/storage/nand_internal.h index f45c617..346be4f 100644 --- a/runtime/src/hle/storage/nand_internal.h +++ b/runtime/src/hle/storage/nand_internal.h @@ -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 #include #include +#include #include #include #include @@ -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 NandCheckSystemSaveRead(const char* who, + const std::filesystem::path& hostPath, int mode, bool ios = false); + // ============================================================================ // File Descriptor Management // ============================================================================ diff --git a/runtime/src/hle/storage/nand_isfs.cpp b/runtime/src/hle/storage/nand_isfs.cpp index 6ea0ad4..f084226 100644 --- a/runtime/src/hle/storage/nand_isfs.cpp +++ b/runtime/src/hle/storage/nand_isfs.cpp @@ -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. diff --git a/runtime/src/input_bindings.cpp b/runtime/src/input_bindings.cpp new file mode 100644 index 0000000..6ffa984 --- /dev/null +++ b/runtime/src/input_bindings.cpp @@ -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 +#include +#include +#include +#include +#include + +#include + +namespace InputBindings { +namespace { + +struct Binding { + std::string text; + InputExpr::Expression expr; + bool active = false; +}; + +std::mutex g_mutex; +std::array, 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 " indices follow the common PlayStation layout, which is what +// DInput reports for a DualShock/DualSense. Other pads may number differently. +const std::unordered_map& ButtonNames() { + static const std::unordered_map 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& AxisNames() { + static const std::unordered_map 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(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(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(std::tolower(*c)); + } + return key; +} + +} // namespace + +void SetInputBlocked(bool blocked) noexcept { + std::lock_guard lock(g_mutex); + g_inputBlocked = blocked; +} + +bool InputBlocked() noexcept { + std::lock_guard lock(g_mutex); + return g_inputBlocked; +} + +void Reload() noexcept { + std::lock_guard 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 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(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 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 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 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> controls; + std::string device; + if (!InputExpr::ReadDolphinConfig(RuntimeConfigFile::PathFromUtf8(path), padIndex, controls, device, + error)) { + return -1; + } + + int imported = 0; + std::vector 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 diff --git a/runtime/src/input_expr.cpp b/runtime/src/input_expr.cpp new file mode 100644 index 0000000..d9c73c3 --- /dev/null +++ b/runtime/src/input_expr.cpp @@ -0,0 +1,631 @@ +#include "input_expr.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace InputExpr { +namespace { + +using Clock = std::chrono::steady_clock; +using FSec = std::chrono::duration; + +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& FunctionTable() { + static const std::unordered_map 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> args; + + // Per-instance state for the stateful functions. Mutable because Evaluate + // is logically a read of current input state. + mutable bool released = false; + mutable bool state = false; + mutable unsigned taps = 0; + mutable double value = 0.0; + mutable Clock::time_point mark = Clock::now(); + mutable bool marked = false; +}; + +namespace { + +// ---- tokenizer ---------------------------------------------------------- + +struct Token { + enum Type { End, Input, Number, Ident, Op, LParen, RParen, Comma } type = End; + std::string text; +}; + +class Lexer { +public: + explicit Lexer(const std::string& text) : m_text(text) {} + + bool Next(Token& tok, std::string& error) { + while (m_pos < m_text.size() && std::isspace(static_cast(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(c)) || c == '.') { + size_t end = m_pos; + while (end < m_text.size() && + (std::isdigit(static_cast(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(c)) || c == '_') { + size_t end = m_pos; + while (end < m_text.size() && + (std::isalnum(static_cast(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; + +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->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(); + if (op == "!") { + node->kind = Kind::Not; + node->args.push_back(std::move(inner)); + } else { + node->kind = Kind::Sub; + auto zero = std::make_unique(); + 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->kind = Kind::Input; + node->input = m_tok.text; + Advance(); + return node; + } + case Token::Number: { + auto node = std::make_unique(); + 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->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->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(node->args.size()); + if (count < it->second.minArgs || count > it->second.maxArgs) { + error = name + " takes " + std::to_string(it->second.minArgs) + " to " + + std::to_string(it->second.maxArgs) + " arguments"; + return nullptr; + } + return node; + } + default: + error = "expected start of expression"; + return nullptr; + } + } + + Lexer m_lexer; + Token m_tok; + std::string m_lexError; + bool m_failed = false; +}; + +// ---- evaluator ---------------------------------------------------------- + +double Eval(const Node& node, const InputSource& source); + +double Arg(const Node& node, size_t index, const InputSource& source) { + return Eval(*node.args[index], source); +} + +double Eval(const Node& node, const InputSource& source) { + switch (node.kind) { + case Kind::Literal: return node.literal; + case Kind::Input: return source ? source(node.input) : 0.0; + case Kind::Not: + case Kind::FnNot: return 1.0 - Arg(node, 0, source); + case Kind::Add: return Arg(node, 0, source) + Arg(node, 1, source); + case Kind::Sub: return Arg(node, 0, source) - Arg(node, 1, source); + case Kind::Mul: return Arg(node, 0, source) * Arg(node, 1, source); + case Kind::Div: { + // Both sides are evaluated even when the divisor is zero: the left + // subtree may hold stateful functions that need their frame update. + const double lhs = Arg(node, 0, source); + const double rhs = Arg(node, 1, source); + return rhs == 0.0 ? 0.0 : lhs / rhs; + } + case Kind::And: return std::min(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::Or: return std::max(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::Xor: { + const double a = Arg(node, 0, source); + const double b = Arg(node, 1, source); + return std::max(std::min(a, 1.0 - b), std::min(b, 1.0 - a)); + } + case Kind::Greater: return Arg(node, 0, source) > Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::Less: return Arg(node, 0, source) < Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::Equal: return Arg(node, 0, source) == Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::FnIf: + return Arg(node, 0, source) > kConditionThreshold ? Arg(node, 1, source) : Arg(node, 2, source); + case Kind::FnMin: return std::min(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnMax: return std::max(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnClamp: { + const double v = Arg(node, 0, source); + double lo = Arg(node, 1, source); + double hi = Arg(node, 2, source); + if (lo > hi) { + std::swap(lo, hi); + } + return std::clamp(v, lo, hi); + } + case Kind::FnAbs: return std::abs(Arg(node, 0, source)); + case Kind::FnSqrt: return std::sqrt(Arg(node, 0, source)); + case Kind::FnPow: return std::pow(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnSin: return std::sin(Arg(node, 0, source)); + case Kind::FnCos: return std::cos(Arg(node, 0, source)); + case Kind::FnTan: return std::tan(Arg(node, 0, source)); + case Kind::FnDeadzone: { + const double v = Arg(node, 0, source); + const double dz = std::clamp(Arg(node, 1, source), 0.0, 0.999); + return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v); + } + case Kind::FnTimer: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double period = Arg(node, 0, source); + double progress = std::chrono::duration_cast(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(FSec(period * resets)); + progress -= resets; + } + return progress; + } + case Kind::FnToggle: { + const double inner = Arg(node, 0, source); + if (inner < kConditionThreshold) { + node.released = true; + } else if (node.released) { + node.released = false; + node.state = !node.state; + } + if (node.args.size() == 2 && Arg(node, 1, source) > kConditionThreshold) { + node.state = false; + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnHold: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double input = Arg(node, 0, source); + if (input < kConditionThreshold) { + node.state = false; + node.mark = now; + } else if (!node.state) { + if (std::chrono::duration_cast(now - node.mark).count() >= Arg(node, 1, source)) { + node.state = true; + } + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnTap: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double elapsed = std::chrono::duration_cast(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(std::clamp(requested + 0.5, 1.0, 64.0)); + if (input < kConditionThreshold) { + node.released = true; + if (node.taps > 0 && timeUp) { + node.taps = 0; + } + return 0.0; + } + if (node.released) { + if (node.taps == 0) { + node.mark = now; + } + ++node.taps; + node.released = false; + } + return desired == node.taps ? 1.0 : 0.0; + } + case Kind::FnPulse: { + const auto now = Clock::now(); + const double input = Arg(node, 0, source); + if (input < kConditionThreshold) { + node.released = true; + } else if (node.released) { + node.released = false; + const double requested = Arg(node, 1, source); + const double safe = std::isfinite(requested) ? std::clamp(requested, 0.0, 3600.0) : 0.0; + const auto seconds = std::chrono::duration_cast(FSec(safe)); + if (node.state) { + node.mark += seconds; + } else { + node.state = true; + node.mark = now + seconds; + } + } + if (node.state && now >= node.mark) { + node.state = false; + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnSmooth: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double elapsed = std::chrono::duration_cast(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& 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 Expression::ReferencedInputs() const { + std::vector out; + if (m_root) { + Collect(*m_root, out); + } + return out; +} + +bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex, + std::vector>& 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 diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 959df95..efe93bc 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -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" @@ -37,6 +39,8 @@ #endif #include + +extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled); #include #include #include @@ -68,6 +72,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(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f)); @@ -139,62 +144,9 @@ std::array g_configuredControllerIndices = [] { return indices; }(); -struct ControllerButtonItem { - const char* configKey; - const char* label; - PADButton padButton; -}; - -constexpr std::array 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 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 @@ -211,6 +163,13 @@ constexpr std::array kClassicProPreset = { "dpad_up", "dpad_down", "dpad_left", "dpad_right", }; +// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share. +constexpr std::array 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; @@ -258,43 +217,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) { @@ -354,7 +295,7 @@ void ApplyConfiguredMappings() { } bool g_wiiRemotesEnabled = RuntimeConfigFile::WiiRemotesEnabled(true); -bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(true); +bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(false); // Accelerometer readout and zero-point calibration for a bare remote / remote + Nunchuk. void DrawWiiRemoteAccelerometer(uint32_t port) { @@ -488,6 +429,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(data->UserData); + text->resize(static_cast(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 errors; + static std::array buffers; + static std::string importStatus; + static int loadedPort = -1; + static bool reloadBuffers = true; + const auto port = static_cast(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(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 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); @@ -576,20 +611,28 @@ void DrawControllerSettings() { PADSerializeMappings(); mappings = PADGetButtonMappings(port, &mappingCount); } - ImGui::SameLine(); - if (ImGui::Button("Classic Controller Pro")) { + const auto applyPreset = [&](const std::array& preset) { const uint32_t port = static_cast(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"); @@ -671,6 +714,8 @@ void DrawControllerSettings() { ImGui::TextUnformatted(kControllerButtons[i].label); ImGui::PopID(); } + DrawExpressionSettings(); + DrawRumbleSettings(); } void DrawAudioSettings() { @@ -1266,6 +1311,8 @@ void PersistDisplayModeIfChanged() { } // namespace void InitializeRuntimeSettings() noexcept { + PAD_HLE_SetRumbleEnabled(g_rumbleEnabled); + InputBindings::Reload(); controller_mapping_wizard::LoadPersistedMappings(); ApplyConfiguredMappings(); AudioBackend::Instance().SetMasterVolume(static_cast(g_audioVolumePercent) / 100.0f); @@ -1290,6 +1337,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 RefreshVrHudVirtualScreen() noexcept { ApplyVrHudVirtualScreen(); } @@ -1341,7 +1389,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(); } diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp index 6c0cf6f..8cd9753 100644 --- a/runtime/src/wii_remote_input.cpp +++ b/runtime/src/wii_remote_input.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include namespace WiiRemoteInput { namespace { @@ -346,6 +348,10 @@ bool AnyWiiControllerConnected() { // Route SDL's input diagnostics (HIDAPI open failures, the Wii driver's // extension/status messages) into console.log, minus the periodic chatter. +// A sub-warning message is written once: SDL repeats the same line on every +// enumeration (one "couldn't open /dev/hidraw7: Permission denied" per HID +// device per pass), and console.log is unbuffered, so the repeats were a +// per-pass burst of writes on the main thread for no new information. void SDLCALL LogSdlMessage(void*, int category, SDL_LogPriority priority, const char* message) { if (message == nullptr) { return; @@ -354,11 +360,42 @@ void SDLCALL LogSdlMessage(void*, int category, SDL_LogPriority priority, const (std::strstr(message, "Motion Plus") != nullptr || std::strstr(message, "Resetting report mode") != nullptr)) { return; } - if (category == SDL_LOG_CATEGORY_INPUT || priority >= SDL_LOG_PRIORITY_WARN) { - RT_LOG("sdl") << message << std::endl; + if (category != SDL_LOG_CATEGORY_INPUT && priority < SDL_LOG_PRIORITY_WARN) { + return; } + if (priority < SDL_LOG_PRIORITY_WARN) { + // Device paths in these messages keep changing (/dev/hidrawN climbs with + // hotplug churn), so cap the set instead of holding one string per line + // for the whole session. + static std::unordered_set s_seen; + if (s_seen.size() >= 256) { + s_seen.clear(); + } + if (!s_seen.insert(message).second) { + return; + } + RT_LOG("sdl") << message << " (further identical messages suppressed)" << std::endl; + return; + } + RT_LOG("sdl") << message << std::endl; } +// Whether Poll() drives its own periodic re-enumeration. The 1->0->1 hint +// flip below makes SDL close and re-open every HIDAPI device on the main +// thread, and on Linux that means an open() attempt on every /dev/hidraw node +// (each failing with EACCES until a udev rule grants access), which showed up +// as a frame hitch every scan interval even on an empty menu. It exists for +// Windows Bluetooth stacks, where a remote that drops or is switched on after +// launch is not seen again until the driver re-enumerates. Linux and macOS +// already get hotplug from udev / IOKit: SDL re-enumerates when a device +// appears, so nothing periodic is needed there. The overlay's "Rescan now" +// still works everywhere. +#if defined(_WIN32) +constexpr bool kPeriodicRescan = true; +#else +constexpr bool kPeriodicRescan = false; +#endif + // Second half of a rescan: re-enables the Wii driver once SDL has seen it off. void FinishRescan(uint64_t now) { if (g_driverOffSinceMs == 0 || now - g_driverOffSinceMs < kRescanDriverOffMs) { @@ -444,18 +481,19 @@ void Poll() { g_lastScanMs = SDL_GetTicks(); return; } - if (!RuntimeConfigFile::WiiContinuousScanEnabled(true)) { + if (!RuntimeConfigFile::WiiContinuousScanEnabled(false)) { g_scanning = false; return; } const uint64_t now = SDL_GetTicks(); if (!g_scanning) { - RT_LOG(RT_TAG_CONFIG) << "No Wii Remote connected; scanning for one (press 1+2 on the remote)" - << std::endl; + RT_LOG(RT_TAG_CONFIG) << "No Wii Remote connected; " + << (kPeriodicRescan ? "scanning for one" : "waiting for one to be paired") + << " (press 1+2 on the remote)" << std::endl; g_scanning = true; g_lostAtMs = now; } - if (now - g_lostAtMs < kScanStartDelayMs) { + if (!kPeriodicRescan || now - g_lostAtMs < kScanStartDelayMs) { return; } const uint64_t interval = now - g_lostAtMs < kFastScanWindowMs ? kFastScanIntervalMs : kScanIntervalMs; @@ -470,6 +508,11 @@ bool IsScanning() { return g_scanning; } +// Whether looking for a remote means periodic rescans or waiting for hotplug. +bool PeriodicRescanEnabled() { + return kPeriodicRescan; +} + // Number of rescans since a Wii controller was last seen. uint32_t ScanCount() { return g_scanCount; diff --git a/runtime/tests/nand_save_tests.cpp b/runtime/tests/nand_save_tests.cpp new file mode 100644 index 0000000..a9bbb60 --- /dev/null +++ b/runtime/tests/nand_save_tests.cpp @@ -0,0 +1,142 @@ +#include "nand_save_probe.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#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(output), "Fixture write failed"); +} + +static std::string Read(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + Require(static_cast(input), "Fixture read failed"); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +// 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; + } +} diff --git a/runtime/tests/nand_settings_tests.cpp b/runtime/tests/nand_settings_tests.cpp new file mode 100644 index 0000000..3231911 --- /dev/null +++ b/runtime/tests/nand_settings_tests.cpp @@ -0,0 +1,183 @@ +#include "nand_settings.h" + +#include +#include +#include +#include +#include + +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(input), std::istreambuf_iterator()}; +} + +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 golden{}; + for (size_t i = 0; i < goldenHex.size() / 2; ++i) { + golden[i] = static_cast(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, 16> claims; + std::vector 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 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(key) ^ (i < plain.size() ? plain[i] : 0); + } + { + std::ofstream output(path, std::ios::binary); + output.write(reinterpret_cast(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 after{}; + { + std::ifstream input(path, std::ios::binary); + input.read(reinterpret_cast(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 results{}; + std::vector 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((*encoded)[i] ^ static_cast(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; + } +} diff --git a/runtime/tests/sc_serial_tests.cpp b/runtime/tests/sc_serial_tests.cpp new file mode 100644 index 0000000..c63257f --- /dev/null +++ b/runtime/tests/sc_serial_tests.cpp @@ -0,0 +1,81 @@ +#include "sc_serial_contract.h" +#include "nand_settings.h" + +#include +#include +#include +#include +#include +#include + +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 ... + 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(timestamp)); + std::array 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(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 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(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; + } +} diff --git a/runtime/tests/test_expr.cpp b/runtime/tests/test_expr.cpp new file mode 100644 index 0000000..c770a3c --- /dev/null +++ b/runtime/tests/test_expr.cpp @@ -0,0 +1,219 @@ +// Verifies the expression engine against Dolphin's documented semantics, +// including the exact line from the user's GCPadNew.ini. + +#include "input_expr.h" + +#include +#include +#include +#include +#include +#include + +static int g_failures = 0; +static std::map g_inputs; + +static InputExpr::InputSource Source() { + return [](const std::string& name) { + const auto it = g_inputs.find(name); + return it == g_inputs.end() ? 0.0 : it->second; + }; +} + +static void Check(bool ok, const std::string& what) { + if (!ok) { + std::printf(" FAIL: %s\n", what.c_str()); + ++g_failures; + } +} + +static InputExpr::Expression Compile(const std::string& text) { + InputExpr::Expression expr; + std::string error; + if (!InputExpr::Expression::Parse(text, expr, error)) { + std::printf(" FAIL: parse '%s': %s\n", text.c_str(), error.c_str()); + ++g_failures; + } + return expr; +} + +static bool Pressed(const InputExpr::Expression& e) { + return e.Evaluate(Source()) > InputExpr::kConditionThreshold; +} + +static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } + +int main() { + std::printf("Dolphin expression engine\n"); + + // Operators: & is min, | is max, ! is 1-x, matching Dolphin. + g_inputs["A"] = 1.0; + g_inputs["B"] = 0.0; + Check(Pressed(Compile("`A`")), "bare input"); + Check(!Pressed(Compile("!`A`")), "not"); + Check(!Pressed(Compile("`A` & `B`")), "and is min"); + Check(Pressed(Compile("`A` | `B`")), "or is max"); + Check(Pressed(Compile("`A` ^ `B`")), "xor"); + Check(!Pressed(Compile("`A` ^ `A`")), "xor of equal inputs is false"); + + // Precedence: & binds tighter than |, so this is A | (B & A). + g_inputs["B"] = 0.0; + Check(Pressed(Compile("`A` | `B` & `A`")), "& binds tighter than |"); + + // Parens and numeric literals. + Check(Pressed(Compile("(`B` | 1)")), "literal"); + Check(Pressed(Compile("min(1, `A`)")), "min"); + Check(!Pressed(Compile("min(0, `A`)")), "min with zero"); + Check(Pressed(Compile("if(`A`, 1, 0)")), "if"); + Check(Pressed(Compile("clamp(5, 0, 1)")), "clamp"); + + // toggle flips on each rising edge and holds between them. + auto toggle = Compile("toggle(`T`)"); + g_inputs["T"] = 0.0; + toggle.Evaluate(Source()); + g_inputs["T"] = 1.0; + Check(Pressed(toggle), "toggle on after first press"); + g_inputs["T"] = 0.0; + Check(Pressed(toggle), "toggle stays on after release"); + g_inputs["T"] = 1.0; + Check(!Pressed(toggle), "toggle off on second press"); + + // hold requires the input to be down for the full duration. + auto hold = Compile("hold(`H`, 0.05)"); + g_inputs["H"] = 1.0; + Check(!Pressed(hold), "hold not satisfied immediately"); + Sleep(70); + Check(Pressed(hold), "hold satisfied after the interval"); + g_inputs["H"] = 0.0; + Check(!Pressed(hold), "hold clears on release"); + + // pulse fires for the given duration after a rising edge. + auto pulse = Compile("pulse(`P`, 0.05)"); + g_inputs["P"] = 0.0; + pulse.Evaluate(Source()); + g_inputs["P"] = 1.0; + Check(Pressed(pulse), "pulse fires on rising edge"); + Sleep(80); + Check(!Pressed(pulse), "pulse expires"); + + // The timing-window idiom seen in shared Dolphin configs. + auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)"); + g_inputs["W"] = 0.0; + window.Evaluate(Source()); + g_inputs["W"] = 1.0; + Check(!Pressed(window), "window closed before its start"); + Sleep(90); + Check(Pressed(window), "window open between the two pulses"); + Sleep(90); + Check(!Pressed(window), "window closed after its end"); + + // timer ramps 0..1 and wraps, so a threshold turns it into a square wave. + auto timer = Compile("`X` & timer(0.1)"); + g_inputs["X"] = 1.0; + int high = 0; + int low = 0; + for (int i = 0; i < 40; ++i) { + (Pressed(timer) ? high : low)++; + Sleep(5); + } + Check(high > 5 && low > 5, "timer alternates high and low"); + + // The exact D-Pad/Up line from the user's GCPadNew.ini. + auto dolphinLine = Compile("`Hat 0 N` | `Button 4` & timer(0.01)"); + g_inputs["Hat 0 N"] = 0.0; + g_inputs["Button 4"] = 0.0; + Check(!Pressed(dolphinLine), "idle with nothing held"); + g_inputs["Hat 0 N"] = 1.0; + Check(Pressed(dolphinLine), "hat alone presses"); + g_inputs["Hat 0 N"] = 0.0; + g_inputs["Button 4"] = 1.0; + high = low = 0; + for (int i = 0; i < 60; ++i) { + (Pressed(dolphinLine) ? high : low)++; + Sleep(2); + } + Check(high > 5 && low > 5, "LB alternates via timer(0.01)"); + + // Regression tests for the CodeRabbit findings on PR #89. + g_inputs["A"] = 1.0; + // clamp with reversed bounds: std::clamp is UB when lo > hi. + Check(Compile("clamp(0.5, 1, 0)").Evaluate(Source()) == 0.5, "clamp tolerates reversed bounds"); + // deadzone(v, 1) would divide by zero. + { + const double v = Compile("deadzone(`A`, 1)").Evaluate(Source()); + Check(std::isfinite(v), "deadzone with dz=1 stays finite"); + } + // timer with a zero or negative period would produce inf or NaN. + for (const char* text : {"timer(0)", "timer(-1)"}) { + const double v = Compile(text).Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " stays finite"); + } + // Any non-finite result is squashed before it can reach the uint8_t cast. + for (const char* text : {"sqrt(0 - 1)", "pow(10, 10000)", "tan(1.5707963267948966)"}) { + const double v = Compile(text).Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " is sanitised at the boundary"); + } + + // tap count is user authored; negative, huge and non-finite must not reach + // the unsigned conversion. + for (const char* text : {"tap(`A`, 0.2, -1)", "tap(`A`, 0.2, 999999999)", "tap(`A`, 0.2, 0)"}) { + InputExpr::Expression e; + std::string err; + Check(InputExpr::Expression::Parse(text, e, err), std::string("parse ") + text); + const double v = e.Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " evaluates without UB"); + } + + // Exponent notation is not part of the number syntax, matching Dolphin's + // lexer; it is rejected rather than silently misparsed. + { + InputExpr::Expression e; + std::string err; + Check(!InputExpr::Expression::Parse("tap(`A`, 0.2, 1e30)", e, err), "exponent notation rejected"); + } + + // A zero divisor must not skip the left subtree: stateful functions there + // still need their per-frame update. + { + auto divToggle = Compile("toggle(`D`) / `Z`"); + g_inputs["Z"] = 0.0; + g_inputs["D"] = 0.0; + divToggle.Evaluate(Source()); + g_inputs["D"] = 1.0; + divToggle.Evaluate(Source()); // rising edge seen even though rhs is 0 + g_inputs["D"] = 0.0; + g_inputs["Z"] = 1.0; + Check(divToggle.Evaluate(Source()) > InputExpr::kConditionThreshold, + "toggle still latched while the divisor was zero"); + } + + // smooth with a zero rate divides 0 by 0; NaN must not stick in the node. + { + auto sm = Compile("smooth(`A`, 0)"); + g_inputs["A"] = 1.0; + sm.Evaluate(Source()); + Sleep(5); + Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite"); + } + + // Referenced inputs, used for diagnostics in the UI. + const auto refs = dolphinLine.ReferencedInputs(); + Check(refs.size() == 2, "two referenced inputs"); + + // Errors are reported, not silently swallowed. + InputExpr::Expression bad; + std::string error; + Check(!InputExpr::Expression::Parse("`A` & ", bad, error), "trailing operator rejected"); + Check(!InputExpr::Expression::Parse("nope(1)", bad, error), "unknown function rejected"); + Check(!InputExpr::Expression::Parse("(`A`", bad, error), "missing paren rejected"); + Check(!InputExpr::Expression::Parse("`A", bad, error), "unterminated backtick rejected"); + Check(InputExpr::Expression::Parse("", bad, error) && bad.Empty(), "empty parses to empty"); + Check(!InputExpr::Expression::Parse("hold(`A`)", bad, error), "wrong arg count rejected"); + + if (g_failures == 0) { + std::printf("all checks passed\n"); + return 0; + } + std::printf("%d check(s) failed\n", g_failures); + return 1; +}