13 Commits

Author SHA1 Message Date
patchzyy 6fb593749e version 2026-09-14 20:36:18 +02:00
theofficialgman 8ec3a1b752 Switch to custom patched llvm 22.1.8 in Linux appimage builder (#214)
* linux prepare-portable-tools: derive llvm and cmake asset paths directly from archive names

* switch to patched LLVM 22.X release

Release assets are extracted and re-uploaded from official CI run on a WIP PR: https://github.com/llvm/llvm-project/actions/runs/34549173201?pr=222821
2026-09-14 18:27:49 +02:00
Nicholas Bly 8705e957c7 Fix autohide cursor + mute hotkey (#211)
* Fix autohide cursor + mute hotkey

* fixes

* Fix rebinding + spacing fixes
2026-09-14 17:00:08 +02:00
theofficialgman 8e0cc96898 Scope Kamek bl-patch LR-continuation detection to genuine skip-return… (#218)
* Scope Kamek bl-patch LR-continuation detection to genuine skip-return targets

Fix crash from Kamek skip-return hooks (Item Rain crash) (#182) added every
Kamek BranchLink patch target to lrContinuationCallTargets unconditionally,
with no filter analogous to the RetroWfcHookSetsLinkRegister check already
used for RetroWFC hooks. Since bl is the ordinary PowerPC call instruction,
this made the codegen treat effectively every patched call in the mod as a
potential skip-return hook, forcing conservative handling (full register
reload, disabled resident-call fast paths, local LR-continuation dispatch
tables) onto thousands of calls that just return normally.

For Retro Rewind this inflated total translated mod size by +42%
(1,414,327 -> 2,005,284 lines), concentrated in ~10 unrelated overlay
functions that happened to call a patched target, and was enough to make
one aggregate build shard pathologically slow to compile (hangs Linux CI).

Instead, only mark a bl target as LR-continuation-aware if a lightweight
discovery-only decode of its own body actually finds evidence of
skip-return behavior via DiscoverLrRelativeIndirectJumpOffsets. Falls back
to the conservative (old) behavior if a target can't be statically
analyzed, so no skip-return case is silently missed.

Verified against the real Retro Rewind mod: total mod size returns to
1,416,350 lines (+0.14% vs. pre-fix, down from +42%), all 6 genuinely new
continuation functions from the original fix are preserved, zero
functions lost, and all 609 existing translator tests still pass.

* Distinguish exhausted from truncated LR-relative offset search

CodeRabbit flagged that TargetExhibitsLrSkipReturn (added in ad2d4e7) treated
an empty DiscoverLrRelativeIndirectJumpOffsets result as a verified "this
target never skip-returns," but the analysis silently drops any path state
once more than MaxStatesPerInstruction (16) distinct states reach one
instruction - a bctr/return on a dropped state can never contribute its
offset, so an empty result could be an incomplete search rather than a real
negative. Treating every capped case as "skip-return possible" outright was
rejected as too broad a fallback given how conservative/expensive that path
already is.

Instead: raise MaxStatesPerInstruction 16 -> 512 (an arbitrary conservative
bound to begin with, not something correctness depended on) so genuinely
branchy functions have far more headroom to reach an exhaustive answer, and
give DiscoverLrRelativeIndirectJumpOffsets an optional onStateCapExceeded
callback that fires exactly when a state is dropped. TargetExhibitsLrSkipReturn
now only falls back to the conservative "treat as skip-return" answer when
the search both found nothing and the cap was actually hit during that run -
not whenever the cap merely exists - so a target is trusted as clean once the
search genuinely exhausts it.

Verified: all 609 translator tests pass, and a full translate-mod run against
the real Retro Rewind mod produces byte-for-byte identical output to the
prior fix (same 4,065 functions, 1,416,350 total lines) - confirming the
16-state cap was never actually the limiting factor in practice and this
change is a pure safety-net closure, not a behavior change for this mod.

* Add LR continuation regression tests

* Refine LR continuation hook analysis

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-09-14 16:47:28 +02:00
jamie 6458ec6abe feat(linux): add --sysroot plumbing for bundled toolchains (#224)
* feat(linux): add --sysroot plumbing for bundled toolchains

* fix(linux): clear cached CMAKE_SYSROOT when --sysroot is omitted

* fix(linux): reject null/empty --sysroot in install command
2026-09-13 22:49:40 +02:00
theofficialgman 209405dfb7 switch to dawn-build fork building on Ubuntu 22.04 rather than 24.04 runners (#215)
fixes https://github.com/patchzyy/Wiicompiled/issues/159
previous requirement for ubuntu 24.04+ libstdc++ inherited from dawn prebuilds now dropped to ubuntu 22.04+ libstdc++ like the rest of the prebuilds

also add all architectures to the URL_HASH check since the dawn tag doesn't change but the binaries have
2026-09-13 14:15:30 +02:00
Nicholas Bly 4bdaff01fc Add Exit button + controller led fix (#221) 2026-09-13 14:07:54 +02:00
devangpratap b555ede2d3 Fix entry point and payload URL in macOS build guide (#222)
translate-recursive now starts at 0x800060A4 (__start) to match recomp.yml and system_bridge.h. The payload curl uses rwfc.net/api/wfc/payload like the rest of the guide, since nas.play.rwfc.net does not answer over https.

Co-authored-by: devangpratap <devangpratap@proton.me>
2026-09-13 10:35:44 +02:00
patchzyy 53d8f71c68 Update README.md 2026-09-12 09:54:51 +02:00
Cristian Boehm 149cfef608 keyboard and mouse support, rebinding overhaul, analog triggers to digital inputs (#162)
* add keyboard support, analog triggers to digital input, and rebinding overhaul

* Update README.md

* Update README.md

readme typo

* implemented code rabbits suggestions

- Preserved NSO GameCube analog triggers.
  - Made modal closure and Escape cancel every rebind kind.
  - Deduced the native button array size; <array> already existed.
  - Centralized axis/sign decoding.
  - Kept threshold updates live, saving only when editing ends.

* add dimming when in settings and add clear mapping button

* Update settings_overlay.cpp

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
2026-09-10 17:37:38 +02:00
Michael G 25c69ae28e Fix crash from Kamek skip-return hooks (Item Rain crash) (#182)
* fix: Kamek LR-continuation hook discovery and dispatch

* test: cover branching Kamek LR continuations

* review fix

* another review fix

fix: get the new tests to pass
test: expose LR restore and loop continuation regressions

* Update translator/src/Translator.Core/Mods/ContinuationPlanner.cs

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

* test: cover continuation regressions from the new path-sensitive planner

* Update translator/src/Translator.Core/Mods/ContinuationPlanner.cs

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

* test: cover continuation regressions from the new path-sensitive planner

* fix: preserve LR continuation analysis across large handlers and clobbers

* fix: track LR-relative r1 across update-form stack stores

* Harden LR-relative continuation test coverage

* Fix LR/SP continuation state tracking

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-10 15:51:16 +02:00
patchzyy 0bb15f0a44 Update building-macos.md 2026-09-10 09:48:03 +02:00
patchzyy 466d06d7db Update README with image and credit modifications
Added an image to the README and updated credits section.
2026-09-10 08:38:04 +02:00
32 changed files with 2296 additions and 253 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
ProductVersion = '0.2.31'
ProductVersion = '0.2.32'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
+2 -2
View File
@@ -39,9 +39,9 @@ $packages = @(
},
[pscustomobject]@{
Name = 'dawn_prebuilt'; File = 'dawn-v20260603.191052-windows-amd64.tar.gz'
Uris = @('https://github.com/encounter/dawn-build/releases/download/v20260603.191052/dawn-windows-amd64.tar.gz')
Uris = @('https://github.com/theofficialgman/dawn-build/releases/download/v20260603.191052/dawn-windows-amd64.tar.gz')
Pins = @(@{ File = $auroraCMake; Text = 'set(AURORA_DAWN_VERSION "v20260603.191052"' },
@{ File = $auroraDawn; Text = 'SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab' })
@{ File = $auroraDawn; Text = 'SHA256=13be9cff8b9b179c42dcd16aeabb6effcc8f0dfdcc14463eda2a5caeda225142' })
},
[pscustomobject]@{
Name = 'fmt'; File = 'fmt-11.1.4.tar.gz'
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
<Version>0.2.31</Version>
<Version>0.2.32</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1</Description>
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
<Version>0.2.31</Version>
<Version>0.2.32</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
@@ -13,7 +13,8 @@ internal static class BuildRunner
string workspace, string profile, string outputDir, string? baseOutputDir,
string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
bool forceCleanBuild, string? translatorBin, string? ccBin, string? cxxBin, string? fuseLd,
string? cmakeBin, string? ninjaBin, string? nativePrebuiltDir, IInstallReporter reporter,
string? cmakeBin, string? ninjaBin, string? nativePrebuiltDir, string? sysroot,
IInstallReporter reporter,
CancellationToken cancellationToken)
{
var script = Path.Combine(workspace, "Launcher", "local-build.sh");
@@ -77,6 +78,10 @@ internal static class BuildRunner
{
startInfo.ArgumentList.Add("--native-prebuilt-dir"); startInfo.ArgumentList.Add(nativePrebuiltDir);
}
if (!string.IsNullOrEmpty(sysroot))
{
startInfo.ArgumentList.Add("--sysroot"); startInfo.ArgumentList.Add(sysroot);
}
using var process = new Process { StartInfo = startInfo };
var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96);
+1 -1
View File
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.31";
public const string Version = "0.2.32";
}
/// <summary>One installed product's record inside install-state.json.</summary>
+11 -1
View File
@@ -143,6 +143,15 @@ internal static class Program
retroWfcOfflineDir = cacheDir;
}
var sysroot = flags.GetValueOrDefault("sysroot");
// --sysroot explicitly provided (even as bare flag at end of argv, which ParseArgs
// stores as null) must carry a path; omitting --sysroot entirely is fine (local-build.sh
// adds -UCMAKE_SYSROOT to clear any stale cached value from a prior configure).
if (flags.ContainsKey("sysroot") && string.IsNullOrWhiteSpace(sysroot))
{
throw new ArgumentException("--sysroot requires a non-empty directory path.");
}
await BuildRunner.RunAsync(
workspace, profile, installDir, baseInstallDir,
retroDir,
@@ -156,6 +165,7 @@ internal static class Program
flags.GetValueOrDefault("cmake"),
flags.GetValueOrDefault("ninja"),
flags.GetValueOrDefault("native-prebuilt-dir"),
sysroot,
reporter, token);
reporter.Progress(InstallStages.Shortcuts, "Creating shortcuts", 98);
@@ -324,7 +334,7 @@ internal static class Program
{--download-retro-wfc-payload | --skip-retro-wfc-payload}]
[--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH]
[--cc PATH] [--cxx PATH] [--fuse-ld NAME_OR_PATH] [--cmake PATH] [--ninja PATH]
[--native-prebuilt-dir DIR] [--progress-json] [--workspace DIR]
[--native-prebuilt-dir DIR] [--sysroot PATH] [--progress-json] [--workspace DIR]
uninstall
launch-base
launch-retro
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<AssemblyName>WiiCompiled.Setup.Linux</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Linux</RootNamespace>
<Version>0.2.31</Version>
<Version>0.2.32</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled on Linux</Description>
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.31";
public const string Version = "0.2.32";
/// <summary>
/// The setup executable is copied into the installation under this name. It is the launcher and
@@ -7,7 +7,7 @@
<AssemblyName>WiiCompiled.Setup</AssemblyName>
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.2.31</Version>
<Version>0.2.32</Version>
<Authors>patchzy</Authors>
<Product>WiiCompiled</Product>
<Description>Command-line installer and launcher for WiiCompiled</Description>
+12
View File
@@ -65,6 +65,7 @@ translator_dll_override=""
translator_bin_override=""
fuse_ld_override=""
native_prebuilt_dir=""
sysroot=""
usage() {
cat <<'EOF'
@@ -87,6 +88,8 @@ Usage: local-build.sh --output-dir DIR [options]
--translator-bin PATH Self-contained Translator.Cli executable (skips building AND needs no dotnet at all)
--native-prebuilt-dir DIR Precompiled aurora/third-party package (see Prepare-NativePrebuilt.sh);
skips compiling aurora-main from source entirely
--sysroot PATH Passed to CMake as -DCMAKE_SYSROOT: where the compiler resolves
standard headers/startup files
EOF
}
@@ -110,6 +113,7 @@ while [[ $# -gt 0 ]]; do
--translator-dll) translator_dll_override=$2; shift 2 ;;
--translator-bin) translator_bin_override=$2; shift 2 ;;
--native-prebuilt-dir) native_prebuilt_dir=$2; shift 2 ;;
--sysroot) sysroot=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown argument: $1" ;;
esac
@@ -414,6 +418,14 @@ fi
if [[ -n "$native_prebuilt_dir" ]]; then
configure_args+=(-DMKW_NATIVE_PREBUILT_DIR="$native_prebuilt_dir")
fi
if [[ -n "$sysroot" ]]; then
configure_args+=(-DCMAKE_SYSROOT="$sysroot")
else
# Explicitly clear any cached CMAKE_SYSROOT from a prior configure so an
# incremental build that transitions from one sysroot to none does not
# silently keep the stale cached path.
configure_args+=(-UCMAKE_SYSROOT)
fi
log_step configure-native "Configuring the native toolchain"
"$cmake_bin" "${configure_args[@]}"
+11 -7
View File
@@ -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=fccecb1906e7ddf5ec040aec5b646b650e2daaafa4423b41341c4717db5bdec0
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=d431eff9f064c86ee7c4c94af570a8f74fcccd1f74c6f0da3af32ce34a1e1b05
cmake_release_arch=aarch64
cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4
ninja_asset=ninja-linux-aarch64.zip
@@ -101,11 +101,14 @@ rm -rf "$work"
mkdir -p "$work/bin" "$work/lib/$target_triple" "$work/include/$target_triple/c++/v1"
# --- clang/lld/llvm-ar, pruned from the official LLVM release ---
llvm_archive_name="LLVM-$llvm_version-Linux-$llvm_release_arch.tar.xz"
# built from PR https://github.com/llvm/llvm-project/pull/222821 on official LLVM Github Actions Runner
# only switch to an official stable LLVM release again once:
# - this PR has merged https://github.com/llvm/llvm-project/pull/221365 and been backported to LLVM stable branch
# - this bug has been fixed with a workaround in the Wiicompiled translator https://github.com/patchzyy/Wiicompiled/issues/208 or in LLVM and been backported to LLVM stable branch
llvm_archive_name="LLVM-PR222821-5ae1c7c43a11b4cdc5ce4dd483c28357bab7dae2-Linux-$llvm_release_arch.tar.xz"
llvm_archive="$downloads/$llvm_archive_name"
download_verified "$llvm_archive" \
"https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvm_version/$llvm_archive_name" \
"https://github.com/theofficialgman/llvm-project/releases/download/llvmorg-22.1.8-patched/$llvm_archive_name" \
"$llvm_release_sha256"
extract_root="$script_dir/artifacts/.extract-clang-$arch"
@@ -113,7 +116,7 @@ rm -rf "$extract_root"
mkdir -p "$extract_root"
echo "prepare-portable-tools.sh: extracting $llvm_archive_name (this is the full ~1.9 GiB release; only a fraction is kept)..."
tar -xf "$llvm_archive" -C "$extract_root"
src="$extract_root/LLVM-$llvm_version-Linux-$llvm_release_arch"
src="$extract_root/${llvm_archive_name%.tar.xz}"
[[ -d "$src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $src" >&2; exit 1; }
echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain..."
@@ -165,7 +168,8 @@ rm -rf "$cmake_extract_root"
mkdir -p "$cmake_extract_root"
echo "prepare-portable-tools.sh: extracting $cmake_archive_name..."
tar -xzf "$cmake_archive" -C "$cmake_extract_root"
cmake_src="$cmake_extract_root/cmake-$cmake_version-linux-$cmake_release_arch"
cmake_src="$cmake_extract_root/${cmake_archive_name%.tar.gz}"
[[ -d "$cmake_src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $cmake_src" >&2; exit 1; }
mkdir -p "$work/share/cmake-$cmake_share_version"
+28 -1
View File
@@ -1,6 +1,19 @@
<img width="4190" height="1232" alt="wiicomplogofinalfinalfinalev2MADEBY_INKWRECK_plzcredit" src="https://github.com/user-attachments/assets/df7a3f2e-5336-479a-b4c0-968dd578726d" />
# WiiCompiled
<p align="center">
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Windows 10 / 11, x64" src="https://img.shields.io/badge/Windows-10%20%2F%2011%20%C2%B7%20x64-0078D4"></a>
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Linux, x64 / ARM64" src="https://img.shields.io/badge/Linux-x64%20%2F%20ARM64-FCC624?logo=linux&amp;logoColor=white"></a>
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="macOS 14+, Apple Silicon" src="https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20Silicon-0A84FF?logo=apple&amp;logoColor=white"></a>
</p>
<p align="center">
<a href="#building-from-source"><img alt="PowerPC static recompilation" src="https://img.shields.io/badge/PowerPC-static%20recompilation-FF9F0A"></a>
<a href="#retro-rewind"><img alt="Retro Rewind supported" src="https://img.shields.io/badge/Retro%20Rewind-supported-FF375F"></a>
<a href="https://github.com/TeamWheelWizard/WheelWizard/releases"><img alt="Install with Wheel Wizard" src="https://img.shields.io/badge/install%20with-Wheel%20Wizard-8B5CF6"></a>
<a href="LICENSE"><img alt="License: GPLv3" src="https://img.shields.io/badge/license-GPLv3-2EA44F?logo=gnu&amp;logoColor=white"></a>
</p>
A native PC port of Mario Kart Wii, made with static recompilation.
There's no emulator in the loop, no interpreter, no JIT, no PowerPC
@@ -54,10 +67,24 @@ Press **F10** while the game window has focus:
Everything you change is saved to `Config.toml` on the spot and restored next launch.
**Real controller support.**
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.
Both button-binding slots also accept SDL triggers and stick directions. Selecting an analog
input shows a threshold slider beneath it (1100%, default 50%); reaching that amount of travel
holds the chosen digital button. Each binding's threshold is saved independently in `Config.toml`
(for example, `a = "right_trigger@35,south"`).
**Keyboard and Mouse support.**
Keyboard and mouse are also available through **F10 > Controller settings > Keyboard and mouse**
for each port. Enabling this replaces that port's gamepad input. The default preset uses WASD
for the main stick, left mouse for A (accelerate), Space for B (brake), right mouse for R
(drift), middle mouse for Z (item), arrow keys for the D-pad (tricks), and Enter for Start.
Keys and mouse buttons can be remapped, including both sticks and triggers; mouse movement
is not used. These settings are saved in `keyboard_bindings.dat` and restored next launch.
**Dolphin-compatible input expressions.**
Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
@@ -201,7 +228,7 @@ AI coding tools were used during development of this project.
All translated output is verified against real hardware behavior and most importantly, physics accuracy is proven synced across Wii, Dolphin, and WiiCompiled (see FAQ).
## Credits
- **inkwreck** - making the logo
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
project's whole graphics layer sits on. MIT licensed.
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
+27 -6
View File
@@ -151,15 +151,36 @@ elseif (_aurora_dawn_provider STREQUAL "package")
endif ()
endif ()
set(AURORA_DAWN_PACKAGE_URL
"https://github.com/encounter/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
"https://github.com/theofficialgman/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
# A release asset is mutable: the same tag has already served two different windows-amd64 archives,
# and a cached extraction is never re-verified. Pin the digest for the combinations we ship.
if (NOT AURORA_DAWN_PACKAGE_URL_HASH
AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052"
AND _dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "amd64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab")
if (NOT AURORA_DAWN_PACKAGE_URL_HASH AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052")
if (_dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "amd64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=13be9cff8b9b179c42dcd16aeabb6effcc8f0dfdcc14463eda2a5caeda225142")
elseif (_dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "arm64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=bf2d921110f14a1d6553f673c5597988e66c02af5587e4a1fee167937d247734")
elseif (_dawn_system STREQUAL "linux" AND _dawn_arch STREQUAL "x86_64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=7adcf241bb2a24ec0c576609f2d67203e0e65db9c5a286ca2bbb6281fa644b35")
elseif (_dawn_system STREQUAL "linux" AND _dawn_arch STREQUAL "aarch64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=2415e253d46f91b2d72fc73bf6055fb31b98b67c773dc546e1991b1cf019732f")
elseif (_dawn_system STREQUAL "darwin" AND _dawn_arch STREQUAL "arm64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=0a8ea8eb0159fc0ba1083c52155d9376fb173cffe690b400464a6ad8881bb461")
elseif (_dawn_system STREQUAL "darwin" AND _dawn_arch STREQUAL "x86_64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=5fe2c7a2a8b4cb82acee4af16779a83ae333c7657b9dc1a5008f5fd1f5ad5f80")
elseif (_dawn_system STREQUAL "ios" AND _dawn_arch STREQUAL "arm64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=f97701d26fd1f25bbcc260b4c31736ede134c730c12556029e2470fde967f424")
elseif (_dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64")
set(AURORA_DAWN_PACKAGE_URL_HASH
"SHA256=0e63e8cbf53551f703f582d1306f4257c0380353f66b53369d96952ce6d9f934")
endif ()
endif ()
endif ()
message(STATUS "aurora: Fetching prebuilt Dawn package from ${AURORA_DAWN_PACKAGE_URL}")
+16
View File
@@ -171,6 +171,22 @@ typedef struct PADButtonMapping {
PADButton padButton;
} PADButtonMapping;
// Explicitly disabled, unlike INVALID which permits default L/R trigger input.
#define PAD_NATIVE_BUTTON_DISABLED 0xfffffffeu
// Axis-to-button bindings share the persisted nativeButton field without
// changing the binary layout of existing controller mapping files.
constexpr u32 PADEncodeAxisButton(u32 axis, bool negative, u32 threshold = 50) {
return 0x10000u | axis | (negative ? 0x80u : 0u) | (threshold << 8);
}
constexpr bool PADIsAxisButton(u32 binding) { return (binding & 0xffff0000u) == 0x10000u; }
constexpr u32 PADAxisButtonThreshold(u32 binding) { return (binding >> 8) & 0xffu; }
constexpr u32 PADAxisButtonAxis(u32 binding) { return binding & 0x7fu; }
constexpr bool PADAxisButtonNegative(u32 binding) { return (binding & 0x80u) != 0; }
constexpr u32 PADAxisButtonIdentity(u32 binding) {
return PADIsAxisButton(binding) ? (binding & ~0xff00u) : binding;
}
typedef struct PADAxisMapping {
PADSignedNativeAxis nativeAxis;
s32 nativeButton;
+39 -10
View File
@@ -319,6 +319,18 @@ std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; }
bool is_native_binding_pressed(SDL_Gamepad* gamepad, u32 binding) {
if (PADIsAxisButton(binding)) {
const u32 axis = PADAxisButtonAxis(binding);
const u32 threshold = PADAxisButtonThreshold(binding);
if (axis >= SDL_GAMEPAD_AXIS_COUNT || threshold < 1 || threshold > 100) return false;
int value = SDL_GetGamepadAxis(gamepad, static_cast<SDL_GamepadAxis>(axis));
if (PADAxisButtonNegative(binding)) value = -value;
return value > 0 && value * 100 >= static_cast<int>(threshold) * 32767;
}
return binding < SDL_GAMEPAD_BUTTON_COUNT &&
SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(binding));
}
bool is_mouse_button_pressed(const s32 scancode) {
const int32_t buttonNum = -(scancode + 1);
if (buttonNum < 1 || buttonNum > 5) {
@@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) {
}
status[i].err = PAD_ERR_NONE;
if (g_keyboardBindings[i].m_mappingsSet) {
if (g_keyboardBindings[i].m_mappingsSet && SDL_GetKeyboardFocus() != nullptr) {
std::ranges::for_each(
g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) {
if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) {
g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) {
if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) {
status[i].button |= mapping.padButton;
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
status[i].button |= mapping.padButton;
@@ -788,7 +800,7 @@ u32 PADRead(PADStatus* status) {
status[i].triggerRight = static_cast<u8>(std::min(static_cast<int>(status[i].triggerRight) + tr, 255));
}
if (controller) {
if (controller && !g_keyboardBindings[i].m_mappingsSet) {
EnsureMappingLoaded(controller);
// Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts
@@ -835,7 +847,7 @@ u32 PADRead(PADStatus* status) {
bool rightTriggerSet = false;
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
&rightTriggerSet](const auto& mapping) {
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
status[i].button |= mapping.padButton;
}
@@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) {
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
return;
}
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
status[i].button |= mapping.padButton;
}
@@ -946,6 +958,17 @@ u32 PADRead(PADStatus* status) {
Sint16 tl = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L));
Sint16 tr = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R));
// Games can read either the digital L/R bits or their analog pressure.
// An explicit button binding must drive both, otherwise the original
// L2/R2 axis still activates L/R even when it was rebound to L1/R1.
// Real GC pads retain independent analog travel and end-stop clicks.
if (!(controller->m_isGameCube ||
(SDL_GetGamepadType(controller->m_controller) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO &&
controller->m_pid == 0x2073))) {
if (leftTriggerSet) tl = (status[i].button & PAD_TRIGGER_L) != 0 ? 32767 : 0;
if (rightTriggerSet) tr = (status[i].button & PAD_TRIGGER_R) != 0 ? 32767 : 0;
}
if (controller->m_deadZones.emulateTriggers) {
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
status[i].button |= PAD_TRIGGER_L;
@@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) {
}
if (controller->m_isGameCube) {
if (cmd == PAD_MOTOR_STOP) {
aurora::input::controller_rumble(instance, 0, 1, 0);
if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) {
// Use an unambiguous motor-off request. The (0, 1) coast encoding
// requires SDL's GameCube brake mode; other backends or an overridden
// hint interpret it as rumble and can leave the controller vibrating.
aurora::input::controller_rumble(instance, 0, 0, 0);
} else if (cmd == PAD_MOTOR_RUMBLE) {
aurora::input::controller_rumble(instance, 1, 1, 0);
} else if (cmd == PAD_MOTOR_STOP_HARD) {
aurora::input::controller_rumble(instance, 0, 0, 0);
}
} else {
if (cmd == PAD_MOTOR_STOP) {
@@ -1278,6 +1302,11 @@ BOOL PADSetKeyButtonBindings(const u32 port, PADKeyButtonBinding bindings[PAD_BU
}
PADKeyButtonBinding* PADGetKeyButtonBindings(const u32 port, u32* buttonCount) {
PADInit();
if (!g_keyboardBindingsLoaded) {
g_keyboardBindingsLoaded = true;
load_keyboard_bindings();
}
if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) {
return nullptr;
}
+4 -4
View File
@@ -49,7 +49,7 @@ Due to legal requirements, no proprietary Nintendo assets or code are included i
3. **Retro Rewind Distribution** *(Skip if only building WiiCompiled)*:
- Download the [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) release package. You will need the `RetroRewind6` folder (which contains `Binaries/Code.pul`).
4. **Retro-WFC Payload** *(Skip if only building WiiCompiled or building offline)*:
- Required for online multiplayer on Retro Rewind. Downloaded during setup from `http://nas.play.rwfc.net/payload?g=RMCPD00`.
- Required for online multiplayer on Retro Rewind. Downloaded during setup from `https://rwfc.net/api/wfc/payload?g=RMCPD00`.
---
@@ -113,7 +113,7 @@ translator() {
```bash
mkdir -p generated/functions build/base
translator translate-recursive 0x8000629c \
translator translate-recursive 0x800060A4 \
--project projects/mkwii/recomp.yml \
--outdir generated/functions \
--output-metadata generated/base_translation_output.json \
@@ -148,7 +148,7 @@ translator emit-base-manifest \
Online play in Retro Rewind requires the shared Retro-WFC payload. Download and validate it:
```bash
mkdir -p build/retro-wfc/binary
curl -fsSL --retry 3 "https://nas.play.rwfc.net/payload?g=RMCPD00" \
curl -fsSL --retry 3 "https://rwfc.net/api/wfc/payload?g=RMCPD00" \
-o build/retro-wfc/binary/payload.RMCPD00.bin
# Validate payload signature and integrity
@@ -320,7 +320,7 @@ Launcher/local-build-macos.command \
```bash
# 1. Download Retro-WFC payload into a staging directory:
mkdir -p build/retro-wfc/binary
curl -fsSL --retry 3 "http://nas.play.rwfc.net/payload?g=RMCPD00" \
curl -fsSL --retry 3 "https://rwfc.net/api/wfc/payload?g=RMCPD00" \
-o build/retro-wfc/binary/payload.RMCPD00.bin
# 2. Run the automated build with the payload directory:
+1
View File
@@ -58,6 +58,7 @@ inline void Flush(bool force = false) {
// still active. A window close is an intentional successful exit, so end the
// process directly and do not run the crash/atexit paths.
[[noreturn]] inline void ExitForAuroraWindowClose() noexcept {
settings_overlay::ReleaseControllers();
WindowPlacementPersistence::Flush(true);
#if defined(_WIN32)
::ExitProcess(0);
+17 -6
View File
@@ -48,8 +48,19 @@ struct NativeButtonItem {
uint32_t nativeButton;
};
inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
inline constexpr auto kNativeButtons = std::to_array<NativeButtonItem>({
{"disabled", "Unmapped", PAD_NATIVE_BUTTON_DISABLED},
{"left_trigger", "Left trigger (LT / L2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, false)},
{"right_trigger", "Right trigger (RT / R2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, false)},
{"left_stick_left", "Left stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, true)},
{"left_stick_right", "Left stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, false)},
{"left_stick_up", "Left stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, true)},
{"left_stick_down", "Left stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, false)},
{"right_stick_left", "Right stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, true)},
{"right_stick_right", "Right stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, false)},
{"right_stick_up", "Right stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, true)},
{"right_stick_down", "Right stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, false)},
{"unmapped", "Default", 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},
@@ -76,7 +87,7 @@ inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNat
{"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");
@@ -88,7 +99,7 @@ inline std::string TrimToken(std::string_view token) {
}
inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
const std::string name = TrimToken(configName);
const std::string name = TrimToken(configName.substr(0, configName.find('@')));
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
[&](const NativeButtonItem& item) { return name == item.configName; });
return it == kNativeButtons.end() ? nullptr : &*it;
@@ -97,8 +108,8 @@ inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
// 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;
[&](const NativeButtonItem& item) { return PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); });
return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it;
}
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
+13
View File
@@ -91,6 +91,7 @@ struct RuntimeUserConfig {
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
std::array<std::optional<std::string>, 12> controllerButtons;
std::optional<bool> rumbleEnabled;
std::optional<int32_t> muteHotkey;
std::map<std::string, std::string> controllerExpressions;
};
@@ -411,6 +412,9 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
}
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
if (auto value = FindConfigInt(document, "audio", "mute_key")) {
config.muteHotkey = *value;
}
if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr;
section != nullptr && section->is_table()) {
@@ -710,6 +714,15 @@ inline bool SetRumbleEnabled(bool value) {
return WriteSetting("controller", "rumble", value ? "true" : "false");
}
inline int32_t MuteHotkey(int32_t fallback) {
return Get().muteHotkey.value_or(fallback);
}
inline bool SetMuteHotkey(int32_t value) {
Mutable().muteHotkey = value;
return WriteSetting("audio", "mute_key", std::to_string(value));
}
inline bool SetAudioVolume(float value) {
value = std::clamp(value, 0.0f, 1.0f);
Mutable().audioVolume = value;
+2
View File
@@ -12,4 +12,6 @@ void Draw() noexcept;
bool StartupScreenVisible() noexcept;
void NotifyStrapInputAccepted() noexcept;
void AdvancePresentedFrame() noexcept;
// Put host controllers back to a neutral state before the process ends.
void ReleaseControllers() noexcept;
} // namespace settings_overlay
+6 -1
View File
@@ -94,7 +94,12 @@ double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
// 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) {
if (PADIsAxisButton(native->nativeButton)) {
const auto axis = static_cast<SDL_GamepadAxis>(PADAxisButtonAxis(native->nativeButton));
const double sign = PADAxisButtonNegative(native->nativeButton) ? -1.0 : 1.0;
return std::clamp(SDL_GetGamepadAxis(gamepad, axis) / 32767.0 * sign, 0.0, 1.0);
}
if (native->nativeButton < SDL_GAMEPAD_BUTTON_COUNT) {
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
: 0.0;
}
+379 -44
View File
@@ -1,5 +1,6 @@
#include "settings_overlay.h"
#include "audio_backend.h"
#include "aurora_events.h"
#include "controller_button_names.h"
#include "controller_mapping_wizard.h"
#include "input_bindings.h"
@@ -15,11 +16,13 @@
#include <SDL3/SDL_keyboard.h>
#include <SDL3/SDL_mouse.h>
#include <SDL3/SDL_scancode.h>
#include <SDL3/SDL_timer.h>
#include <array>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdint>
@@ -69,6 +72,7 @@ const char* GraphicsApiDisplayName() {
}
bool g_topBarVisible = false;
bool g_exitPromptOpen = false;
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
int g_controllerPort = 0;
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
@@ -79,6 +83,7 @@ int g_soundEffectsVolumePercent =
int g_uiVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::UiVolume(1.0f) * 100.0f));
int g_voicesVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::VoicesVolume(1.0f) * 100.0f));
bool g_audioMuted = RuntimeConfigFile::AudioMuted(false);
int32_t g_muteHotkey = RuntimeConfigFile::MuteHotkey(SDL_SCANCODE_BACKSLASH);
bool g_audioMixWorker = RuntimeConfigFile::AudioMixWorkerEnabled(true);
bool g_attenuateMusicWhenMediaPlays = RuntimeConfigFile::AttenuateMusicWhenMediaPlays(false);
int g_frameInterpolationMode = [] {
@@ -186,6 +191,18 @@ void LimitResolutionForFrameRate() {
using ControllerNames::FindNativeButton;
uint32_t ConfiguredNativeButton(const NativeButtonItem& item, const std::string& token) {
if (!PADIsAxisButton(item.nativeButton)) return item.nativeButton;
const size_t separator = token.find('@');
if (separator == std::string::npos) return item.nativeButton;
uint32_t threshold = 0;
const char* end = token.data() + token.size();
const auto parsed = std::from_chars(token.data() + separator + 1, end, threshold);
if (parsed.ec != std::errc{} || parsed.ptr != end || threshold < 1 || threshold > 100)
return item.nativeButton;
return PADAxisButtonIdentity(item.nativeButton) | (threshold << 8);
}
struct ControllerBindingPair {
std::string primary;
std::string secondary;
@@ -204,6 +221,13 @@ ControllerBindingPair SplitControllerBinding(const std::string& value) {
using ControllerNames::NativeButtonForValue;
std::string NativeBindingConfig(uint32_t binding) {
std::string value = NativeButtonForValue(binding).configName;
if (PADIsAxisButton(binding)) value += '@' + std::to_string(PADAxisButtonThreshold(binding));
return value;
}
void SetTopBarVisible(bool visible) {
if (g_topBarVisible == visible) {
return;
@@ -242,7 +266,7 @@ void ApplyConfiguredMappings() {
}
const ControllerBindingPair binding = SplitControllerBinding(*configured);
if (const NativeButtonItem* native = FindNativeButton(binding.primary)) {
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
PADSetButtonMapping(port, PADButtonMapping{ConfiguredNativeButton(*native, binding.primary), kControllerButtons[i].padButton});
} else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " button '" << binding.primary << "'" << std::endl;
@@ -250,7 +274,7 @@ void ApplyConfiguredMappings() {
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
if (!binding.secondary.empty()) {
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
altNative = native->nativeButton;
altNative = ConfiguredNativeButton(*native, binding.secondary);
} else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " secondary button '" << binding.secondary << "'" << std::endl;
@@ -395,6 +419,228 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
ImGui::EndMenu();
}
const char* KeyBindingName(int scancode) {
switch (scancode) {
case PAD_KEY_MOUSE_LEFT: return "Mouse left";
case PAD_KEY_MOUSE_RIGHT: return "Mouse right";
case PAD_KEY_MOUSE_MIDDLE: return "Mouse middle";
case PAD_KEY_MOUSE_X1: return "Mouse side 1";
case PAD_KEY_MOUSE_X2: return "Mouse side 2";
case PAD_KEY_INVALID: return "Unmapped";
default:
return scancode >= 0 && scancode < SDL_SCANCODE_COUNT
? SDL_GetScancodeName(static_cast<SDL_Scancode>(scancode)) : "Unknown";
}
}
enum class RebindKind { KeyboardButton, KeyboardAxis, Controller, MuteHotkey };
struct RebindState {
bool active = false;
bool openPopup = false;
RebindKind kind{};
uint32_t port = 0;
uint16_t target = 0;
bool secondary = false;
SDL_JoystickID instance = 0;
Clock::time_point deadline{};
std::string label;
std::array<bool, SDL_SCANCODE_COUNT> keys{};
uint32_t mouse = 0;
std::array<bool, SDL_GAMEPAD_BUTTON_COUNT> buttons{};
std::array<bool, SDL_GAMEPAD_AXIS_COUNT> axesReady{};
} g_rebind;
void BeginRebind(RebindKind kind, uint16_t target, const char* label, bool secondary = false) {
g_rebind = {};
g_rebind.active = true;
g_rebind.openPopup = true;
g_rebind.kind = kind;
g_rebind.port = static_cast<uint32_t>(g_controllerPort);
g_rebind.target = target;
g_rebind.secondary = secondary;
g_rebind.label = label;
g_rebind.deadline = Clock::now() + std::chrono::seconds(10);
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
std::copy_n(keys, std::min(count, static_cast<int>(g_rebind.keys.size())), g_rebind.keys.begin());
g_rebind.mouse = SDL_GetMouseState(nullptr, nullptr);
const int index = PADGetIndexForPort(g_rebind.port);
if (kind == RebindKind::Controller && index >= 0) {
if (auto* pad = PADGetSDLGamepadForIndex(index)) {
g_rebind.instance = SDL_GetGamepadID(pad);
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT; ++i)
g_rebind.buttons[i] = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i)
g_rebind.axesReady[i] = std::abs(static_cast<int>(SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i)))) < 8000;
}
}
}
void CompleteRebind(uint32_t value) {
const auto& capture = g_rebind;
if (capture.kind == RebindKind::Controller) {
const int index = PADGetIndexForPort(capture.port);
auto* pad = index >= 0 ? PADGetSDLGamepadForIndex(index) : nullptr;
if (pad == nullptr || SDL_GetGamepadID(pad) != capture.instance) {
g_rebind.active = false;
return;
}
if (capture.secondary) PADSetAltButtonMapping(capture.port, {value, capture.target});
else PADSetButtonMapping(capture.port, {value, capture.target});
uint32_t count = 0, altCount = 0;
auto* primary = PADGetButtonMappings(capture.port, &count);
auto* alternate = PADGetAltButtonMappings(capture.port, &altCount);
uint32_t primaryValue = PAD_NATIVE_BUTTON_INVALID, alternateValue = PAD_NATIVE_BUTTON_INVALID;
for (uint32_t i = 0; i < count; ++i)
if (primary[i].padButton == capture.target) primaryValue = primary[i].nativeButton;
for (uint32_t i = 0; i < altCount; ++i)
if (alternate[i].padButton == capture.target) alternateValue = alternate[i].nativeButton;
std::string config = NativeBindingConfig(primaryValue);
if (alternateValue != PAD_NATIVE_BUTTON_INVALID) config += ',' + NativeBindingConfig(alternateValue);
for (size_t i = 0; i < kControllerButtons.size(); ++i)
if (kControllerButtons[i].padButton == capture.target) RuntimeConfigFile::SetControllerButton(i, config);
} else if (capture.kind == RebindKind::MuteHotkey) {
g_muteHotkey = static_cast<int32_t>(value);
RuntimeConfigFile::SetMuteHotkey(g_muteHotkey);
g_rebind.active = false;
return;
} else if (capture.kind == RebindKind::KeyboardButton) {
PADSetKeyButtonBinding(capture.port, {static_cast<int32_t>(value), capture.target});
} else {
PADSetKeyAxisBinding(capture.port, {static_cast<int32_t>(value), capture.target, 1});
}
PADSerializeMappings();
g_rebind.active = false;
}
void DrawRebindPrompt() {
if (g_rebind.openPopup) {
ImGui::OpenPopup("Rebind input");
g_rebind.openPopup = false;
}
if (!ImGui::BeginPopupModal("Rebind input", &g_rebind.active, ImGuiWindowFlags_AlwaysAutoResize)) {
g_rebind.active = false;
return;
}
if (g_rebind.active) {
ImGui::Text("Rebind: %s", g_rebind.label.c_str());
ImGui::TextUnformatted(g_rebind.kind == RebindKind::Controller
? "Press a controller button, pull a trigger, or move a stick."
: g_rebind.kind == RebindKind::MuteHotkey
? "Press a keyboard key."
: "Press a keyboard key or click a mouse button.");
ImGui::TextUnformatted("Release any held input first. Backspace or Delete clears the mapping.");
ImGui::TextUnformatted("Escape can be bound. F10 is reserved for settings.");
const float remaining = std::chrono::duration<float>(g_rebind.deadline - Clock::now()).count();
ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast<int>(std::ceil(remaining))));
const bool clear = ImGui::Button("Clear mapping");
ImGui::SameLine();
if (ImGui::Button("Cancel")) g_rebind.active = false;
// UI clicks must not become mouse bindings (buttons activate on release).
const bool overControl = ImGui::IsAnyItemHovered();
if (g_rebind.active && (clear || remaining <= 0.0f)) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind != RebindKind::Controller) {
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
for (int i = 1; i < std::min(count, static_cast<int>(SDL_SCANCODE_COUNT)) && g_rebind.active; ++i) {
if (keys[i] && !g_rebind.keys[i] && i != SDL_SCANCODE_F10) CompleteRebind(i);
g_rebind.keys[i] = keys[i];
}
const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr);
for (int i = 1; i <= 5 && g_rebind.active; ++i)
if (!overControl && g_rebind.kind != RebindKind::MuteHotkey &&
(mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast<uint32_t>(-i - 1));
g_rebind.mouse = mouse;
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind == RebindKind::Controller) {
auto* pad = SDL_GetGamepadFromID(g_rebind.instance);
if (pad != nullptr) {
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT && g_rebind.active; ++i) {
const bool pressed = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
if (pressed && !g_rebind.buttons[i]) CompleteRebind(i);
g_rebind.buttons[i] = pressed;
}
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT && g_rebind.active; ++i) {
const int value = SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i));
if (std::abs(value) < 8000) g_rebind.axesReady[i] = true;
if (g_rebind.axesReady[i] && std::abs(value) >= 16384)
CompleteRebind(PADEncodeAxisButton(i, value < 0));
}
}
}
}
if (!g_rebind.active) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target,
float width = 220.0f) {
const std::string caption = std::string(KeyBindingName(scancode)) + "##binding";
if (ImGui::Button(caption.c_str(), ImVec2(width, 0.0f))) BeginRebind(kind, target, label);
ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
ImGui::TextUnformatted(label);
}
bool DrawKeyboardSettings(uint32_t port) {
uint32_t count = 0;
auto* buttons = PADGetKeyButtonBindings(port, &count);
bool enabled = buttons != nullptr;
bool usePreset = false;
if (ImGui::Checkbox("Keyboard and mouse", &enabled)) {
PADSetKeyboardActive(port, enabled);
PADSerializeMappings();
buttons = PADGetKeyButtonBindings(port, &count);
usePreset = enabled && std::all_of(buttons, buttons + count, [](const auto& binding) {
return binding.scancode == PAD_KEY_INVALID;
});
}
if (!enabled) return false;
ImGui::TextDisabled("Replaces the gamepad on this port. F10 opens settings.");
if (ImGui::Button("Use WASD + mouse preset") || usePreset) {
const std::array<int, PAD_BUTTON_COUNT> keys = {
PAD_KEY_MOUSE_LEFT, SDL_SCANCODE_SPACE, SDL_SCANCODE_E, SDL_SCANCODE_Q,
SDL_SCANCODE_RETURN, PAD_KEY_MOUSE_MIDDLE, SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT,
};
for (size_t i = 0; i < keys.size(); ++i)
PADSetKeyButtonBinding(port, {keys[i], kControllerButtons[i].padButton});
const std::array<int, PAD_AXIS_COUNT> axes = {
SDL_SCANCODE_D, SDL_SCANCODE_A, SDL_SCANCODE_W, SDL_SCANCODE_S,
SDL_SCANCODE_L, SDL_SCANCODE_J, SDL_SCANCODE_I, SDL_SCANCODE_K,
SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
};
uint32_t axisCount = 0;
auto* mappings = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i)
PADSetKeyAxisBinding(port, {axes[i], mappings[i].padAxis, 1});
PADSerializeMappings();
}
ImGui::SeparatorText("Button mapping");
for (uint32_t i = 0; i < count; ++i) {
int key = buttons[i].scancode;
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(PADGetButtonName(buttons[i].padButton), key, RebindKind::KeyboardButton, buttons[i].padButton);
ImGui::PopID();
}
ImGui::SeparatorText("Stick and trigger mapping");
uint32_t axisCount = 0;
auto* axes = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i) {
int key = axes[i].scancode;
ImGui::PushID(static_cast<int>(count + i));
const char* direction = PADGetAxisDirectionLabel(axes[i].padAxis);
const std::string label = std::string(PADGetAxisName(axes[i].padAxis)) + " " +
(direction != nullptr ? direction : "");
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(label.c_str(), key, RebindKind::KeyboardAxis, axes[i].padAxis);
ImGui::PopID();
}
return true;
}
// Controller settings menu: port selection, controller assignment and button mapping.
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
@@ -501,6 +747,10 @@ void DrawControllerSettings() {
ImGui::Separator();
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
if (DrawKeyboardSettings(selectedGamePort)) {
return;
}
ImGui::Separator();
const char* currentName = PADGetName(selectedGamePort);
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
if (ImGui::MenuItem("Unassign controller")) {
@@ -542,10 +792,10 @@ void DrawControllerSettings() {
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) {
std::string value = NativeButtonForValue(primaryNative).configName;
std::string value = NativeBindingConfig(primaryNative);
if (altNative != PAD_NATIVE_BUTTON_INVALID) {
value += ',';
value += NativeButtonForValue(altNative).configName;
value += NativeBindingConfig(altNative);
}
RuntimeConfigFile::SetControllerButton(index, value);
};
@@ -603,6 +853,11 @@ void DrawControllerSettings() {
}
ImGui::SeparatorText("Button mapping");
ImGui::TextDisabled("LT / L2 = left trigger. RT / R2 = right trigger.");
ImGui::TextDisabled("LB / L1 = left shoulder. RB / R1 = right shoulder.");
ImGui::TextDisabled("Click a binding, then press an input. No input for 10 seconds clears it.");
const float bindingWidth = ImGui::CalcTextSize("Right shoulder (RB / R1)").x +
ImGui::GetFrameHeight() + ImGui::GetStyle().FramePadding.x * 2.0f;
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
return mapping.padButton == kControllerButtons[i].padButton;
@@ -622,30 +877,40 @@ void DrawControllerSettings() {
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(190.0f);
if (ImGui::BeginCombo("##primary", current.label)) {
for (const auto& candidate : kNativeButtons) {
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
if (ImGui::Selectable(candidate.label, selected)) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
writeBinding(i, candidate.nativeButton,
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
if (!PADIsAxisButton(mapping->nativeButton)) return;
int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
ImGui::SetNextItemWidth(bindingWidth);
if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
const PADButtonMapping updated = {
PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
mapping->padButton,
};
if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
else PADSetButtonMapping(selectedGamePort, updated);
}
ImGui::EndCombo();
if (ImGui::IsItemDeactivatedAfterEdit()) {
writeBinding(i, mappingIt->nativeButton,
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
PADSerializeMappings();
}
};
ImGui::BeginGroup();
ImGui::SetNextItemWidth(bindingWidth);
const std::string primaryCaption = std::string(current.label) + "##primary";
if (ImGui::Button(primaryCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label);
}
drawThreshold(mappingIt, false);
ImGui::EndGroup();
if (altIt != nullptr) {
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
if (!altBound && !altRowExpanded[i]) {
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
altRowExpanded[i] = true;
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Add a second binding; pressing either one works");
@@ -654,27 +919,15 @@ void DrawControllerSettings() {
ImGui::SameLine();
ImGui::TextUnformatted("or");
ImGui::SameLine();
ImGui::BeginGroup();
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
ImGui::SetNextItemWidth(190.0f);
if (ImGui::BeginCombo("##alt", altLabel)) {
for (const auto& candidate : kNativeButtons) {
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID;
const bool selected = candidate.nativeButton == altIt->nativeButton;
if (ImGui::Selectable(isNone ? "None" : candidate.label, selected)) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
PADSetAltButtonMapping(
port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
writeBinding(i, mappingIt->nativeButton, candidate.nativeButton);
if (isNone) {
altRowExpanded[i] = false;
}
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
ImGui::SetNextItemWidth(bindingWidth);
const std::string altCaption = std::string(altLabel) + "##alt";
if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
}
drawThreshold(altIt, true);
ImGui::EndGroup();
}
}
ImGui::SameLine();
@@ -712,10 +965,14 @@ void DrawAudioSettings() {
MusicAttenuation::SetVoicesVolume(volume);
RuntimeConfigFile::SetVoicesVolume(volume);
}
const float labelColumn = ImGui::GetCursorPosX() + ImGui::CalcItemWidth();
if (ImGui::Checkbox("Mute", &g_audioMuted)) {
AudioBackend::Instance().SetMuted(g_audioMuted);
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
}
ImGui::SameLine();
DrawKeyBinding("Mute shortcut", g_muteHotkey, RebindKind::MuteHotkey, 0,
std::max(60.0f, labelColumn - ImGui::GetCursorPosX()));
ImGui::Separator();
if (ImGui::Checkbox("Mix audio on a worker thread", &g_audioMixWorker)) {
// Applies immediately: SetMixWorkerEnabled joins any in-flight mix
@@ -935,11 +1192,40 @@ void DrawStartupScreen() {
ImGui::PopStyleColor();
}
void DrawExitPrompt() {
constexpr const char* kTitle = "Exit";
if (g_exitPromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle);
if (!ImGui::BeginPopupModal(kTitle, &g_exitPromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return;
ImGui::TextUnformatted("Quit the game?");
if (ImGui::Button("Exit", ImVec2(120.0f, 0.0f))) ExitForAuroraWindowClose();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) g_exitPromptOpen = false;
if (!g_exitPromptOpen) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawTopBar() {
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
if (!g_topBarVisible) {
return;
}
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::GetBackgroundDrawList()->AddRectFilled(viewport->Pos,
ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y),
IM_COL32(0, 0, 0, 70));
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f,
viewport->Pos.y + viewport->Size.y - 24.0f),
ImGuiCond_Always, ImVec2(0.5f, 1.0f));
ImGui::SetNextWindowBgAlpha(0.85f);
if (ImGui::Begin("Settings input hint", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing)) {
ImGui::TextUnformatted("Settings open - game controls disabled. Press F10 to return to the game.");
}
ImGui::End();
if (!ImGui::BeginMainMenuBar()) return;
ImGui::TextUnformatted("WiiCompiled");
ImGui::Separator();
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
@@ -968,6 +1254,9 @@ void DrawTopBar() {
if (ImGui::BeginMenu("Controller settings")) {
DrawControllerSettings();
// Nest capture under this menu so opening/closing the modal preserves
// the settings popup and its current port and scroll position.
DrawRebindPrompt();
ImGui::EndMenu();
}
@@ -980,14 +1269,21 @@ void DrawTopBar() {
const std::string audioMenuLabel = audioLabel + "###AudioSettingsMenu";
if (ImGui::BeginMenu(audioMenuLabel.c_str())) {
DrawAudioSettings();
DrawRebindPrompt();
ImGui::EndMenu();
}
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + ImGui::GetStyle().FramePadding.x * 2.0f;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - hideWidth - 8.0f));
const ImGuiStyle& style = ImGui::GetStyle();
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + style.FramePadding.x * 2.0f;
const float exitWidth = ImGui::CalcTextSize("X").x + style.FramePadding.x * 2.0f;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(),
ImGui::GetWindowWidth() - hideWidth - exitWidth - style.ItemSpacing.x - 8.0f));
if (ImGui::MenuItem("Hide (F10)")) {
SetTopBarVisible(false);
}
if (ImGui::MenuItem("X")) {
g_exitPromptOpen = true;
}
ImGui::EndMainMenuBar();
}
@@ -1016,9 +1312,12 @@ void UpdateCursorAutoHide() {
return;
}
g_cursorHidden = shouldHide;
// ImGui_ImplSDL3_NewFrame calls SDL_ShowCursor every frame unless this flag is set.
if (shouldHide) {
ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
SDL_HideCursor();
} else {
ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
SDL_ShowCursor();
}
}
@@ -1074,15 +1373,50 @@ void HandleEvents(const AuroraEvent* events) noexcept {
continue;
}
controller_mapping_wizard::HandleSdlEvent(ev->sdl);
if (IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
if (g_rebind.active && (IsToggleKey(ev->sdl, SDL_SCANCODE_BACKSPACE) ||
IsToggleKey(ev->sdl, SDL_SCANCODE_DELETE))) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
}
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
SetTopBarVisible(!g_topBarVisible);
}
if (!g_rebind.active && g_muteHotkey != PAD_KEY_INVALID &&
IsToggleKey(ev->sdl, static_cast<SDL_Scancode>(g_muteHotkey))) {
g_audioMuted = !g_audioMuted;
AudioBackend::Instance().SetMuted(g_audioMuted);
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
}
if (!g_rebind.active && !g_topBarVisible && IsToggleKey(ev->sdl, SDL_SCANCODE_ESCAPE)) {
g_exitPromptOpen = true;
}
if (IsMouseActivity(ev->sdl)) {
g_lastMouseActivity = Clock::now();
}
}
}
void ReleaseControllers() noexcept {
// Aurora drives the LED white on first PADRead and never clears it, and the
// exit paths terminate the process outright, so do it here.
bool queued = false;
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const s32 index = PADGetIndexForPort(port);
if (index < 0) continue;
if (SDL_Gamepad* pad = PADGetSDLGamepadForIndex(static_cast<u32>(index))) {
SDL_SetGamepadLED(pad, 0, 0, 0);
queued = true;
}
}
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD};
PADControlAllMotors(stopAll.data());
// SDL hands LED and rumble reports to its own HIDAPI sender thread rather
// than writing them here, so without this the process dies before the
// controller ever receives them.
if (queued) SDL_Delay(120);
}
void Draw() noexcept {
// Wait for the frame worker's DONE phase: it has replayed the previous frame's ImGui draw lists
// and started the next ImGui frame, so all overlay callers can now safely issue ImGui commands.
@@ -1100,9 +1434,10 @@ void Draw() noexcept {
}
DrawFpsOverlay();
DrawTopBar();
DrawExitPrompt();
controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game.
const bool inputBlocked = controller_mapping_wizard::IsActive();
const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active;
PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen();
+69 -163
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
@@ -1891,6 +1891,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
overlayBuild,
continuationPlan,
retroWfcResolvedExecutableHooks,
patchPlan,
kamekFunctionStarts,
moduleLinkBase,
selected.CodeSize,
@@ -2231,6 +2232,7 @@ int EmitModCpp(
OverlayBuildResult overlayBuild,
ContinuationPlan continuationPlan,
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
KamekPatchPlan patchPlan,
IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
uint moduleLinkBase,
uint moduleLinkedCodeSize,
@@ -2272,14 +2274,53 @@ int EmitModCpp(
.ToHashSet();
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
var discoveredContinuationQueue = new Queue<ContinuationEntry>();
var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null
? new Dictionary<uint, uint[]>()
: retroWfcExecutableHooks
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
.GroupBy(h => h.TargetAddress!.Value)
.ToDictionary(
g => g.Key,
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
var hookLrBases = new List<(uint TargetAddress, uint ContinuationAddress)>();
if (retroWfcExecutableHooks is not null)
{
hookLrBases.AddRange(
retroWfcExecutableHooks
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
}
var hookLrAnalysis = new Dictionary<uint, LrContinuationAnalysis>();
var hookDiscoveryCache = new Dictionary<uint, IReadOnlyList<PpcInstruction>>();
IReadOnlyList<PpcInstruction> DiscoverHookBody(uint target)
{
if (!hookDiscoveryCache.TryGetValue(target, out var instructions))
{
instructions = modTranslator.Discover(target,
new TranslationOptions(KnownFunctionEntryPoints: knownFunctionEntryPoints)).Instructions;
hookDiscoveryCache.Add(target, instructions);
}
return instructions;
}
LrContinuationAnalysis AnalyzeHook(uint target)
{
if (!hookLrAnalysis.TryGetValue(target, out var analysis))
{
analysis = LrContinuationAnalysis.Analyze(target, DiscoverHookBody);
hookLrAnalysis.Add(target, analysis);
}
return analysis;
}
foreach (var patch in patchPlan.ExecutablePatches.Where(p => p.CommandId == KamekCommandId.BranchLink && p.Arguments.Count > 0))
{
var target = KamekAddress.Resolve(patch.Arguments[0], patchPlan.ModuleGuestBase);
if (!AnalyzeHook(target).MaySkipReturn)
{
continue;
}
hookLrBases.Add((target, checked(patch.CommandAddress + 4u)));
}
var linkedHookLrBasesByTarget = hookLrBases
.GroupBy(h => h.TargetAddress)
.ToDictionary(
g => g.Key,
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
var lrContinuationCallTargets = linkedHookLrBasesByTarget.Keys.ToHashSet();
var linkedCallFallthroughLrOverrides = retroWfcExecutableHooks is null
? new Dictionary<uint, uint>()
@@ -2374,50 +2415,42 @@ int EmitModCpp(
}
}
void RecordDiscoveredLrRelativeBaseContinuations(
FunctionTranslationResult result,
IReadOnlyList<uint> lrBases,
string reason)
void RecordHookContinuations(uint hookTarget, IReadOnlyList<uint> lrBases)
{
if (lrBases.Count == 0)
var analysis = AnalyzeHook(hookTarget);
foreach (var lrBase in lrBases)
{
return;
}
foreach (var offset in DiscoverLrRelativeIndirectJumpOffsets(result).Distinct())
{
foreach (var lrBase in lrBases)
var targets = analysis.Offsets.Select(offset => unchecked(lrBase + (uint)offset));
if (analysis.WasTruncated && baseFunctions.FindContaining(lrBase - 4u) is { } caller)
{
// Unknown offsets can resume at any aligned instruction in this caller.
targets = targets.Concat(Enumerable.Range(0, checked((int)((caller.End - caller.Start) / 4)))
.Select(index => caller.Start + (uint)index * 4u));
}
foreach (var target in targets.Distinct())
{
var target = unchecked(lrBase + (uint)offset);
var section = baseManifest.Sections.FirstOrDefault(s => target >= s.GuestStart && target < s.GuestEnd);
if (section is null || !section.Executable)
{
if (section is null || !section.Executable || (target & 3u) != 0)
continue;
}
var containing = baseFunctions.FindContaining(target);
if (containing is null || containing.Start == target)
{
if (containing is null || containing.Start == target || !queuedContinuationAddresses.Add(target))
continue;
}
if (!queuedContinuationAddresses.Add(target))
{
continue;
}
discoveredContinuationQueue.Enqueue(new ContinuationEntry(
target,
containing.Start,
containing.End,
section.Name,
result.EntryPoint,
hookTarget,
KamekCommandId.Branch,
$"{reason}; LR-relative jump offset {offset:+#;-#;0}"));
$"LR-relative hook target 0x{hookTarget:X8}"));
}
}
}
foreach (var (target, lrBases) in linkedHookLrBasesByTarget)
RecordHookContinuations(target, lrBases);
ModTranslationWork CreateContinuationWork(ContinuationEntry continuation)
{
var name = $"rr_continue_{continuation.Address:X8}";
@@ -2583,16 +2616,7 @@ int EmitModCpp(
}
CommitWave(attempts, (result, work) =>
{
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}");
if (linkedHookLrBasesByTarget.TryGetValue(work.Address, out var lrBases))
{
RecordDiscoveredLrRelativeBaseContinuations(
result,
lrBases,
$"base continuation discovered from LR-relative hook target 0x{work.Address:X8}");
}
});
RecordDiscoveredBaseContinuations(result, $"base continuation discovered from module 0x{work.Address:X8}"));
}
DrainDiscoveredContinuations();
@@ -2750,124 +2774,6 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
}
}
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result)
{
var lrOffsets = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int? ctrOffset = null;
foreach (var instruction in result.Instructions)
{
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
{
lrOffsets[lrDest] = 0;
continue;
}
if ((mnemonic == "mr" || mnemonic == "or") &&
TryGetInstructionReg(instruction, 0, out var moveDest) &&
TryGetInstructionReg(instruction, 1, out var moveSource) &&
(mnemonic == "mr" ||
(instruction.Operands.Count >= 3 &&
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
{
if (lrOffsets.TryGetValue(moveSource, out var sourceOffset))
{
lrOffsets[moveDest] = sourceOffset;
}
else
{
lrOffsets.Remove(moveDest);
}
continue;
}
if (mnemonic == "addi" &&
TryGetInstructionReg(instruction, 0, out var addDest) &&
TryGetInstructionReg(instruction, 1, out var addBase) &&
TryGetInstructionImm(instruction, 2, out var imm))
{
if (lrOffsets.TryGetValue(addBase, out var baseOffset))
{
lrOffsets[addDest] = checked(baseOffset + imm);
}
else
{
lrOffsets.Remove(addDest);
}
continue;
}
if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
{
ctrOffset = lrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : null;
continue;
}
if (mnemonic == "bctr")
{
if (ctrOffset.HasValue)
{
yield return ctrOffset.Value;
}
ctrOffset = null;
continue;
}
if (TryInstructionWritesDest(instruction, out var dest))
{
lrOffsets.Remove(dest);
}
}
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
{
register = NormalizeInstructionReg(operand.Name);
return true;
}
register = string.Empty;
return false;
}
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
{
immediate = operand.Value;
return true;
}
immediate = 0;
return false;
}
static bool TryInstructionWritesDest(PpcInstruction instruction, out string destination)
{
destination = string.Empty;
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
{
return false;
}
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
{
return false;
}
destination = NormalizeInstructionReg(operand.Name);
return true;
}
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
}
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
hook.TypeName is "call" or "branchCtrLink" ||
hook.Intent.Contains("Call", StringComparison.Ordinal);
@@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Text.Json;
using Translator.Core.Disassembly;
using Translator.Core.Parsing.Kamek;
@@ -273,4 +274,541 @@ public static class ContinuationPlanner
Or,
AddSigned
}
// Dropped states make a negative result inconclusive.
private const int MaxStatesPerInstruction = 512;
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(
IReadOnlyList<PpcInstruction> instructions,
Action? onStateCapExceeded = null,
Action? onUnresolvedExit = null)
{
if (instructions.Count == 0)
{
yield break;
}
var indexByAddress = new Dictionary<uint, int>(instructions.Count);
for (var i = 0; i < instructions.Count; i++)
{
indexByAddress.TryAdd(instructions[i].Address, i);
}
var visited = new HashSet<PathState>[instructions.Count];
for (var i = 0; i < instructions.Count; i++)
{
visited[i] = new HashSet<PathState>();
}
var seenOffsets = new HashSet<int>();
var worklist = new Queue<(int Index, PathState State)>();
void Enqueue(int targetIndex, PathState stateToEnqueue)
{
worklist.Enqueue((targetIndex, stateToEnqueue));
}
int? GetFallthroughIndex(PpcInstruction instruction)
{
if (indexByAddress.TryGetValue(instruction.EndAddress, out var nextIndex))
{
return nextIndex;
}
return null;
}
Enqueue(0, PathState.Empty);
while (worklist.Count > 0)
{
var (idx, state) = worklist.Dequeue();
if (!visited[idx].Add(state))
{
continue;
}
if (visited[idx].Count > MaxStatesPerInstruction)
{
onStateCapExceeded?.Invoke();
continue;
}
var instruction = instructions[idx];
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
var nextState = state;
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
{
if (lrDest == "r1")
{
nextState = nextState.WithClearedStackOffsets();
}
nextState = nextState.LrReturnOffset.HasValue
? nextState.WithLrOffset(lrDest, nextState.LrReturnOffset.Value)
: nextState.WithoutLrOffset(lrDest);
}
else if ((mnemonic == "mr" || mnemonic == "or") &&
TryGetInstructionReg(instruction, 0, out var moveDest) &&
TryGetInstructionReg(instruction, 1, out var moveSource) &&
(mnemonic == "mr" ||
(instruction.Operands.Count >= 3 &&
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
{
if (moveDest == "r1" && moveSource != "r1")
{
nextState = nextState.WithClearedStackOffsets();
}
nextState = nextState.LrOffsets.TryGetValue(moveSource, out var sourceOffset)
? nextState.WithLrOffset(moveDest, sourceOffset)
: nextState.WithoutLrOffset(moveDest);
}
else if ((mnemonic == "addi" || mnemonic == "addic") &&
TryGetInstructionReg(instruction, 0, out var addDest) &&
TryGetInstructionReg(instruction, 1, out var addBase) &&
TryGetInstructionImm(instruction, 2, out var imm))
{
if (addDest == "r1")
{
nextState = addBase == "r1"
? nextState.WithSpDelta(unchecked(nextState.SpDelta + imm))
: nextState.WithClearedStackOffsets();
}
nextState = nextState.LrOffsets.TryGetValue(addBase, out var baseOffset)
? nextState.WithLrOffset(addDest, unchecked(baseOffset + imm))
: nextState.WithoutLrOffset(addDest);
}
else if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
{
var newCtrOffset = nextState.LrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : (int?)null;
nextState = nextState.WithCtrOffset(newCtrOffset);
}
else if (mnemonic == "mtlr" && TryGetInstructionReg(instruction, 0, out var lrSource))
{
var newLrReturnOffset = nextState.LrOffsets.TryGetValue(lrSource, out var sourceOffset) ? sourceOffset : (int?)null;
nextState = nextState.WithLrReturnOffset(newLrReturnOffset);
}
else if (mnemonic == "stw" &&
TryGetInstructionReg(instruction, 0, out var storeSrc) &&
TryGetInstructionDisplacement(instruction, 1, out var storeDisp, out var storeBase, out _))
{
if (storeBase == "r1")
{
var targetSlot = nextState.SpDelta + storeDisp;
nextState = nextState.LrOffsets.TryGetValue(storeSrc, out var offset)
? nextState.WithStackOffset(targetSlot, offset)
: nextState.WithoutStackOffset(targetSlot);
}
}
else if (mnemonic == "stwu" &&
TryGetInstructionReg(instruction, 0, out var stwuSrc) &&
TryGetInstructionDisplacement(instruction, 1, out var stwuDisp, out var stwuBase, out _))
{
if (stwuBase == "r1")
{
var targetSlot = nextState.SpDelta + stwuDisp;
nextState = nextState.LrOffsets.TryGetValue(stwuSrc, out var offset)
? nextState.WithStackOffset(targetSlot, offset)
: nextState.WithoutStackOffset(targetSlot);
nextState = nextState.WithAdjustedStackPointer(stwuDisp);
}
else
{
nextState = nextState.WithoutLrOffset(stwuBase);
}
}
else if (TryGetStackStoreRange(instruction, out var storeOffset, out var storeSize, out var updatesStackPointer))
{
nextState = nextState.WithoutStackOffsetsInRange(
nextState.SpDelta + storeOffset,
storeSize);
if (updatesStackPointer)
{
nextState = nextState.WithAdjustedStackPointer(storeOffset);
}
}
else if (mnemonic == "lwz" &&
TryGetInstructionReg(instruction, 0, out var loadDest) &&
TryGetInstructionDisplacement(instruction, 1, out var loadDisp, out var loadBase, out _))
{
if (loadBase == "r1")
{
var targetSlot = nextState.SpDelta + loadDisp;
var hasStackOffset = nextState.StackOffsets.TryGetValue(targetSlot, out var offset);
if (loadDest == "r1")
{
nextState = nextState.WithClearedStackOffsets();
}
nextState = hasStackOffset
? nextState.WithLrOffset(loadDest, offset)
: nextState.WithoutLrOffset(loadDest);
}
else
{
nextState = nextState.WithoutLrOffset(loadDest);
if (loadDest == "r1")
{
nextState = nextState.WithClearedStackOffsets();
}
}
}
else
{
if (TryInstructionWritesDest(instruction, out var destinations))
{
foreach (var dest in destinations)
{
nextState = nextState.WithoutLrOffset(dest);
if (dest == "r1")
{
nextState = nextState.WithClearedStackOffsets();
}
}
}
}
if (instruction.IsCall || mnemonic == "bl" || mnemonic == "blrl")
{
nextState = nextState.WithLrReturnOffset(null).WithCtrOffset(null);
for (var register = 0; register <= 12; register++)
{
if (register != 1 && register != 2)
{
nextState = nextState.WithoutLrOffset($"r{register}");
}
}
}
if (mnemonic == "bctr")
{
if (state.CtrOffset.HasValue && seenOffsets.Add(state.CtrOffset.Value))
{
yield return state.CtrOffset.Value;
}
if (!state.CtrOffset.HasValue && instruction.BranchTargets.Count == 0)
onUnresolvedExit?.Invoke();
nextState = nextState.WithCtrOffset(null);
if (instruction.BranchTargets.Count == 0)
{
continue;
}
}
var isReturn = !instruction.IsCall && (instruction.IsReturn || mnemonic == "blr" || mnemonic == "bclr" ||
(mnemonic.StartsWith("b", StringComparison.Ordinal) && mnemonic.EndsWith("lr", StringComparison.Ordinal)));
if (isReturn)
{
if (!state.LrReturnOffset.HasValue)
onUnresolvedExit?.Invoke();
if (state.LrReturnOffset.HasValue && state.LrReturnOffset.Value != 0 && seenOffsets.Add(state.LrReturnOffset.Value))
{
yield return state.LrReturnOffset.Value;
}
if (!instruction.IsConditionalBranch)
{
continue;
}
}
if (instruction.IsUnconditionalBranch)
{
foreach (var target in instruction.BranchTargets)
{
if (indexByAddress.TryGetValue(target, out var targetIndex))
{
Enqueue(targetIndex, nextState);
}
}
}
else if (instruction.IsConditionalBranch)
{
var fallthrough = GetFallthroughIndex(instruction);
if (fallthrough.HasValue)
{
Enqueue(fallthrough.Value, nextState);
}
if (!isReturn && !instruction.IsCall)
{
foreach (var target in instruction.BranchTargets)
{
if (indexByAddress.TryGetValue(target, out var targetIndex))
{
Enqueue(targetIndex, nextState);
}
}
}
}
else
{
var fallthrough = GetFallthroughIndex(instruction);
if (fallthrough.HasValue)
{
Enqueue(fallthrough.Value, nextState);
}
}
}
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
{
register = NormalizeInstructionReg(operand.Name);
return true;
}
register = string.Empty;
return false;
}
static bool TryGetInstructionDisplacement(PpcInstruction instruction, int index, out int offset, out string baseRegister, out int baseRegisterNumber)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcDisplacementOperand operand)
{
offset = operand.Offset;
baseRegister = NormalizeInstructionReg(operand.BaseRegister);
baseRegisterNumber = operand.BaseRegisterNumber;
return true;
}
offset = 0;
baseRegister = string.Empty;
baseRegisterNumber = -1;
return false;
}
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
{
immediate = operand.Value;
return true;
}
immediate = 0;
return false;
}
static bool TryInstructionWritesDest(PpcInstruction instruction, out IReadOnlyList<string> destinations)
{
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
{
destinations = Array.Empty<string>();
return false;
}
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
{
destinations = Array.Empty<string>();
return false;
}
if (mnemonic == "lmw")
{
var startReg = Math.Clamp(operand.Number, 0, 31);
var regs = new string[32 - startReg];
for (var r = startReg; r <= 31; r++)
{
regs[r - startReg] = $"r{r}";
}
destinations = regs;
return true;
}
destinations = [NormalizeInstructionReg(operand.Name)];
return true;
}
static bool TryGetStackStoreRange(PpcInstruction instruction, out int offset, out int size, out bool updatesStackPointer)
{
offset = 0;
size = 0;
updatesStackPointer = false;
if (!TryGetInstructionDisplacement(instruction, 1, out offset, out var baseRegister, out _) ||
baseRegister != "r1")
{
return false;
}
switch (instruction.Mnemonic.ToLowerInvariant())
{
case "stfs":
size = 4;
return true;
case "stfsu":
size = 4;
updatesStackPointer = true;
return true;
case "stfd":
size = 8;
return true;
case "stfdu":
size = 8;
updatesStackPointer = true;
return true;
case "stmw" when instruction.Operands[0] is PpcRegisterOperand register:
size = checked((32 - Math.Clamp(register.Number, 0, 31)) * 4);
return true;
default:
return false;
}
}
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
}
private sealed class PathState : IEquatable<PathState>
{
public ImmutableDictionary<string, int> LrOffsets { get; }
public int? CtrOffset { get; }
public int? LrReturnOffset { get; }
public int SpDelta { get; }
public ImmutableDictionary<int, int> StackOffsets { get; }
public PathState(
ImmutableDictionary<string, int> lrOffsets,
int? ctrOffset,
int? lrReturnOffset,
int spDelta,
ImmutableDictionary<int, int> stackOffsets)
{
LrOffsets = lrOffsets;
CtrOffset = ctrOffset;
LrReturnOffset = lrReturnOffset;
SpDelta = spDelta;
StackOffsets = stackOffsets;
}
public static readonly PathState Empty = new(
ImmutableDictionary<string, int>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
null,
0,
0,
ImmutableDictionary<int, int>.Empty);
public PathState WithLrOffset(string register, int offset) =>
LrOffsets.TryGetValue(register, out var cur) && cur == offset
? this
: new(LrOffsets.SetItem(register, offset), CtrOffset, LrReturnOffset, SpDelta, StackOffsets);
public PathState WithoutLrOffset(string register) =>
LrOffsets.ContainsKey(register)
? new(LrOffsets.Remove(register), CtrOffset, LrReturnOffset, SpDelta, StackOffsets)
: this;
public PathState WithCtrOffset(int? ctrOffset) =>
ctrOffset == CtrOffset
? this
: new(LrOffsets, ctrOffset, LrReturnOffset, SpDelta, StackOffsets);
public PathState WithLrReturnOffset(int? lrReturnOffset) =>
lrReturnOffset == LrReturnOffset
? this
: new(LrOffsets, CtrOffset, lrReturnOffset, SpDelta, StackOffsets);
public PathState WithSpDelta(int spDelta) =>
spDelta == SpDelta
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, spDelta, StackOffsets);
public PathState WithAdjustedStackPointer(int displacement)
{
// r1 can hold an LR-relative address too. Update both relations;
// guest address arithmetic wraps at 32 bits.
var updated = WithSpDelta(unchecked(SpDelta + displacement));
return LrOffsets.TryGetValue("r1", out var offset)
? updated.WithLrOffset("r1", unchecked(offset + displacement))
: updated;
}
public PathState WithStackOffset(int slot, int offset) =>
StackOffsets.TryGetValue(slot, out var cur) && cur == offset
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.SetItem(slot, offset));
public PathState WithoutStackOffset(int slot) =>
StackOffsets.ContainsKey(slot)
? new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.Remove(slot))
: this;
public PathState WithoutStackOffsetsInRange(int start, int size)
{
var end = checked(start + size);
var remaining = StackOffsets;
foreach (var slot in StackOffsets.Keys)
{
if (slot < end && start < checked(slot + 4))
{
remaining = remaining.Remove(slot);
}
}
return remaining.Count == StackOffsets.Count
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, remaining);
}
public PathState WithClearedStackOffsets() =>
StackOffsets.IsEmpty
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, ImmutableDictionary<int, int>.Empty);
public bool Equals(PathState? other)
{
if (ReferenceEquals(this, other)) return true;
if (other is null) return false;
if (CtrOffset != other.CtrOffset || LrReturnOffset != other.LrReturnOffset || SpDelta != other.SpDelta) return false;
if (LrOffsets.Count != other.LrOffsets.Count || StackOffsets.Count != other.StackOffsets.Count) return false;
foreach (var (k, v) in LrOffsets)
{
if (!other.LrOffsets.TryGetValue(k, out var otherV) || v != otherV)
{
return false;
}
}
foreach (var (k, v) in StackOffsets)
{
if (!other.StackOffsets.TryGetValue(k, out var otherV) || v != otherV)
{
return false;
}
}
return true;
}
public override bool Equals(object? obj) => obj is PathState other && Equals(other);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(CtrOffset);
hash.Add(LrReturnOffset);
hash.Add(SpDelta);
hash.Add(LrOffsets.Count);
var regHash = 0;
foreach (var (k, v) in LrOffsets)
{
regHash ^= HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(k), v);
}
hash.Add(regHash);
hash.Add(StackOffsets.Count);
var stackHash = 0;
foreach (var (k, v) in StackOffsets)
{
stackHash ^= HashCode.Combine(k, v);
}
hash.Add(stackHash);
return hash.ToHashCode();
}
}
}
@@ -0,0 +1,83 @@
using Translator.Core.Disassembly;
namespace Translator.Core.Mods;
// Opaque exits prevent classification; truncated exploration may also hide offsets.
public sealed record LrContinuationAnalysis(IReadOnlyList<int> Offsets, bool IsComplete, bool WasTruncated)
{
public bool MaySkipReturn => !IsComplete || Offsets.Count != 0;
public static LrContinuationAnalysis Analyze(
uint entryPoint,
Func<uint, IReadOnlyList<PpcInstruction>> discover)
{
const int maxFunctions = 256;
const int maxInstructions = 65536;
var instructions = new Dictionary<uint, PpcInstruction>();
var pending = new Queue<uint>();
var visited = new HashSet<uint>();
var complete = true;
var truncated = false;
pending.Enqueue(entryPoint);
while (pending.TryDequeue(out var entry))
{
if (instructions.ContainsKey(entry) || !visited.Add(entry))
continue;
if (visited.Count > maxFunctions)
{
complete = false;
truncated = true;
break;
}
IReadOnlyList<PpcInstruction> body;
try
{
body = discover(entry);
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException
or IndexOutOfRangeException or NotSupportedException or OverflowException)
{
complete = false;
truncated = true;
continue;
}
if (!body.Any(instruction => instruction.Address == entry) ||
instructions.Count + body.Count > maxInstructions)
{
complete = false;
truncated = true;
continue;
}
foreach (var instruction in body)
instructions.TryAdd(instruction.Address, instruction);
foreach (var instruction in body)
{
if (!instruction.IsCall)
{
foreach (var target in instruction.BranchTargets)
if (!instructions.ContainsKey(target)) pending.Enqueue(target);
}
if ((!instruction.IsReturn && !instruction.IsUnconditionalBranch) ||
instruction.IsConditionalBranch)
{
if (!instructions.ContainsKey(instruction.EndAddress))
pending.Enqueue(instruction.EndAddress);
}
}
}
if (!instructions.TryGetValue(entryPoint, out var first))
return new LrContinuationAnalysis([], false, true);
// Keep the original entry first, including when a tail target precedes it.
var ordered = new[] { first }.Concat(instructions.Values
.Where(instruction => instruction.Address != entryPoint)
.OrderBy(instruction => instruction.Address)).ToArray();
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(
ordered, () => { complete = false; truncated = true; }, () => complete = false).ToArray();
return new LrContinuationAnalysis(offsets, complete, truncated);
}
}
@@ -1,4 +1,6 @@
using System.Buffers.Binary;
using System.Linq;
using Translator.Core.Disassembly;
using Translator.Core.Mods;
using Translator.Core.Mods.Mkwii;
using Translator.Core.Parsing.Kamek;
@@ -128,6 +130,54 @@ public class ContinuationPlannerTests
Assert.Contains("Retro WFC executable hook continuation", entry.Reason);
}
[Fact]
public void DiscoverLrRelativeIndirectJumpOffsets_DiscoversSkipReturnOffset()
{
var instructions = new[]
{
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
PpcDecoder.Decode(0x8180D8EC, 0x3BFF0014u), // addi r31, r31, 20
PpcDecoder.Decode(0x8180D8F0, 0x7FE803A6u), // mtlr r31
PpcDecoder.Decode(0x8180D8F4, 0x4E800020u), // blr
};
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
var offset = Assert.Single(offsets);
Assert.Equal(20, offset);
}
[Fact]
public void DiscoverLrRelativeIndirectJumpOffsets_IgnoresStandardLrRestore()
{
var instructions = new[]
{
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
PpcDecoder.Decode(0x8180D8EC, 0x93E10008u), // stw r31, 8(r1)
PpcDecoder.Decode(0x8180D8F0, 0x83E10008u), // lwz r31, 8(r1)
PpcDecoder.Decode(0x8180D8F4, 0x7FE803A6u), // mtlr r31
PpcDecoder.Decode(0x8180D8F8, 0x4E800020u), // blr
};
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions);
Assert.Empty(offsets);
}
[Fact]
public void DiscoverLrRelativeIndirectJumpOffsets_SupportsBctrOffset()
{
var instructions = new[]
{
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
PpcDecoder.Decode(0x8180D8EC, 0x397F0008u), // addi r11, r31, 8
PpcDecoder.Decode(0x8180D8F0, 0x7D6903A6u), // mtctr r11
PpcDecoder.Decode(0x8180D8F4, 0x4E800420u), // bctr
};
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
var offset = Assert.Single(offsets);
Assert.Equal(8, offset);
}
private static KamekChunk EmptyChunk() =>
new(
0,
@@ -0,0 +1,261 @@
using System.Buffers.Binary;
using System.Text.Json;
using Translator.Cli.Configuration;
using Translator.Core.Build;
using Translator.Core.Mods;
using Translator.Core.Parsing.Kamek;
namespace Translator.Tests;
public sealed class KamekLrContinuationIntegrationTests
{
private const uint Caller = 0x80004000;
private const uint Module = 0x80010000;
private const uint Hook = Module + 0x40;
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void SkipReturnThroughKnownTailCallsRetainsCallerDispatch(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20));
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void OrdinaryReturnsThroughKnownTailCallsStayLightweight(int tailDepth)
{
var bundle = Translate(tailDepth, [0x38630001u, 0x4E800020u]); // addi r3,r3,1; blr
var caller = Source(bundle, Caller);
Assert.DoesNotContain("switch (ctx->lr)", caller);
Assert.DoesNotContain("if (ctx->lr !=", caller);
Assert.DoesNotContain(bundle.Entries, entry => entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void DirectSkipReturnRegistersTheAdjustedBaseAddress()
{
var bundle = Translate(0, SkipReturn(20));
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void CtrSkipRetainsDispatchAndRegistersItsContinuation()
{
var bundle = Translate(0,
[
0x7D8802A6u, // mflr r12
0x398C0014u, // addi r12,r12,20
0x7D8903A6u, // mtctr r12
0x4E800420u // bctr
]);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
[Fact]
public void NormalReturnArmDoesNotHideTailCalledSkipReturn()
{
var bundle = Translate(1, SkipReturn(20), conditionalWrapper: true);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void SharedHookRetainsResumeDispatchAtEveryCallSite(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20), sharedTarget: true);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
AssertResumeDispatch(Source(bundle, Caller + 0x40), Caller + 0x40, Caller + 0x58);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void SkipReturnHandlingDoesNotSpreadToAnOrdinaryPatchedCaller(int tailDepth)
{
var bundle = Translate(tailDepth, SkipReturn(20), companionBody: [0x38630001u, 0x4E800020u]);
var ordinaryCaller = Source(bundle, Caller + 0x40);
Assert.DoesNotContain("switch (ctx->lr)", ordinaryCaller);
Assert.DoesNotContain("if (ctx->lr !=", ordinaryCaller);
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
}
[Theory]
[InlineData(0)]
[InlineData(20)]
public void SavedAndRestoredLrDistinguishesOrdinaryAndSkipReturns(int offset)
{
var bundle = Translate(0,
[
0x7D8802A6u, // mflr r12
0x91810004u, // stw r12,4(r1)
0x81810004u, // lwz r12,4(r1)
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
]);
var caller = Source(bundle, Caller);
if (offset == 0)
Assert.DoesNotContain("if (ctx->lr !=", caller);
else
AssertResumeDispatch(caller, Caller, Caller + 4 + (uint)offset);
}
[Fact]
public void CappedAnalysisDoesNotClassifyAHookAsAnOrdinaryReturn()
{
var bundle = Translate(0, SyntheticLrHookFactory.ManyOrdinaryReturnPaths());
AssertResumeDispatch(Source(bundle, Caller), Caller, Caller + 24);
Assert.Contains(bundle.Entries, entry => entry.EntryPoint == Caller + 24 &&
entry.VirtualPath.Contains("rr_continue_"));
}
private static void AssertResumeDispatch(string source, uint callSite, uint continuation)
{
var call = source.IndexOf($"InvokeDirectCpu<0x{Hook:X8}u>(ctx);", StringComparison.Ordinal);
Assert.True(call >= 0, source);
var guard = source.IndexOf($"if (ctx->lr != 0x{callSite + 4:X8}u)", call, StringComparison.Ordinal);
Assert.True(guard > call, $"The hook must dispatch its adjusted return address.\n{source}");
Assert.Contains($"case 0x{continuation:X8}u:", source[guard..]);
Assert.Contains($"goto loc_{continuation:X8};", source[guard..]);
Assert.Contains($"loc_{continuation:X8}:", source);
}
private static uint[] SkipReturn(int offset) =>
[
0x7D8802A6u, // mflr r12
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
];
private static string Source(TranslationSourceBundle bundle, uint address) =>
Assert.Single(bundle.Entries.Where(entry => entry.EntryPoint == address &&
entry.VirtualPath.StartsWith("overlays/", StringComparison.Ordinal))).Source;
private static TranslationSourceBundle Translate(int tailDepth, uint[] body,
bool conditionalWrapper = false, bool sharedTarget = false, uint[]? companionBody = null)
{
var root = Path.Combine(Path.GetTempPath(), $"kamek-lr-integration-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
uint[] caller = [0x60000000u, 0x38630001u, 0x38630001u, 0x38630001u,
0x38630001u, 0x38630001u, 0x60000000u, 0x4E800020u];
var hasSecondCaller = sharedTarget || companionBody is not null;
var baseWords = Enumerable.Repeat(0x4E800020u, hasSecondCaller ? 24 : 8).ToArray();
caller.CopyTo(baseWords, 0);
if (hasSecondCaller) caller.CopyTo(baseWords, 16);
var baseBytes = Words(baseWords);
File.WriteAllBytes(Path.Combine(root, "main.dol"), SyntheticDolFactory.CreateBytes(
Caller, sections: [SyntheticDolFactory.Text(0, Caller, baseWords)]));
File.WriteAllBytes(Path.Combine(root, "base.bin"), baseBytes);
// translate-mod requires a REL; this fixture has no REL code or relocations.
File.WriteAllBytes(Path.Combine(root, "empty.rel"), new byte[0x48]);
var functions = new List<BaseFunctionRangeMetadata>
{
new(Caller, Caller + 32, "caller", ".text", 0, "synthetic", ["Executable"])
};
if (hasSecondCaller)
functions.Add(new(Caller + 0x40, Caller + 0x60, "second_caller", ".text", 0x40,
"synthetic", ["Executable"]));
var manifest = new BaseManifest("synthetic", 1, "TEST01", "P", "", 0,
[new BaseSectionMetadata(".text", "main.dol", Caller, Caller + (uint)baseBytes.Length,
true, false, "base.bin", 0)],
functions, "ranges.json");
var manifestPath = Path.Combine(root, "base.json");
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest));
var projectPath = Path.Combine(root, "recomp.yml");
Directory.CreateDirectory(Path.Combine(root, "native"));
Directory.CreateDirectory(Path.Combine(root, "generated", "functions"));
File.WriteAllText(projectPath, """
schema_version: 1
project:
id: kamek-lr-test
memory:
base: 0x80000000
size: 0x00020000
sda_base: 0x80002000
sda2_base: 0x80003000
inputs:
dol:
path: main.dol
rel:
path: empty.rel
load_address: 0x80008000
runtime:
native_registration_root: native
output:
root: generated
""");
var moduleSize = companionBody is null ? 0x40 * (tailDepth + 1) + body.Length * 4
: 0x300 + companionBody.Length * 4;
var moduleWords = Enumerable.Repeat(0x4E800020u, moduleSize / 4).ToArray();
body.CopyTo(moduleWords, (0x40 * (tailDepth + 1)) / 4);
var commands = new List<uint> { ((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu, Caller, Hook - Module };
if (hasSecondCaller)
commands.AddRange([((uint)KamekCommandId.BranchLink << 24) | 0x00FFFFFEu,
Caller + 0x40, sharedTarget ? Hook - Module : 0x300u]);
companionBody?.CopyTo(moduleWords, 0x300 / 4);
for (var depth = 0; depth < tailDepth; depth++)
{
var offset = 0x40u * (uint)(depth + 1);
var branchOffset = offset;
if (conditionalWrapper && depth == 0)
{
moduleWords[offset / 4] = 0x2C030000u; // cmpwi r3,0
moduleWords[offset / 4 + 1] = 0x4D820020u; // beqlr
branchOffset += 8;
}
// Explicit Kamek branch targets make every helper a known function boundary.
commands.Add(((uint)KamekCommandId.Branch << 24) | branchOffset);
commands.Add(offset + 0x40);
}
var code = Words(moduleWords);
var commandBytes = Words(commands.ToArray());
var pul = new byte[KamekChunk.HeaderSize + code.Length + commandBytes.Length];
Write(pul, 0, KamekChunk.Magic0);
Write(pul, 4, KamekChunk.Magic1);
Write(pul, 12, (uint)code.Length);
Write(pul, 24, (uint)pul.Length);
code.CopyTo(pul, KamekChunk.HeaderSize);
commandBytes.CopyTo(pul, KamekChunk.HeaderSize + code.Length);
var pulPath = Path.Combine(root, "Code.pul");
File.WriteAllBytes(pulPath, pul);
var output = Path.Combine(root, "mod");
string[] args = ["translate-mod", "--project", projectPath, "--code-pul", pulPath,
"--base-manifest", manifestPath, "--out", output, "--module-guest-base", $"0x{Module:X8}",
"--module-link-base", $"0x{Module:X8}", "--skip-retro-wfc", "--emit-cpp", "--threads", "1"];
var entryPoint = typeof(TranslationProjectConfig).Assembly.EntryPoint!;
var exitCode = Assert.IsType<int>(entryPoint.Invoke(null, [args]));
Assert.Equal(0, exitCode);
return TranslationSourceBundle.Read(Path.Combine(output, "translated_sources.bin"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
private static byte[] Words(uint[] words)
{
var bytes = new byte[words.Length * 4];
for (var index = 0; index < words.Length; index++) Write(bytes, index * 4, words[index]);
return bytes;
}
private static void Write(byte[] bytes, int offset, uint value) =>
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(offset, 4), value);
}
@@ -0,0 +1,125 @@
using Translator.Core.Disassembly;
using Translator.Core.Mods;
namespace Translator.Tests;
public sealed class LrContinuationAnalysisTests
{
private const uint Entry = 0x80010000;
private const uint Tail = Entry - 0x40;
[Fact]
public void TailHelperUsesTheWrappersLrState()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x398C0014, 0x4BFFFFB8], // mflr; addi +20; b Tail
[Tail] = [0x7D8803A6, 0x4E800020] // mtlr r12; blr
});
Assert.True(result.IsComplete);
Assert.Equal(new[] { 20 }, result.Offsets);
}
[Fact]
public void TailHelperUsesTheWrappersStackFrame()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x9421FFF0, 0x91810014, 0x4BFFFFB4],
[Tail] = [0x38210010, 0x81810004, 0x398C0014, 0x7D8803A6, 0x4E800020]
});
Assert.True(result.IsComplete);
Assert.Equal(new[] { 20 }, result.Offsets);
}
[Fact]
public void OrdinaryTailCycleTerminatesWithoutInventingOffsets()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x4BFFFFC0],
[Tail] = [0x48000040]
});
Assert.True(result.IsComplete);
Assert.False(result.MaySkipReturn);
}
[Fact]
public void AdjustingTailCycleReportsIncompleteAnalysis()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7D8802A6, 0x4800003C],
[Entry + 0x40] = [0x398C0004, 0x4BFFFFFC]
});
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void UndecodableTailDoesNotProveAnOrdinaryReturn()
{
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x4BFFFFC0] });
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void NormalCallDoesNotInheritItsCalleesSkipOffset()
{
var visited = new List<uint>();
var result = LrContinuationAnalysis.Analyze(Entry, address =>
{
visited.Add(address);
return Decode(address, [0x7FE802A6, 0x4BFFFFBD, 0x7FE803A6, 0x4E800020]);
});
Assert.Equal(new[] { Entry }, visited);
Assert.True(result.IsComplete);
Assert.False(result.MaySkipReturn);
}
[Fact]
public void UnknownIndirectTailIsIncomplete()
{
var result = Analyze(new Dictionary<uint, uint[]> { [Entry] = [0x7D8903A6, 0x4E800420] });
Assert.False(result.IsComplete);
Assert.False(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
[Fact]
public void ConditionalCallDoesNotTraverseATargetAlsoUsedByATailBranch()
{
var result = Analyze(new Dictionary<uint, uint[]>
{
[Entry] = [0x7FE802A6, 0x4182FFBD, 0x3BE00000, 0x4BFFFFB4],
[Tail] = [0x3BFF0014, 0x7FE803A6, 0x4E800020]
});
Assert.Empty(result.Offsets);
}
[Fact]
public void TailDiscoveryBudgetDoesNotProveAnOrdinaryReturn()
{
var calls = 0;
var result = LrContinuationAnalysis.Analyze(Entry, address =>
{
calls++;
return Decode(address, [0x48000040]);
});
Assert.InRange(calls, 1, 256);
Assert.False(result.IsComplete);
Assert.True(result.WasTruncated);
Assert.True(result.MaySkipReturn);
}
private static LrContinuationAnalysis Analyze(Dictionary<uint, uint[]> functions) =>
LrContinuationAnalysis.Analyze(Entry, address => functions.TryGetValue(address, out var words)
? Decode(address, words)
: throw new InvalidOperationException("No synthetic function at this address."));
private static PpcInstruction[] Decode(uint address, uint[] words) =>
words.Select((word, index) => PpcDecoder.Decode(address + (uint)index * 4, word)).ToArray();
}
@@ -0,0 +1,63 @@
using Translator.Core.Analysis.Ssa;
using Translator.Core.Analysis.Representation;
using Translator.Core.CodeGen;
using Translator.Core.Ir;
using Translator.Core.Representation;
using Xunit;
namespace Translator.Tests;
// This binary-free code-generation regression must run in the default suite.
public class LrContinuationCodeGenTests
{
[Fact]
public void CodeGenerator_DispatchesGuestCallLrContinuationWithoutMarkingTargetNonReturning()
{
var function = new IrFunction(
"lr_continuation_call",
"0x800E591C",
new[]
{
new IrBasicBlock("0x800E591C", new IrInstruction[]
{
new IrAssign("lr", IrValue.Imm(unchecked((int)0x800E5920u))),
new IrCall(string.Empty, "0x8179AC3C", Array.Empty<IrValue>()),
new IrAssign("r3", IrValue.Imm(8)),
new IrReturn(null)
}),
new IrBasicBlock("0x800E5934", new IrInstruction[]
{
new IrAssign("r3", IrValue.Imm(1)),
new IrReturn(null)
})
});
var types = new RepresentationEnvironment(new Dictionary<string, ValueRepresentation>
{
["lr"] = ValueRepresentation.UInt32,
["r3"] = ValueRepresentation.UInt32
});
var signature = new FunctionAbiClassification("lr_continuation_call", ValueRepresentation.Void);
var ssa = new SsaTransformer().Convert(function);
var code = new CxxLinearCodeGenerator().Emit(
0x800E591C,
ssa,
signature,
types,
lrContinuationCallTargets: new HashSet<uint> { 0x8179AC3Cu });
var callIndex = code.IndexOf("InvokeDirectCpu<0x8179AC3Cu>(ctx);", StringComparison.Ordinal);
var fallthroughGuardIndex = code.IndexOf("if (ctx->lr != 0x800E5920u)", callIndex, StringComparison.Ordinal);
var localCaseIndex = code.IndexOf("case 0x800E5934u:", fallthroughGuardIndex, StringComparison.Ordinal);
var returnIndex = code.IndexOf("return;", localCaseIndex, StringComparison.Ordinal);
var fallthroughAssignmentIndex = code.IndexOf("r3 = 8;", callIndex, StringComparison.Ordinal);
Assert.True(callIndex >= 0);
Assert.True(fallthroughGuardIndex > callIndex);
Assert.True(localCaseIndex > fallthroughGuardIndex);
Assert.True(returnIndex > localCaseIndex);
Assert.True(fallthroughAssignmentIndex > returnIndex, code);
Assert.Contains("goto loc_800E5934;", code);
}
}
@@ -0,0 +1,78 @@
using Translator.Core.Disassembly;
using Translator.Core.Mods;
namespace Translator.Tests;
public sealed class LrRelativeAnalysisCompletenessTests
{
[Fact]
public void EmptyResultReportsWhenPathStatesWereDropped()
{
var capped = false;
var offsets = Analyze(SyntheticLrHookFactory.ManyOrdinaryReturnPaths(), () => capped = true);
Assert.Empty(offsets);
Assert.True(capped, "An empty result from capped exploration must not prove an ordinary return.");
}
[Theory]
[InlineData(0)]
[InlineData(20)]
public void ExhaustiveAnalysisDoesNotReportAStateCap(int offset)
{
var capped = false;
var offsets = Analyze(
[
0x7D8802A6u, // mflr r12
0x398C0000u | (ushort)offset, // addi r12,r12,offset
0x7D8803A6u, // mtlr r12
0x4E800020u // blr
], () => capped = true);
Assert.False(capped);
Assert.Equal(offset == 0 ? Array.Empty<int>() : [offset], offsets);
}
[Fact]
public void CappedArmDoesNotDiscardAnOffsetFoundOnAnotherArm()
{
var capped = false;
var offsets = Analyze(
[
0x7D8802A6u, // +00: mflr r12
0x2C030000u, // +04: cmpwi r3,0
0x41820010u, // +08: beq +0x18
0x398C0014u, // +0C: addi r12,r12,20
0x7D8803A6u, // +10: mtlr r12
0x4E800020u, // +14: blr
.. SyntheticLrHookFactory.ManyOrdinaryReturnPaths() // +18
], () => capped = true);
Assert.True(capped);
Assert.Equal(new[] { 20 }, offsets);
}
private static int[] Analyze(uint[] words, Action onStateCapExceeded)
{
var instructions = words.Select((word, index) =>
PpcDecoder.Decode(0x80010000u + (uint)index * 4, word)).ToArray();
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions, onStateCapExceeded).ToArray();
}
}
internal static class SyntheticLrHookFactory
{
public static uint[] ManyOrdinaryReturnPaths()
{
// 1,024 possible states exceed the 512-state cap without an unbounded loop.
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
for (uint register = 3; register <= 12; register++)
{
words.Add(0x2C000000u | (register << 16)); // cmpwi rN,0
words.Add(0x41820008u); // beq +8
words.Add(0x38000004u | (register << 21) | (31u << 16)); // addi rN,r31,4
}
words.Add(0x4E800020u); // blr with unchanged LR
return words.ToArray();
}
}
@@ -0,0 +1,448 @@
using System.Buffers.Binary;
using Translator.Core.Disassembly;
using Translator.Core.Loading;
using Translator.Core.Mods;
using Xunit;
namespace Translator.Tests;
public class LrRelativeContinuationTests
{
[Theory]
[InlineData(20, 40)]
[InlineData(40, 20)]
public void MutuallyExclusiveAdjustmentsKeepBothOffsets(int firstOffset, int secondOffset)
{
// Both arms start with the incoming LR and join at mtlr. Adding the
// offsets together invents a continuation that neither arm can reach.
var offsets = DiscoverOffsets(
0x7FE802A6u, // +00: mflr r31
0x2C030000u, // +04: cmpwi r3,0
0x4182000Cu, // +08: beq +0x14
AddiR31(firstOffset), // +0C: addi r31,r31,firstOffset
0x48000008u, // +10: b +0x18
AddiR31(secondOffset), // +14: addi r31,r31,secondOffset
0x7FE803A6u, // +18: mtlr r31
0x4E800020u);// +1C: blr
Assert.Equal(new[] { 20, 40 }, offsets);
}
[Fact]
public void NormalReturnArmDoesNotEraseSkipReturnAtSharedBlr()
{
// The normal arm writes the original LR; it must not overwrite the
// other arm's LR + 20 in the analysis of the shared return.
var offsets = DiscoverOffsets(
0x7FE802A6u, // +00: mflr r31
0x2C030000u, // +04: cmpwi r3,0
0x41820010u, // +08: beq +0x18
0x397F0014u, // +0C: addi r11,r31,20
0x7D6803A6u, // +10: mtlr r11
0x48000008u, // +14: b +0x1C
0x7FE803A6u, // +18: mtlr r31
0x4E800020u);// +1C: blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void ConditionalNormalReturnStillDiscoversSkipOnFallthrough()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x2C030000u, // cmpwi r3,0
0x4D820020u, // beqlr
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void SavedNonvolatileLrSurvivesHelperCall()
{
// r31 survives a normal ABI call even though the call replaces LR.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x48000101u, // bl helper outside this function
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void ReloadingSavedRegisterAfterMtlrDoesNotEraseSkipReturn()
{
// A hook epilogue restores the caller's r31 after committing its
// adjusted return address to LR.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x83E10008u, // lwz r31,8(r1)
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void UnknownLrWriteReplacesEarlierSkipReturn()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void UnadjustedRegisterReturnDoesNotAddAContinuation()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void LoadMultipleWordOverwritesSavedRegistersThroughR31()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0xBB610008u, // lmw r30,8(r1)
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void BlrlCallIsNotTreatedAsReturn()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x4E800021u, // blrl
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void MflrAfterCallDoesNotTreatClobberedLrAsIncomingLr()
{
var offsets = DiscoverOffsets(
0x48000101u, // bl helper
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void RestoredIncomingLrBeforeCtrSkipStillDiscoversOffset()
{
// The helper replaces LR, but the stack save/restore recovers the
// incoming LR before the hook jumps to the caller's continuation.
var offsets = DiscoverOffsets(
0x7C0802A6u, // mflr r0
0x90010004u, // stw r0,4(r1)
0x9421FFF0u, // stwu r1,-16(r1)
0x48000101u, // bl helper outside this function
0x38210010u, // addi r1,r1,16
0x80010004u, // lwz r0,4(r1)
0x7C0803A6u, // mtlr r0
0x7D6802A6u, // mflr r11
0x396B0008u, // addi r11,r11,8
0x7D6903A6u, // mtctr r11
0x4E800420u);// bctr
Assert.Equal(new[] { 8 }, offsets);
}
[Fact]
public void BoundedLoopBeforeCtrSkipStillDiscoversOffset()
{
// Updating an LR-derived register in a two-iteration loop must not
// starve analysis of the exit, whose target uses unchanged r31.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x7FC802A6u, // mflr r30
0x38600002u, // li r3,2
0x7C6903A6u, // mtctr r3
0x3BDE0004u, // addi r30,r30,4
0x4200FFFCu, // bdnz -4
0x397F0008u, // addi r11,r31,8
0x7D6903A6u, // mtctr r11
0x4E800420u);// bctr
Assert.Equal(new[] { 8 }, offsets);
}
[Fact]
public void UntrackedR1WriteInvalidatesStackTracking()
{
// If r1 is overwritten from an untracked source, previously saved stack slots
// must not be used to recover LR state.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x80230000u, // lwz r1,0(r3)
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void LargeStraightLineHandlerStillDiscoversSkipReturn()
{
// The analyzer's global step budget is spent one step per (instruction,
// state) pair, so a long enough handler exhausts it before reaching the
// return and silently reports no continuation at all. Main's linear
// scanner had no budget and always found the offset.
var words = new List<uint>
{
0x7FE802A6u, // mflr r31
0x3BFF0014u // addi r31,r31,20
};
for (var i = 0; i < 10_010; i++)
{
words.Add(0x60000000u); // nop
}
words.Add(0x7FE803A6u); // mtlr r31
words.Add(0x4E800020u); // blr
Assert.Equal(new[] { 20 }, DiscoverOffsets(words.ToArray()));
}
[Fact]
public void LargeBranchingHandlerStillDiscoversCtrSkip()
{
// Same budget, reached far sooner once the handler branches: this is the
// bctr shape the pre-PR scanner discovered at any function size.
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
for (var i = 0; i < 160; i++)
{
var displacement = (uint)((i + 1) * 4 & 0xFFFF);
words.Add(0x2C030000u); // cmpwi r3,0
words.Add(0x4182000Cu); // beq +0xC
words.Add(0x3BDF0000u | displacement); // addi r30,r31,disp
words.Add(0x48000008u); // b +8
words.Add(0x3BBF0000u | displacement); // addi r29,r31,disp
}
words.Add(0x397F0008u); // addi r11,r31,8
words.Add(0x7D6903A6u); // mtctr r11
words.Add(0x4E800420u); // bctr
Assert.Equal(new[] { 8 }, DiscoverOffsets(words.ToArray()));
}
[Fact]
public void FloatStoreOverSavedSlotInvalidatesStackTracking()
{
// stfd writes 0x10..0x17, which covers the slot the adjusted LR was
// saved to. Only stw/stwu invalidate slots today, so the reload is
// credited with a return address the stack no longer holds.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10014u, // stw r31,0x14(r1)
0xD8410010u, // stfd f2,0x10(r1)
0x80010014u, // lwz r0,0x14(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void StoreMultipleOverSavedSlotInvalidatesStackTracking()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0xBFC10008u, // stmw r30,8(r1)
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void StackPointerUpdatePreservesAdjustedLrOffset()
{
var offsets = DiscoverOffsets(
0x7C2802A6u, // mflr r1
0xDC410004u, // stfdu f2,4(r1)
0x7C2803A6u, // mtlr r1
0x4E800020u);// blr
Assert.Equal(new[] { 4 }, offsets);
}
[Fact]
public void VolatileRegisterDoesNotSurviveHelperCall()
{
// r3 is caller-saved, so the callee is free to destroy the adjusted
// return address this hook staged before the call.
var offsets = DiscoverOffsets(
0x7C6802A6u, // mflr r3
0x38630014u, // addi r3,r3,20
0x48000101u, // bl helper outside this function
0x7C6803A6u, // mtlr r3
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void CtrDoesNotSurviveHelperCall()
{
// CTR is volatile across a call for the same reason.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE903A6u, // mtctr r31
0x48000101u, // bl helper outside this function
0x4E800420u);// bctr
Assert.Empty(offsets);
}
[Fact]
public void MflrR1InvalidatesOldStackSlots()
{
// After mflr r1, 8(r1) refers to incoming LR + 8, not the old
// stack slot. Its contents are unknown; do not invent a +20 return.
Assert.Empty(DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x7C2802A6u, // mflr r1
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u)); // blr
}
[Fact]
public void AddiR1UpdatesLrRelativeOffset()
{
// Like StackPointerUpdatePreservesAdjustedLrOffset, r1 holds incoming
// LR here. Updating r1 must update that relation as well as stack state.
Assert.Equal(new[] { 4 }, DiscoverOffsets(
0x7C2802A6u, // mflr r1
0x38210004u, // addi r1,r1,4
0x7C2803A6u, // mtlr r1
0x4E800020u)); // blr
}
[Theory]
[InlineData(0x38210004u, 4)] // addi r1,r1,4
[InlineData(0x30210004u, 4)] // addic r1,r1,4
[InlineData(0x94210004u, 4)] // stwu r1,4(r1)
[InlineData(0xD4410004u, 4)] // stfsu f2,4(r1)
[InlineData(0xDC410004u, 4)] // stfdu f2,4(r1)
[InlineData(0x3821FFFCu, -4)] // addi r1,r1,-4
public void StackPointerUpdatesPreserveLrRelation(uint update, int expectedOffset)
{
Assert.Equal(new[] { expectedOffset }, DiscoverOffsets(
0x7C2802A6u, // mflr r1
update,
0x7C2803A6u, // mtlr r1
0x4E800020u)); // blr
}
[Theory]
[InlineData(0x7FE1FB78u)] // mr r1,r31
[InlineData(0x383F0000u)] // addi r1,r31,0
public void CopyingLrIntoR1PreservesReturnButInvalidatesOldStack(uint copy)
{
var prefix = new uint[]
{
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
copy,
};
Assert.Equal(new[] { 20 }, DiscoverOffsets(
prefix.Concat(new uint[] { 0x7C2803A6u, 0x4E800020u }).ToArray()));
Assert.Empty(DiscoverOffsets(prefix.Concat(new uint[]
{
0x80010008u, // lwz r0,8(r1): no longer the old stack slot
0x7C0803A6u, // mtlr r0
0x4E800020u,
}).ToArray()));
}
[Fact]
public void StackPointerSelfMovePreservesSavedLr()
{
Assert.Equal(new[] { 20 }, DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x7C210B78u, // mr r1,r1
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u));
}
[Fact]
public void IncompleteInstructionListDoesNotInventFallthroughAcrossGap()
{
// Defensive incomplete-input test, not a production disassembly trace:
// the missing instruction could overwrite r31 or branch elsewhere.
// Address sorting alone does not establish a fallthrough edge.
var instructions = new[]
{
PpcDecoder.Decode(0x81800000u, 0x7FE802A6u), // mflr r31
PpcDecoder.Decode(0x81800008u, 0x3BFF0014u), // addi r31,r31,20
PpcDecoder.Decode(0x8180000Cu, 0x7FE803A6u), // mtlr r31
PpcDecoder.Decode(0x81800010u, 0x4E800020u), // blr
};
Assert.Empty(ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions));
}
private static uint AddiR31(int offset) => 0x3BFF0000u | (uint)(offset & 0xFFFF);
private static int[] DiscoverOffsets(params uint[] words)
{
const uint entry = 0x81800000u;
var memory = new byte[words.Length * 4];
for (var i = 0; i < words.Length; i++)
{
BinaryPrimitives.WriteUInt32BigEndian(memory.AsSpan(i * 4, 4), words[i]);
}
var range = AddressRange.FromStartAndSize(entry, (uint)memory.Length);
var image = new ProgramImage(memory, range, range, default, "lr-continuation-test", entry);
using var disassembler = new PpcDisassembler();
// Use the production reachable-instruction traversal and ordering,
// rather than handing the planner an artificial execution trace.
var instructions = disassembler.DisassembleFunction(
image, entry, maxInstructions: words.Length + 1, maxBytes: memory.Length);
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions)
.Distinct().OrderBy(offset => offset).ToArray();
}
}