From d3d0de62a66e4c34f329ca60a4d7254a923ab4cf Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:29:49 +0200 Subject: [PATCH 01/24] Throttle SDL logs and gate Wii rescans (#129) * Throttle SDL logs and gate Wii rescans * Clarify Wii remote scan state in overlay --- runtime/include/wii_remote_input.h | 4 ++- runtime/src/settings_overlay.cpp | 4 ++- runtime/src/wii_remote_input.cpp | 53 +++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) 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/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 282f7a4..6561ef8 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -393,8 +393,10 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) { } ImGui::EndDisabled(); ImGui::SameLine(); - if (WiiRemoteInput::IsScanning()) { + if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) { ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount()); + } else if (WiiRemoteInput::IsScanning()) { + ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote"); } else { ImGui::TextDisabled("Not scanning"); } diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp index 6c0cf6f..09dc97d 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) { @@ -450,12 +487,13 @@ void Poll() { } 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; From efc44b0482f5eb67d9c7d32168152509322a0dd4 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:35:39 +0200 Subject: [PATCH 02/24] Default Wii remote continuous scanning to off (#137) --- runtime/include/runtime_config.h | 2 +- runtime/src/settings_overlay.cpp | 2 +- runtime/src/wii_remote_input.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index d7f668f..7606677 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -804,7 +804,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/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 6561ef8..f2e753f 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -321,7 +321,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) { diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp index 09dc97d..8cd9753 100644 --- a/runtime/src/wii_remote_input.cpp +++ b/runtime/src/wii_remote_input.cpp @@ -481,7 +481,7 @@ void Poll() { g_lastScanMs = SDL_GetTicks(); return; } - if (!RuntimeConfigFile::WiiContinuousScanEnabled(true)) { + if (!RuntimeConfigFile::WiiContinuousScanEnabled(false)) { g_scanning = false; return; } From 989d5e00da7615a09836dacec967a39a8fa613da Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:02:02 +0200 Subject: [PATCH 03/24] Updat eversion --- Launcher/Build-Installer.ps1 | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 6727b61..27058cb 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.26' + ProductVersion = '0.2.27' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 3caeff9..4f59f7a 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.27"; /// /// 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..48fb916 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.27 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled From 65047bc7b72ab7772ed0bac260469f621eb15b6e Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:12:18 +0200 Subject: [PATCH 04/24] Bump LLVM --- .github/workflows/package.yml | 6 +- Launcher/prepare-portable-tools.sh | 117 ++--------------------------- 2 files changed, 9 insertions(+), 114 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 6df88da..742418f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -42,12 +42,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 \ diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh index 9602345..fe7b42a 100755 --- a/Launcher/prepare-portable-tools.sh +++ b/Launcher/prepare-portable-tools.sh @@ -25,7 +25,7 @@ set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) workspace=$(cd "$script_dir/.." && pwd) -llvm_version=22.1.8 +llvm_version=23.1.0 cmake_version=4.3.3 ninja_version=1.13.2 destination="$script_dir/artifacts/portable-tools" @@ -52,13 +52,13 @@ done case "$arch" in x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu - llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384 + llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c cmake_release_arch=x86_64 cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802 ninja_asset=ninja-linux.zip ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;; aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu - llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34 + llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4 cmake_release_arch=aarch64 cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4 ninja_asset=ninja-linux-aarch64.zip @@ -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" @@ -126,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain.. # clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke. # Stripped: debug symbols are dead weight for a bundled compiler nobody will debug. -cp -a "$src/bin/clang-22" "$work/bin/" -strip "$work/bin/clang-22" -ln -s clang-22 "$work/bin/clang" +cp -a "$src/bin/clang-23" "$work/bin/" +strip "$work/bin/clang-23" +ln -s clang-23 "$work/bin/clang" ln -s clang "$work/bin/clang++" # lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first - @@ -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" < Date: Fri, 4 Sep 2026 21:17:11 +0200 Subject: [PATCH 05/24] Fix LLVM path --- Launcher/Prepare-NativePrebuilt.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Launcher/Prepare-NativePrebuilt.sh b/Launcher/Prepare-NativePrebuilt.sh index 3d4e6ed..df2e100 100755 --- a/Launcher/Prepare-NativePrebuilt.sh +++ b/Launcher/Prepare-NativePrebuilt.sh @@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja" assert_file "$cc" "Portable C compiler" assert_file "$cxx" "Portable C++ compiler" assert_dir "$aurora_source" "aurora-main source tree" -clang_binary=$(normalize "$toolchain_dir/bin/clang-22") +clang_binary=$(normalize "$toolchain_dir/bin/clang-23") assert_file "$clang_binary" "Portable clang driver binary" (( parallel > 0 )) || parallel=$(nproc) From 602348f90540c23f1dd73cf6e687a434b3afacef Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:00:21 +0200 Subject: [PATCH 06/24] Update Models.cs --- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs index b9c9671..4c8a4b9 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.27"; } /// One installed product's record inside install-state.json. From e34f055b3aa7f88b731f5456a55d21128698b77f Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:55:09 +0200 Subject: [PATCH 07/24] Shortcut (#155) --- .../InstallerEngine.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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) { From be153e0fa0cddd27511dcbca81ae4e5863225ef9 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:07:05 -0400 Subject: [PATCH 08/24] Add Dolphin-compatible input expressions and GCPadNew.ini import, DualSense L/R remapping, vibration toggle (#89) * Add Dolphin-compatible input expressions and GCPadNew.ini import Rebased onto current main; addresses both CodeRabbit reviews on #89. - Expression engine matching Dolphin's semantics: doubles rather than booleans, 0.5 press threshold, & as min, | as max, and the functions if, min, max, clamp, abs, sqrt, pow, sin, cos, tan, deadzone, timer, toggle, hold, tap, pulse and smooth. Timing uses a steady clock in seconds, as Dolphin does, so a copied expression behaves identically. - Expressions bind to the GameCube buttons and triggers, combined with the existing button mapping rather than replacing it, and are skipped while the settings overlay holds input. - Import reads [GCPadN] from the Dolphin config directory or from GCPadNew.ini beside the executable. Stick axes are not expression driven and keep their normal mapping. - Fixes #74: a digital button bound to L or R now reports a fully pulled analog trigger, plus a PlayStation preset and a vibration toggle. Review fixes: config paths round-trip through RuntimeConfigFile::PathToUtf8 and PathFromUtf8 so non-ASCII paths open correctly on Windows, and the duplicated exists branch is gone; the tap count is clamped before the unsigned conversion; the expression editor uses resizable storage via ImGuiInputTextFlags_CallbackResize so a long expression cannot be saved truncated; clamp bounds are ordered before std::clamp; is included for std::strtod; non-finite values are rejected at the evaluator boundary as well as at the deadzone and timer divisions; and InputBindings::Reload() runs from InitializeRuntimeSettings rather than the vibration handler. runtime/tests/test_expr.cpp covers operator precedence, each stateful function and every case raised in review. Third review round: smooth() guards NaN as well as infinity so a zero rate cannot latch a non-finite value in node state; division evaluates both operands so stateful functions in the left subtree still update when the divisor is zero; the expression editor clears stale errors when the port changes; and runtime/tests/test_expr.cpp is registered with CTest as mkw_input_expr_tests, following the existing test targets. * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.md | 14 +- runtime/CMakeLists.txt | 9 + runtime/include/controller_button_names.h | 159 ++++++ runtime/include/input_bindings.h | 67 +++ runtime/include/input_expr.h | 53 ++ runtime/include/runtime_config.h | 33 ++ runtime/src/hle/input/pad.cpp | 68 +++ runtime/src/input_bindings.cpp | 302 +++++++++++ runtime/src/input_expr.cpp | 631 ++++++++++++++++++++++ runtime/src/settings_overlay.cpp | 225 +++++--- runtime/tests/test_expr.cpp | 219 ++++++++ 11 files changed, 1693 insertions(+), 87 deletions(-) create mode 100644 runtime/include/controller_button_names.h create mode 100644 runtime/include/input_bindings.h create mode 100644 runtime/include/input_expr.h create mode 100644 runtime/src/input_bindings.cpp create mode 100644 runtime/src/input_expr.cpp create mode 100644 runtime/tests/test_expr.cpp diff --git a/README.md b/README.md index 3201692..7317b86 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ Press **F10** while the game window has focus: - Internal resolution - FPS counter - Controller assignment for all four ports -- Full per-controller button mapping +- Full per-controller button mapping, including the bumpers +- Dolphin-syntax input expressions and GCPadNew.ini import +- Controller vibration on/off - Volume, instant mute, and the music ducking toggle Everything you change is saved to `Config.toml` on the spot and restored next launch. @@ -56,6 +58,16 @@ 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 (`if`, `min`, `max`, `clamp`, `timer`, `toggle`, `hold`, +`tap`, `pulse`, `smooth`, `deadzone`), evaluated against the same wall-clock timing. A Dolphin +`GCPadNew.ini` can be imported directly from the F10 bar. Stick axes are not covered by expressions +and keep their normal mapping. + +**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). diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6fd79e2..be057c7 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -277,6 +277,15 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +# 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/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/runtime_config.h b/runtime/include/runtime_config.h index 7606677..c8cdf80 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +90,8 @@ struct RuntimeUserConfig { // comma-separated SDL-style physical button names ("south", or // "dpad_up,left_shoulder") as values; pressing either bound button counts. std::array, 12> controllerButtons; + std::optional rumbleEnabled; + std::map controllerExpressions; }; namespace RuntimeConfigFile { @@ -407,6 +410,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"); @@ -677,6 +691,25 @@ inline bool SetControllerButton(size_t index, std::string value) { return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value)); } +inline std::string ControllerExpression(const std::string& key) { + const auto it = Get().controllerExpressions.find(key); + return it == Get().controllerExpressions.end() ? std::string() : it->second; +} + +inline bool SetControllerExpression(const std::string& key, const std::string& value) { + Mutable().controllerExpressions[key] = value; + return WriteSetting("controller", key, FormatString(value)); +} + +inline bool RumbleEnabled(bool fallback = true) { + return Get().rumbleEnabled.value_or(fallback); +} + +inline bool SetRumbleEnabled(bool value) { + Mutable().rumbleEnabled = value; + return WriteSetting("controller", "rumble", value ? "true" : "false"); +} + inline bool SetAudioVolume(float value) { value = std::clamp(value, 0.0f, 1.0f); Mutable().audioVolume = value; 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/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 f2e753f..0429be6 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" @@ -34,6 +36,8 @@ #endif #include + +extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled); #include #include #include @@ -65,6 +69,7 @@ const char* GraphicsApiDisplayName() { } bool g_topBarVisible = false; +bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true); int g_controllerPort = 0; float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f); int g_audioVolumePercent = static_cast(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f)); @@ -106,62 +111,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 @@ -178,6 +130,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; @@ -225,43 +184,25 @@ void LimitResolutionForFrameRate() { } } -const NativeButtonItem* FindNativeButton(std::string value) { - const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) { - return value == item.configName; - }); - return it == kNativeButtons.end() ? nullptr : &*it; -} +using ControllerNames::FindNativeButton; struct ControllerBindingPair { std::string primary; std::string secondary; }; -std::string TrimBindingToken(const std::string& token) { - const size_t begin = token.find_first_not_of(" \t"); - if (begin == std::string::npos) { - return {}; - } - const size_t end = token.find_last_not_of(" \t"); - return token.substr(begin, end - begin + 1); -} // Config values hold up to two comma-separated button names ("dpad_up" or // "dpad_up,left_shoulder"); pressing either one counts as the GC button. ControllerBindingPair SplitControllerBinding(const std::string& value) { const size_t comma = value.find(','); if (comma == std::string::npos) { - return {TrimBindingToken(value), {}}; + return {ControllerNames::TrimToken(value), {}}; } - return {TrimBindingToken(value.substr(0, comma)), TrimBindingToken(value.substr(comma + 1))}; + return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))}; } -const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { - const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) { - return nativeButton == item.nativeButton; - }); - return it == kNativeButtons.end() ? kNativeButtons.front() : *it; -} +using ControllerNames::NativeButtonForValue; void SetTopBarVisible(bool visible) { if (g_topBarVisible == visible) { @@ -393,10 +334,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) { } ImGui::EndDisabled(); ImGui::SameLine(); - if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) { + if (WiiRemoteInput::IsScanning()) { ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount()); - } else if (WiiRemoteInput::IsScanning()) { - ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote"); } else { ImGui::TextDisabled("Not scanning"); } @@ -457,6 +396,105 @@ 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 defined(_WIN32) + for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + Wup028Adapter::SetRumble(port, false); + } +#endif + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Applies to every port."); + } +} + void DrawControllerSettings() { for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) { const std::string label = "Port " + std::to_string(port + 1); @@ -545,20 +583,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"); @@ -640,6 +686,8 @@ void DrawControllerSettings() { ImGui::TextUnformatted(kControllerButtons[i].label); ImGui::PopID(); } + DrawExpressionSettings(); + DrawRumbleSettings(); } void DrawAudioSettings() { @@ -994,6 +1042,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); @@ -1014,6 +1064,7 @@ void InitializeRuntimeSettings() noexcept { g_strapInputAccepted.store(false, std::memory_order_relaxed); g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed); PADBlockInput(false); + InputBindings::SetInputBlocked(false); } void HandleEvents(const AuroraEvent* events) noexcept { @@ -1056,7 +1107,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/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; +} From 8c6c177857e5000acf0a70cc447e5a5137122181 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:11:48 +0200 Subject: [PATCH 09/24] Simplify README by removing redundant input details --- README.md | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7317b86..d616cb4 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,8 @@ inputs like paddles, touchpads and share buttons show up when the hardware repor **Dolphin-compatible input expressions.** Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators -(`!` `&` `|` `^`) and the same functions (`if`, `min`, `max`, `clamp`, `timer`, `toggle`, `hold`, -`tap`, `pulse`, `smooth`, `deadzone`), evaluated against the same wall-clock timing. A Dolphin -`GCPadNew.ini` can be imported directly from the F10 bar. Stick axes are not covered by expressions -and keep their normal mapping. +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. @@ -73,20 +71,7 @@ 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 From 6eba523d70c9ed2e64bbefcb2092ef55b2f64905 Mon Sep 17 00:00:00 2001 From: theofficialgman <28281419+theofficialgman@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:17:21 -0400 Subject: [PATCH 10/24] Switch back to LLVM 22 so build can succeed on at least some systems (#141) two upstream LLVM bugs currently prevent building on some of the newest distros. There is no current LLVM release that works on them so we are pending fixes from LLVM https://github.com/patchzyy/Wiicompiled/issues/136 --- Launcher/Prepare-NativePrebuilt.sh | 2 +- Launcher/prepare-portable-tools.sh | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Launcher/Prepare-NativePrebuilt.sh b/Launcher/Prepare-NativePrebuilt.sh index df2e100..3d4e6ed 100755 --- a/Launcher/Prepare-NativePrebuilt.sh +++ b/Launcher/Prepare-NativePrebuilt.sh @@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja" assert_file "$cc" "Portable C compiler" assert_file "$cxx" "Portable C++ compiler" assert_dir "$aurora_source" "aurora-main source tree" -clang_binary=$(normalize "$toolchain_dir/bin/clang-23") +clang_binary=$(normalize "$toolchain_dir/bin/clang-22") assert_file "$clang_binary" "Portable clang driver binary" (( parallel > 0 )) || parallel=$(nproc) diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh index fe7b42a..8e812ff 100755 --- a/Launcher/prepare-portable-tools.sh +++ b/Launcher/prepare-portable-tools.sh @@ -25,7 +25,7 @@ set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) workspace=$(cd "$script_dir/.." && pwd) -llvm_version=23.1.0 +llvm_version=22.1.8 cmake_version=4.3.3 ninja_version=1.13.2 destination="$script_dir/artifacts/portable-tools" @@ -52,13 +52,13 @@ done case "$arch" in x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu - llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c + llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384 cmake_release_arch=x86_64 cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802 ninja_asset=ninja-linux.zip ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;; aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu - llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4 + llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34 cmake_release_arch=aarch64 cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4 ninja_asset=ninja-linux-aarch64.zip @@ -120,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain.. # clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke. # Stripped: debug symbols are dead weight for a bundled compiler nobody will debug. -cp -a "$src/bin/clang-23" "$work/bin/" -strip "$work/bin/clang-23" -ln -s clang-23 "$work/bin/clang" +cp -a "$src/bin/clang-22" "$work/bin/" +strip "$work/bin/clang-22" +ln -s clang-22 "$work/bin/clang" ln -s clang "$work/bin/clang++" # lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first - From 3f7fed48c93c966b1ffa3eafa78957273b320973 Mon Sep 17 00:00:00 2001 From: Cristian Boehm <75502884+GalaxisBeast@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:20:15 -0400 Subject: [PATCH 11/24] Fix GC Pocket+ rumble stop handling (#148) --- aurora-main/lib/input.cpp | 9 +++++++++ aurora-main/lib/input.hpp | 1 + 2 files changed, 10 insertions(+) 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; From 009697fb977a235e250dd6a7ae1a96d86902a072 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:31:53 +0200 Subject: [PATCH 12/24] Update network_socket.cpp --- runtime/src/hle/net/network_socket.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From a67069afd3035bea91a1253e9f2a19ee2bcd1a6c Mon Sep 17 00:00:00 2001 From: Michael G <10155689+DarthMDev@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:45:29 -0400 Subject: [PATCH 13/24] fix(macos): increase guest fiber stack size (#154) --- runtime/src/fiber_manager.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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))); From 56db6ba641739472e1939abf571e013b176b272b Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:09:34 +0200 Subject: [PATCH 14/24] v0.2.28 --- Launcher/Build-Installer.ps1 | 2 +- .../WiiCompiled.Setup.Common.Cli.csproj | 2 +- .../WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj | 2 +- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 27058cb..ec95fbf 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.27' + ProductVersion = '0.2.28' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 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..4f7cad6 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.28 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..7cbc650 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.28 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 4c8a4b9..59b4984 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.27"; + public const string Version = "0.2.28"; } /// 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..6c68fc0 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.28 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 4f59f7a..42e5005 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.27"; + public const string Version = "0.2.28"; /// /// 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 48fb916..af088e3 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.27 + 0.2.28 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled From 5d67b229f6c94461e7156fdf397638f2aa873442 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:54:24 +0200 Subject: [PATCH 15/24] Update settings_overlay.cpp --- runtime/src/settings_overlay.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 0429be6..8856ba5 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -483,11 +483,6 @@ void DrawRumbleSettings() { PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, }; PADControlAllMotors(stopAll.data()); -#if defined(_WIN32) - for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { - Wup028Adapter::SetRumble(port, false); - } -#endif } } if (ImGui::IsItemHovered()) { From d1d80613cce30c98a2f5ed872dcd22241f1363a0 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:00:23 +0200 Subject: [PATCH 16/24] Load console identity from NAND setting.txt (#164) --- runtime/CMakeLists.txt | 5 ++ runtime/include/console_identity.h | 109 +++++--------------------- runtime/include/nand_settings.h | 69 ++++++++++++++++ runtime/src/hle/sc.cpp | 44 +++++++---- runtime/tests/nand_settings_tests.cpp | 65 +++++++++++++++ 5 files changed, 188 insertions(+), 104 deletions(-) create mode 100644 runtime/include/nand_settings.h create mode 100644 runtime/tests/nand_settings_tests.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index be057c7..df8418e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -277,6 +277,11 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp") +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) + # The input expression engine is self-contained, so it can be exercised without # linking the runtime or SDL. add_executable(mkw_input_expr_tests 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/nand_settings.h b/runtime/include/nand_settings.h new file mode 100644 index 0000000..4d9d947 --- /dev/null +++ b/runtime/include/nand_settings.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace RuntimeNandSettings { + +using Settings = std::map; + +// 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(nandRoot / "title/00000001/00000002/data/setting.txt", + 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; +} + +} // namespace RuntimeNandSettings diff --git a/runtime/src/hle/sc.cpp b/runtime/src/hle/sc.cpp index 62c4411..e587455 100644 --- a/runtime/src/hle/sc.cpp +++ b/runtime/src/hle/sc.cpp @@ -12,7 +12,25 @@ namespace { -constexpr uint32_t kPalProductRegion = 2; +// Use the SDK's own value tables, including its unknown-region result. +uint32_t LookupProductRegion(uint32_t table, uint32_t stride, uint32_t count, + const std::string& value) { + for (uint32_t index = 0; index < count; ++index) { + const uint32_t entry = table + index * stride; + if (!Memory::Contains(entry, stride)) { + break; + } + const auto* bytes = static_cast(Memory::GetPointer(entry, stride)); + if (bytes[0] == 0xFF) { + break; + } + if (value.size() < stride - 1 && + std::memcmp(bytes + 1, value.c_str(), value.size() + 1) == 0) { + return bytes[0]; + } + } + return 0xFFFFFFFFu; +} } // namespace @@ -50,16 +68,12 @@ extern "C" uint32_t SCGetEuRgb60Mode_HLE() PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ()); -// The managed NAND intentionally starts without a console-owned setting.txt. -// DWC nevertheless requires the Wii product code and serial number so it can -// include csnum in NAS authentication. Expose one stable virtual-console -// identity without requiring or mutating a user's real NAND. +// Expose the selected emulated NAND identity through the SDK SC APIs. extern "C" uint32_t SCGetProductArea_HLE() { - // The PAL setting.txt AREA value is "EUR". The SDK's lookup table at - // 0x8029CEB0 maps JPN=0, USA=1, EUR=2. - return kPalProductRegion; + return LookupProductRegion(0x8029CEB0u, 5, 13, + RuntimeConsoleIdentity::Current().area); } PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ()); @@ -68,12 +82,13 @@ extern "C" uint32_t SCGetProductCode_HLE() { // Original PAL SC storage for the six-byte CODE value. constexpr uint32_t kProductCodeAddress = 0x803869E0u; - static constexpr char kProductCode[] = "LEH"; - if (!Memory::Contains(kProductCodeAddress, sizeof(kProductCode))) { + const std::string& productCode = RuntimeConsoleIdentity::Current().productCode; + const size_t size = productCode.size() + 1; + if (!Memory::Contains(kProductCodeAddress, size)) { return 0; } - std::memcpy(Memory::GetPointer(kProductCodeAddress, sizeof(kProductCode)), - kProductCode, sizeof(kProductCode)); + std::memcpy(Memory::GetPointer(kProductCodeAddress, size), + productCode.c_str(), size); return kProductCodeAddress; } @@ -94,9 +109,8 @@ PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddr extern "C" uint32_t SCGetProductGameRegion_HLE() { - // The PAL setting.txt GAME value is "EU". The SDK's own lookup table at - // 0x8029CEF8 maps JP=0, US=1, EU=2. - return kPalProductRegion; + return LookupProductRegion(0x8029CEF8u, 4, 4, + RuntimeConsoleIdentity::Current().gameRegion); } PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ()); diff --git a/runtime/tests/nand_settings_tests.cpp b/runtime/tests/nand_settings_tests.cpp new file mode 100644 index 0000000..12a8140 --- /dev/null +++ b/runtime/tests/nand_settings_tests.cpp @@ -0,0 +1,65 @@ +#include "nand_settings.h" + +#include +#include +#include + +static void Require(bool condition) { + if (!condition) { + throw std::runtime_error("NAND settings check failed"); + } +} + +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 { + Require(!RuntimeNandSettings::Read(root)); + Require(!std::filesystem::exists(root)); + std::filesystem::create_directories(path.parent_path()); + const std::string plain = "AREA=USA\r\n\nCODE=LU\r\nSERNO=987654321\r\nGAME=US\r\n"; + std::array 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"); + 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)); + 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; + } +} From 1c0a3edee9a97deff13ea92fb023ee3c577cfb8c Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:00:47 +0200 Subject: [PATCH 17/24] Add synthetic recompilation CI workflow (#161) --- .github/workflows/build.yml | 4 + .github/workflows/package.yml | 6 +- .github/workflows/recomp-test.yml | 41 ++++++++ Launcher/Test-Recompilation.ps1 | 146 +++++++++++++++++++++++++++++ Launcher/prepare-portable-tools.sh | 26 ++--- 5 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/recomp-test.yml create mode 100644 Launcher/Test-Recompilation.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f258e14..267a34e 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 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 742418f..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: @@ -93,7 +97,7 @@ jobs: release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [linux-appimage, windows-installer] + needs: [linux-appimage, windows-installer, recompilation] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/recomp-test.yml b/.github/workflows/recomp-test.yml new file mode 100644 index 0000000..0908d15 --- /dev/null +++ b/.github/workflows/recomp-test.yml @@ -0,0 +1,41 @@ +name: Synthetic recompilation + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + name: Windows runtime (synthetic DOL) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + # Cache downloads only. Preparation still validates pins, and every run + # compiles current Aurora, runtime, and generated sources from scratch. + - uses: actions/cache@v5 + with: + path: Launcher/artifacts/downloads + key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }} + + - name: Prepare the shipped Windows toolchain + shell: pwsh + run: ./Launcher/Prepare-PortableTools.ps1 + + - name: Prepare pinned native dependencies + shell: pwsh + run: ./Launcher/Prepare-Dependencies.ps1 + + - name: Translate, compile the full runtime, and link + shell: pwsh + run: ./Launcher/Test-Recompilation.ps1 -Parallel 3 diff --git a/Launcher/Test-Recompilation.ps1 b/Launcher/Test-Recompilation.ps1 new file mode 100644 index 0000000..9a8d8a2 --- /dev/null +++ b/Launcher/Test-Recompilation.ps1 @@ -0,0 +1,146 @@ +# Build the real Windows runtime with translated, entirely synthetic PowerPC code. +# No game dump, game symbol map, REL, mod download, or existing generated/ output is used. +[CmdletBinding()] +param( + [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools', + [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies', + [string]$StageDirectory = 'build/recomp-test', + [ValidateRange(1, 64)] [int]$Parallel = 3 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +function Full([string]$Path) { + if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) } + return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) +} +$portableTools = Full $PortableToolsDirectory +$dependencies = Full $DependencySourceDirectory +$stage = Full $StageDirectory +$dotnet = (Get-Command dotnet -CommandType Application).Source +$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe' +$ninja = Join-Path $portableTools 'Ninja/ninja.exe' +$compilerBin = Join-Path $portableTools 'llvm-mingw/bin' +Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)' +Assert-File $ninja 'Pinned Ninja' +Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)' + +# Refuse reuse so a developer's game translation or an earlier build cannot make +# the test pass. Keep the staging tree after the run for diagnostics. +if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" } +[IO.Directory]::CreateDirectory($stage) | Out-Null +Write-Host "Synthetic recompilation workspace: $stage" + +# Copy current sources, including uncommitted edits, but no ignored build output. +# An isolated workspace preserves the developer's real generated/ directory. +$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main +if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' } +foreach ($relative in $sourceFiles | Sort-Object -Unique) { + $destination = Join-Path $stage $relative + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination +} + +# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr. +# Native HLE wrappers also call these eight guest symbols directly. Give each +# its own generated blr function so the real product can link without game code. +# Keep this list explicit: a new unresolved guest dependency must fail the test. +[uint32]$entry = 0x80001000L +[uint32[]]$guestCallbacks = @( + 0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL, + 0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL +) +$textSize = [int]($guestCallbacks[-1] - $entry + 4) +$dataOffset = 0x100 + $textSize +$dol = [byte[]]::new($dataOffset + 4) +function Write-BigEndian32([int]$Offset, [uint32]$Value) { + $dol[$Offset] = [byte](($Value -shr 24) -band 255) + $dol[$Offset + 1] = [byte](($Value -shr 16) -band 255) + $dol[$Offset + 2] = [byte](($Value -shr 8) -band 255) + $dol[$Offset + 3] = [byte]($Value -band 255) +} +Write-BigEndian32 0x00 0x100 # text[0] file offset +Write-BigEndian32 0x48 $entry # text[0] guest address +Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero) +Write-BigEndian32 0x1C $dataOffset # data[0] file offset +Write-BigEndian32 0x64 0x80600000L # data[0] guest address +Write-BigEndian32 0xAC 4 # data[0] length +Write-BigEndian32 0xD8 0x80601000L # BSS address +Write-BigEndian32 0xDC 32 # BSS length +Write-BigEndian32 0xE0 $entry # entry point +Write-BigEndian32 0x100 0x38600028 # li r3,40 +Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2 +Write-BigEndian32 0x108 0x60000000 # nop +Write-BigEndian32 0x10C 0x4E800020 # blr +foreach ($address in $guestCallbacks) { + Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020 +} +Write-BigEndian32 $dataOffset 0x12345678 +[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol) +$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', ' +$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n" +[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap) +$manifest = Join-Path $stage 'recomp.yml' +[IO.File]::WriteAllText($manifest, @" +schema_version: 1 +workspace_root: . +project: + id: ci-synthetic-dol + display_name: CI Synthetic DOL +memory: + base: 0x80000000 + size: 0x01800000 + sda_base: 0x80600000 + sda2_base: 0x80600000 +inputs: + dol: + path: synthetic.dol +translation: + entry_points: [$entryPoints] + function_map: + path: synthetic-functions.txt + allow_unsupported_instructions: false +runtime: + native_abi_directories: [] + native_registration_root: runtime/src +output: + root: generated +"@) + +$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj' +Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator' +$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll' +$metadata = Join-Path $stage 'generated/base_translation_output.json' +Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest, + '--output-metadata', $metadata, '--threads', "$Parallel") ` + 'Translating the synthetic DOL' +# Function-map seeds can be skipped by discovery; do not accept a partial fixture. +$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json +foreach ($address in @($entry) + $guestCallbacks) { + if ($address -notin $translated.functions.entryPoint) { + throw ('Synthetic function 0x{0:X8} was not translated.' -f $address) + } +} +Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration' +Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph' + +$nativeBuild = Join-Path $stage 'native-build' +$oldPath = $env:PATH +try { + $env:PATH = Get-MkwToolchainPath $portableTools + $configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild ` + -Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') ` + -CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') ` + -ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') ` + -DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON') + Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' + Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") ` + 'Compiling and linking the synthetic product with the full runtime' + Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product' +} finally { + $env:PATH = $oldPath +} +Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).' diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh index 8e812ff..69ea00d 100755 --- a/Launcher/prepare-portable-tools.sh +++ b/Launcher/prepare-portable-tools.sh @@ -201,10 +201,10 @@ Ninja $ninja_version Apache License 2.0 EOF -echo "prepare-portable-tools.sh: smoke-testing the toolchain..." -smoke_dir=$(mktemp -d) -trap 'rm -rf "$smoke_dir"' EXIT -cat > "$smoke_dir/t.cpp" <<'EOF' +echo "prepare-portable-tools.sh: testing the toolchain..." +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cat > "$test_dir/t.cpp" <<'EOF' #include #include int main() { @@ -214,23 +214,23 @@ int main() { return sum == 6 ? 0 : 1; } EOF -"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t" -"$smoke_dir/t" +"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t" +"$test_dir/t" # Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain # clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that # can't find the compiler. -cat > "$smoke_dir/CMakeLists.txt" <<'EOF' +cat > "$test_dir/CMakeLists.txt" <<'EOF' cmake_minimum_required(VERSION 3.16) -project(smoke CXX) -add_executable(smoke t.cpp) +project(test CXX) +add_executable(test t.cpp) EOF -"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \ +"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \ -DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null -"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null -"$smoke_dir/build/smoke" +"$work/bin/cmake" --build "$test_dir/build" >/dev/null +"$test_dir/build/test" -rm -rf "$smoke_dir" +rm -rf "$test_dir" trap - EXIT mv "$work" "$toolchain_dir" From 8e57cc162f48d3f9398cc332929f8ebc9c3ea699 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:48:41 +0200 Subject: [PATCH 18/24] version --- Launcher/Build-Installer.ps1 | 2 +- .../WiiCompiled.Setup.Common.Cli.csproj | 2 +- .../WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj | 2 +- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index ec95fbf..4bb7b63 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.28' + ProductVersion = '0.2.29' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 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 4f7cad6..2519f54 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.28 + 0.2.29 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 7cbc650..417e48a 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.28 + 0.2.29 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 59b4984..8102c7b 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.28"; + public const string Version = "0.2.29"; } /// 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 6c68fc0..57c6a0f 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.28 + 0.2.29 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 42e5005..09ea084 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.28"; + public const string Version = "0.2.29"; /// /// 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 af088e3..d79ecbb 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.28 + 0.2.29 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled From 2d9dc4e0f253735aeff2a14a5348509021362c11 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:14:54 +0200 Subject: [PATCH 19/24] import setting.txt (#169) * import setting.txt * coderabbit ugh --- runtime/CMakeLists.txt | 2 + runtime/include/nand_path.h | 15 ++- runtime/include/nand_settings.h | 155 +++++++++++++++++++++++++- runtime/tests/nand_settings_tests.cpp | 122 +++++++++++++++++++- 4 files changed, 289 insertions(+), 5 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index df8418e..34ff3c3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -278,6 +278,8 @@ target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp") +find_package(Threads REQUIRED) +target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads) target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17) add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests) 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_settings.h b/runtime/include/nand_settings.h index 4d9d947..055f4c2 100644 --- a/runtime/include/nand_settings.h +++ b/runtime/include/nand_settings.h @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include #include #include #include @@ -9,14 +12,23 @@ #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(nandRoot / "title/00000001/00000002/data/setting.txt", - std::ios::binary); + std::ifstream input(FilePath(nandRoot), std::ios::binary); std::array bytes{}; if (!input.read(reinterpret_cast(bytes.data()), bytes.size())) { return std::nullopt; @@ -66,4 +78,143 @@ inline bool HasIdentity(const Settings& settings) { 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/tests/nand_settings_tests.cpp b/runtime/tests/nand_settings_tests.cpp index 12a8140..3231911 100644 --- a/runtime/tests/nand_settings_tests.cpp +++ b/runtime/tests/nand_settings_tests.cpp @@ -3,22 +3,78 @@ #include #include #include +#include +#include -static void Require(bool condition) { +static void Require(bool condition, const char* message = "NAND settings check failed") { if (!condition) { - throw std::runtime_error("NAND settings check failed"); + 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) { @@ -35,6 +91,7 @@ int main() { 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); @@ -54,6 +111,67 @@ int main() { 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; From f424536d3bbee54925dfbadd79769291dd36a757 Mon Sep 17 00:00:00 2001 From: Wubbzee <41394708+JGM01@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:20:51 -0400 Subject: [PATCH 20/24] Treat empty MKW save as missing rather than corrupt (#168) * treat empty mkw save as missing first-run format zero-fills rksys.dat before any real save; a quit before the first save left an all-zero file that read back as corrupt and trapped the user in a delete/recreate loop. read opens now treat an all-zero rksys.dat as absent (a real save always begins with the RKSD0006 header), so the game recreates it from scratch. also ignore native build output. * shorten * I dont really want to change this to be honest. * extra safety --------- Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com> --- .gitignore | 2 + runtime/CMakeLists.txt | 5 + runtime/include/nand_save_probe.h | 59 ++++++++++ runtime/src/hle/storage/nand_api.cpp | 3 + runtime/src/hle/storage/nand_async.cpp | 2 + runtime/src/hle/storage/nand_fs.cpp | 20 ++++ runtime/src/hle/storage/nand_internal.h | 7 ++ runtime/src/hle/storage/nand_isfs.cpp | 3 + runtime/tests/nand_save_tests.cpp | 142 ++++++++++++++++++++++++ 9 files changed, 243 insertions(+) create mode 100644 runtime/include/nand_save_probe.h create mode 100644 runtime/tests/nand_save_tests.cpp diff --git a/.gitignore b/.gitignore index 855cfed..59542aa 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ Code.pul /build/ /build-*/ /native-build/ +/native-build-macos/ +/local-products/ /dist/ /out/ [Bb]in/ diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 34ff3c3..9267a4e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -277,6 +277,11 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +add_executable(mkw_nand_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) 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/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/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; + } +} From 730e3122d53726537d7de55d0b135a5f3f2615e2 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:22:57 +0200 Subject: [PATCH 21/24] version 0.2.30 --- Launcher/Build-Installer.ps1 | 2 +- .../WiiCompiled.Setup.Common.Cli.csproj | 2 +- .../WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj | 2 +- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 4bb7b63..d1ff5dd 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.29' + ProductVersion = '0.2.30' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 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 2519f54..debf346 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.29 + 0.2.30 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 417e48a..fc77bdd 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.29 + 0.2.30 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 8102c7b..217523c 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.29"; + public const string Version = "0.2.30"; } /// 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 57c6a0f..5f83472 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.29 + 0.2.30 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 09ea084..c92a991 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.29"; + public const string Version = "0.2.30"; /// /// 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 d79ecbb..bd5161d 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.29 + 0.2.30 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled From e0e362bd992e07784f8ce7fa795cdb496af7b075 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:28:56 +0200 Subject: [PATCH 22/24] csnum fix --- runtime/CMakeLists.txt | 5 ++ runtime/include/sc_serial_contract.h | 26 +++++++++ runtime/src/hle/sc.cpp | 10 ++-- runtime/tests/sc_serial_tests.cpp | 81 ++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 runtime/include/sc_serial_contract.h create mode 100644 runtime/tests/sc_serial_tests.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 9267a4e..0bee6f1 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -289,6 +289,11 @@ target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST 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 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/src/hle/sc.cpp b/runtime/src/hle/sc.cpp index e587455..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 @@ -97,12 +98,9 @@ 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)); 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; + } +} From 88b990b0602c642fe17cce6996af8c6ad63e663b Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:38:14 +0200 Subject: [PATCH 23/24] update --- Launcher/Build-Installer.ps1 | 2 +- .../WiiCompiled.Setup.Common.Cli.csproj | 2 +- .../WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj | 2 +- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index d1ff5dd..eb70930 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.30' + ProductVersion = '0.2.31' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 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 debf346..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.30 + 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 fc77bdd..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.30 + 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 217523c..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.30"; + 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 5f83472..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.30 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index c92a991..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.30"; + 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 bd5161d..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.30 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled From a135beb201042b20f390c6695ca6b26768820fb4 Mon Sep 17 00:00:00 2001 From: Wubbzee <41394708+JGM01@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:18:53 -0400 Subject: [PATCH 24/24] Reduce CI time using caching (#180) * caching a little bit * provide CMake with the explicit path to sccache.exe * map ACTIONS_RESULTS_URL to ACTIONS_CACHE_URL so sccache can upload the files... * i removed the parallel oops * small change * doing a little bit of flag editing * update sccache and cache nuget stuff --- .github/workflows/build.yml | 8 ++++++++ .github/workflows/recomp-test.yml | 22 +++++++++++++++++++++- .gitignore | 2 ++ Launcher/NativeBuildFlags.ps1 | 22 ++++++++++++++-------- Launcher/Test-Recompilation.ps1 | 6 ++++-- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 267a34e..a156710 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,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/recomp-test.yml b/.github/workflows/recomp-test.yml index 0908d15..3a1244a 100644 --- a/.github/workflows/recomp-test.yml +++ b/.github/workflows/recomp-test.yml @@ -28,6 +28,19 @@ jobs: 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 @@ -36,6 +49,13 @@ jobs: 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 3 + 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 59542aa..0409068 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,5 @@ project.lock.json *.log output.txt +# Operating System +.DS_Store 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 index 9a8d8a2..789e4dd 100644 --- a/Launcher/Test-Recompilation.ps1 +++ b/Launcher/Test-Recompilation.ps1 @@ -136,9 +136,11 @@ try { -CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') ` -ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') ` -DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON') - Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' + Invoke-Checked $cmake $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' + '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