From 466d06d7db9b4449f45ee79cb854ee8ac322b66f Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:38:04 +0200
Subject: [PATCH 01/14] Update README with image and credit modifications
Added an image to the README and updated credits section.
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 396cf9b..e9629d1 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+
# WiiCompiled
@@ -201,7 +202,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
From 0bb15f0a44d6c569e5ca17c4436faf1922915eeb Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 09:48:03 +0200
Subject: [PATCH 02/14] Update building-macos.md
---
docs/building-macos.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/building-macos.md b/docs/building-macos.md
index 6c8aa95..6d0870e 100644
--- a/docs/building-macos.md
+++ b/docs/building-macos.md
@@ -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`.
---
@@ -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:
From 25c69ae28e46a127d87db8d394f857e34da54f99 Mon Sep 17 00:00:00 2001
From: Michael G <10155689+DarthMDev@users.noreply.github.com>
Date: Thu, 10 Sep 2026 09:51:16 -0400
Subject: [PATCH 03/14] 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>
---
translator/src/Translator.Cli/Program.cs | 150 +----
.../Mods/ContinuationPlanner.cs | 527 ++++++++++++++++++
.../ContinuationPlannerTests.cs | 50 ++
.../LrContinuationCodeGenTests.cs | 63 +++
.../LrRelativeContinuationTests.cs | 448 +++++++++++++++
5 files changed, 1112 insertions(+), 126 deletions(-)
create mode 100644 translator/tests/Translator.Tests/LrContinuationCodeGenTests.cs
create mode 100644 translator/tests/Translator.Tests/LrRelativeContinuationTests.cs
diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs
index 9eee788..ac1f3c0 100644
--- a/translator/src/Translator.Cli/Program.cs
+++ b/translator/src/Translator.Cli/Program.cs
@@ -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? retroWfcExecutableHooks,
+ KamekPatchPlan patchPlan,
IReadOnlyList kamekFunctionStarts,
uint moduleLinkBase,
uint moduleLinkedCodeSize,
@@ -2272,14 +2274,25 @@ int EmitModCpp(
.ToHashSet();
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
var discoveredContinuationQueue = new Queue();
- var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null
- ? new Dictionary()
- : 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)));
+ }
+ 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);
+ 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()
@@ -2750,123 +2763,8 @@ IEnumerable DirectModuleTargets(FunctionTranslationResult result, uint mod
}
}
-IEnumerable DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result)
-{
- var lrOffsets = new Dictionary(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();
-}
+IEnumerable DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
+ ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
hook.TypeName is "call" or "branchCtrLink" ||
diff --git a/translator/src/Translator.Core/Mods/ContinuationPlanner.cs b/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
index 7027278..e583bd5 100644
--- a/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
+++ b/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
@@ -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,530 @@ public static class ContinuationPlanner
Or,
AddSigned
}
+
+ public static IEnumerable DiscoverLrRelativeIndirectJumpOffsets(IReadOnlyList instructions)
+ {
+ if (instructions.Count == 0)
+ {
+ yield break;
+ }
+
+ var indexByAddress = new Dictionary(instructions.Count);
+ for (var i = 0; i < instructions.Count; i++)
+ {
+ indexByAddress.TryAdd(instructions[i].Address, i);
+ }
+
+ var visited = new HashSet[instructions.Count];
+ for (var i = 0; i < instructions.Count; i++)
+ {
+ visited[i] = new HashSet();
+ }
+
+ var seenOffsets = new HashSet();
+ var worklist = new Queue<(int Index, PathState State)>();
+ const int MaxStatesPerInstruction = 16;
+
+ 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)
+ {
+ 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;
+ }
+
+ 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 && 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)
+ {
+ 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 destinations)
+ {
+ if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
+ {
+ destinations = Array.Empty();
+ 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();
+ 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
+ {
+ public ImmutableDictionary LrOffsets { get; }
+ public int? CtrOffset { get; }
+ public int? LrReturnOffset { get; }
+ public int SpDelta { get; }
+ public ImmutableDictionary StackOffsets { get; }
+
+ public PathState(
+ ImmutableDictionary lrOffsets,
+ int? ctrOffset,
+ int? lrReturnOffset,
+ int spDelta,
+ ImmutableDictionary stackOffsets)
+ {
+ LrOffsets = lrOffsets;
+ CtrOffset = ctrOffset;
+ LrReturnOffset = lrReturnOffset;
+ SpDelta = spDelta;
+ StackOffsets = stackOffsets;
+ }
+
+ public static readonly PathState Empty = new(
+ ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
+ null,
+ 0,
+ 0,
+ ImmutableDictionary.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.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();
+ }
+ }
}
diff --git a/translator/tests/Translator.Tests/ContinuationPlannerTests.cs b/translator/tests/Translator.Tests/ContinuationPlannerTests.cs
index efa2c51..08aae9a 100644
--- a/translator/tests/Translator.Tests/ContinuationPlannerTests.cs
+++ b/translator/tests/Translator.Tests/ContinuationPlannerTests.cs
@@ -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,
diff --git a/translator/tests/Translator.Tests/LrContinuationCodeGenTests.cs b/translator/tests/Translator.Tests/LrContinuationCodeGenTests.cs
new file mode 100644
index 0000000..c11bff6
--- /dev/null
+++ b/translator/tests/Translator.Tests/LrContinuationCodeGenTests.cs
@@ -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()),
+ 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
+ {
+ ["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 { 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);
+ }
+}
diff --git a/translator/tests/Translator.Tests/LrRelativeContinuationTests.cs b/translator/tests/Translator.Tests/LrRelativeContinuationTests.cs
new file mode 100644
index 0000000..ec58376
--- /dev/null
+++ b/translator/tests/Translator.Tests/LrRelativeContinuationTests.cs
@@ -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
+ {
+ 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 { 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();
+ }
+}
From 149cfef60891f38e1c04e52df0c2f240a6ec53f4 Mon Sep 17 00:00:00 2001
From: Cristian Boehm <75502884+GalaxisBeast@users.noreply.github.com>
Date: Thu, 10 Sep 2026 11:37:38 -0400
Subject: [PATCH 04/14] 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; 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>
---
README.md | 14 +
aurora-main/include/dolphin/pad.h | 16 +
aurora-main/lib/dolphin/pad/pad.cpp | 49 ++-
runtime/include/controller_button_names.h | 23 +-
runtime/src/input_bindings.cpp | 7 +-
runtime/src/settings_overlay.cpp | 349 +++++++++++++++++++---
6 files changed, 399 insertions(+), 59 deletions(-)
diff --git a/README.md b/README.md
index e9629d1..c5088c0 100644
--- a/README.md
+++ b/README.md
@@ -55,10 +55,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 (1–100%, 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
diff --git a/aurora-main/include/dolphin/pad.h b/aurora-main/include/dolphin/pad.h
index 6f821c3..69dfcd3 100644
--- a/aurora-main/include/dolphin/pad.h
+++ b/aurora-main/include/dolphin/pad.h
@@ -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;
diff --git a/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp
index bb82c24..75ef372 100644
--- a/aurora-main/lib/dolphin/pad/pad.cpp
+++ b/aurora-main/lib/dolphin/pad/pad.cpp
@@ -319,6 +319,18 @@ std::array g_suppressLeftTrigger{};
std::array 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(axis));
+ if (PADAxisButtonNegative(binding)) value = -value;
+ return value > 0 && value * 100 >= static_cast(threshold) * 32767;
+ }
+ return binding < SDL_GAMEPAD_BUTTON_COUNT &&
+ SDL_GetGamepadButton(gamepad, static_cast(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(std::min(static_cast(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(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(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(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L));
Sint16 tr = std::max(static_cast(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;
}
diff --git a/runtime/include/controller_button_names.h b/runtime/include/controller_button_names.h
index d0d2b81..ab61c03 100644
--- a/runtime/include/controller_button_names.h
+++ b/runtime/include/controller_button_names.h
@@ -48,8 +48,19 @@ struct NativeButtonItem {
uint32_t nativeButton;
};
-inline constexpr std::array kNativeButtons = {{
- {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
+inline constexpr auto kNativeButtons = std::to_array({
+ {"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 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) {
diff --git a/runtime/src/input_bindings.cpp b/runtime/src/input_bindings.cpp
index 6ffa984..03d2456 100644
--- a/runtime/src/input_bindings.cpp
+++ b/runtime/src/input_bindings.cpp
@@ -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(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(native->nativeButton)) ? 1.0
: 0.0;
}
diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp
index 8856ba5..aad825d 100644
--- a/runtime/src/settings_overlay.cpp
+++ b/runtime/src/settings_overlay.cpp
@@ -20,6 +20,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -186,6 +187,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 +217,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 +262,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 +270,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 +415,219 @@ 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(scancode)) : "Unknown";
+ }
+}
+
+enum class RebindKind { KeyboardButton, KeyboardAxis, Controller };
+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 keys{};
+ uint32_t mouse = 0;
+ std::array buttons{};
+ std::array 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(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(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(i));
+ for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i)
+ g_rebind.axesReady[i] = std::abs(static_cast(SDL_GetGamepadAxis(pad, static_cast(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::KeyboardButton) {
+ PADSetKeyButtonBinding(capture.port, {static_cast(value), capture.target});
+ } else {
+ PADSetKeyAxisBinding(capture.port, {static_cast(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."
+ : "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(g_rebind.deadline - Clock::now()).count();
+ ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast(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(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(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 && (mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast(-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(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(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) {
+ const std::string caption = std::string(KeyBindingName(scancode)) + "##binding";
+ if (ImGui::Button(caption.c_str(), ImVec2(220.0f, 0.0f))) BeginRebind(kind, target, label);
+ ImGui::SameLine();
+ 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 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 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(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(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 +734,10 @@ void DrawControllerSettings() {
ImGui::Separator();
const uint32_t selectedGamePort = static_cast(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 +779,10 @@ void DrawControllerSettings() {
PADGetAltButtonMappings(static_cast(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 +840,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 +864,40 @@ void DrawControllerSettings() {
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
ImGui::PushID(static_cast(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(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(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(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 +906,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(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();
@@ -936,10 +1176,27 @@ void DrawStartupScreen() {
}
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 +1225,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();
}
@@ -1074,7 +1334,12 @@ 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(PAD_KEY_INVALID));
+ }
+ if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
SetTopBarVisible(!g_topBarVisible);
}
if (IsMouseActivity(ev->sdl)) {
@@ -1102,7 +1367,7 @@ void Draw() noexcept {
DrawTopBar();
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();
From 53d8f71c68e94f44c9b4d6ed925cfe7c7bb81103 Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Sat, 12 Sep 2026 09:54:51 +0200
Subject: [PATCH 05/14] Update README.md
---
README.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/README.md b/README.md
index c5088c0..0922cfd 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,18 @@
# WiiCompiled
+
+
+
+
+
+
+
+
+
+
+
+
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
From b555ede2d3288da17b5433bb2f07d20499041074 Mon Sep 17 00:00:00 2001
From: devangpratap <115096812+devangpratap@users.noreply.github.com>
Date: Sun, 13 Sep 2026 04:35:44 -0400
Subject: [PATCH 06/14] 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
---
docs/building-macos.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/building-macos.md b/docs/building-macos.md
index 6d0870e..f1084c8 100644
--- a/docs/building-macos.md
+++ b/docs/building-macos.md
@@ -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
From 4bdaff01fc2afaa9451e6ec3d88f8e444eaa8ba3 Mon Sep 17 00:00:00 2001
From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com>
Date: Sun, 13 Sep 2026 08:07:54 -0400
Subject: [PATCH 07/14] Add Exit button + controller led fix (#221)
---
runtime/include/aurora_events.h | 1 +
runtime/include/settings_overlay.h | 2 ++
runtime/src/settings_overlay.cpp | 50 ++++++++++++++++++++++++++++--
3 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/runtime/include/aurora_events.h b/runtime/include/aurora_events.h
index ddb2df8..1fcc911 100644
--- a/runtime/include/aurora_events.h
+++ b/runtime/include/aurora_events.h
@@ -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);
diff --git a/runtime/include/settings_overlay.h b/runtime/include/settings_overlay.h
index b706989..631eb3a 100644
--- a/runtime/include/settings_overlay.h
+++ b/runtime/include/settings_overlay.h
@@ -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
diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp
index aad825d..9354800 100644
--- a/runtime/src/settings_overlay.cpp
+++ b/runtime/src/settings_overlay.cpp
@@ -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,6 +16,7 @@
#include
#include
#include
+#include
#include
#include
@@ -70,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);
@@ -1175,6 +1178,18 @@ 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) {
return;
@@ -1243,11 +1258,17 @@ void DrawTopBar() {
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();
}
@@ -1342,12 +1363,36 @@ void HandleEvents(const AuroraEvent* events) noexcept {
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
SetTopBarVisible(!g_topBarVisible);
}
+ 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(index))) {
+ SDL_SetGamepadLED(pad, 0, 0, 0);
+ queued = true;
+ }
+ }
+ constexpr std::array 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.
@@ -1365,6 +1410,7 @@ 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() || g_rebind.active;
From 209405dfb72c73d6bc26f5214bf4dfe0f8baae2f Mon Sep 17 00:00:00 2001
From: theofficialgman <28281419+theofficialgman@users.noreply.github.com>
Date: Sun, 13 Sep 2026 08:15:30 -0400
Subject: [PATCH 08/14] 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
---
Launcher/Prepare-Dependencies.ps1 | 4 +--
aurora-main/cmake/AuroraDawnProvider.cmake | 33 ++++++++++++++++++----
2 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/Launcher/Prepare-Dependencies.ps1 b/Launcher/Prepare-Dependencies.ps1
index ec80895..7e6a699 100644
--- a/Launcher/Prepare-Dependencies.ps1
+++ b/Launcher/Prepare-Dependencies.ps1
@@ -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'
diff --git a/aurora-main/cmake/AuroraDawnProvider.cmake b/aurora-main/cmake/AuroraDawnProvider.cmake
index 002344b..da71fb8 100644
--- a/aurora-main/cmake/AuroraDawnProvider.cmake
+++ b/aurora-main/cmake/AuroraDawnProvider.cmake
@@ -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}")
From 6458ec6abebb2289a3ee42d9ffcb3713c3193b5c Mon Sep 17 00:00:00 2001
From: jamie
Date: Sun, 13 Sep 2026 21:49:40 +0100
Subject: [PATCH 09/14] 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
---
Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs | 7 ++++++-
Launcher/WiiCompiled.Setup.Linux/Program.cs | 12 +++++++++++-
Launcher/local-build.sh | 12 ++++++++++++
3 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
index 60e1711..284310a 100644
--- a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
@@ -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);
diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs
index 267bb68..9595398 100644
--- a/Launcher/WiiCompiled.Setup.Linux/Program.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs
@@ -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
diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh
index 7d9989d..bdca15e 100755
--- a/Launcher/local-build.sh
+++ b/Launcher/local-build.sh
@@ -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[@]}"
From 8e0cc968988722e6f28e262ba24aa945b9372597 Mon Sep 17 00:00:00 2001
From: theofficialgman <28281419+theofficialgman@users.noreply.github.com>
Date: Mon, 14 Sep 2026 10:47:28 -0400
Subject: [PATCH 10/14] =?UTF-8?q?Scope=20Kamek=20bl-patch=20LR-continuatio?=
=?UTF-8?q?n=20detection=20to=20genuine=20skip-return=E2=80=A6=20(#218)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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>
---
translator/src/Translator.Cli/Program.cs | 86 +++---
.../Mods/ContinuationPlanner.cs | 17 +-
.../Mods/LrContinuationAnalysis.cs | 83 ++++++
.../KamekLrContinuationIntegrationTests.cs | 261 ++++++++++++++++++
.../LrContinuationAnalysisTests.cs | 125 +++++++++
.../LrRelativeAnalysisCompletenessTests.cs | 78 ++++++
6 files changed, 608 insertions(+), 42 deletions(-)
create mode 100644 translator/src/Translator.Core/Mods/LrContinuationAnalysis.cs
create mode 100644 translator/tests/Translator.Tests/KamekLrContinuationIntegrationTests.cs
create mode 100644 translator/tests/Translator.Tests/LrContinuationAnalysisTests.cs
create mode 100644 translator/tests/Translator.Tests/LrRelativeAnalysisCompletenessTests.cs
diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs
index ac1f3c0..2ffd646 100644
--- a/translator/src/Translator.Cli/Program.cs
+++ b/translator/src/Translator.Cli/Program.cs
@@ -2282,9 +2282,37 @@ int EmitModCpp(
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
}
+ var hookLrAnalysis = new Dictionary();
+ var hookDiscoveryCache = new Dictionary>();
+ IReadOnlyList 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)));
}
@@ -2387,50 +2415,42 @@ int EmitModCpp(
}
}
- void RecordDiscoveredLrRelativeBaseContinuations(
- FunctionTranslationResult result,
- IReadOnlyList lrBases,
- string reason)
+ void RecordHookContinuations(uint hookTarget, IReadOnlyList 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}";
@@ -2596,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();
@@ -2763,9 +2774,6 @@ IEnumerable DirectModuleTargets(FunctionTranslationResult result, uint mod
}
}
-IEnumerable DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
- ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
-
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
hook.TypeName is "call" or "branchCtrLink" ||
hook.Intent.Contains("Call", StringComparison.Ordinal);
diff --git a/translator/src/Translator.Core/Mods/ContinuationPlanner.cs b/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
index e583bd5..bb34523 100644
--- a/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
+++ b/translator/src/Translator.Core/Mods/ContinuationPlanner.cs
@@ -275,7 +275,13 @@ public static class ContinuationPlanner
AddSigned
}
- public static IEnumerable DiscoverLrRelativeIndirectJumpOffsets(IReadOnlyList instructions)
+ // Dropped states make a negative result inconclusive.
+ private const int MaxStatesPerInstruction = 512;
+
+ public static IEnumerable DiscoverLrRelativeIndirectJumpOffsets(
+ IReadOnlyList instructions,
+ Action? onStateCapExceeded = null,
+ Action? onUnresolvedExit = null)
{
if (instructions.Count == 0)
{
@@ -296,7 +302,6 @@ public static class ContinuationPlanner
var seenOffsets = new HashSet();
var worklist = new Queue<(int Index, PathState State)>();
- const int MaxStatesPerInstruction = 16;
void Enqueue(int targetIndex, PathState stateToEnqueue)
{
@@ -325,6 +330,7 @@ public static class ContinuationPlanner
if (visited[idx].Count > MaxStatesPerInstruction)
{
+ onStateCapExceeded?.Invoke();
continue;
}
@@ -484,6 +490,9 @@ public static class ContinuationPlanner
yield return state.CtrOffset.Value;
}
+ if (!state.CtrOffset.HasValue && instruction.BranchTargets.Count == 0)
+ onUnresolvedExit?.Invoke();
+
nextState = nextState.WithCtrOffset(null);
if (instruction.BranchTargets.Count == 0)
{
@@ -495,6 +504,8 @@ public static class ContinuationPlanner
(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;
@@ -524,7 +535,7 @@ public static class ContinuationPlanner
Enqueue(fallthrough.Value, nextState);
}
- if (!isReturn)
+ if (!isReturn && !instruction.IsCall)
{
foreach (var target in instruction.BranchTargets)
{
diff --git a/translator/src/Translator.Core/Mods/LrContinuationAnalysis.cs b/translator/src/Translator.Core/Mods/LrContinuationAnalysis.cs
new file mode 100644
index 0000000..229a073
--- /dev/null
+++ b/translator/src/Translator.Core/Mods/LrContinuationAnalysis.cs
@@ -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 Offsets, bool IsComplete, bool WasTruncated)
+{
+ public bool MaySkipReturn => !IsComplete || Offsets.Count != 0;
+
+ public static LrContinuationAnalysis Analyze(
+ uint entryPoint,
+ Func> discover)
+ {
+ const int maxFunctions = 256;
+ const int maxInstructions = 65536;
+ var instructions = new Dictionary();
+ var pending = new Queue();
+ var visited = new HashSet();
+ 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 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);
+ }
+}
diff --git a/translator/tests/Translator.Tests/KamekLrContinuationIntegrationTests.cs b/translator/tests/Translator.Tests/KamekLrContinuationIntegrationTests.cs
new file mode 100644
index 0000000..689136b
--- /dev/null
+++ b/translator/tests/Translator.Tests/KamekLrContinuationIntegrationTests.cs
@@ -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
+ {
+ 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)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(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);
+}
diff --git a/translator/tests/Translator.Tests/LrContinuationAnalysisTests.cs b/translator/tests/Translator.Tests/LrContinuationAnalysisTests.cs
new file mode 100644
index 0000000..a8de6a7
--- /dev/null
+++ b/translator/tests/Translator.Tests/LrContinuationAnalysisTests.cs
@@ -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
+ {
+ [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
+ {
+ [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
+ {
+ [Entry] = [0x4BFFFFC0],
+ [Tail] = [0x48000040]
+ });
+ Assert.True(result.IsComplete);
+ Assert.False(result.MaySkipReturn);
+ }
+
+ [Fact]
+ public void AdjustingTailCycleReportsIncompleteAnalysis()
+ {
+ var result = Analyze(new Dictionary
+ {
+ [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 { [Entry] = [0x4BFFFFC0] });
+ Assert.False(result.IsComplete);
+ Assert.True(result.WasTruncated);
+ Assert.True(result.MaySkipReturn);
+ }
+
+ [Fact]
+ public void NormalCallDoesNotInheritItsCalleesSkipOffset()
+ {
+ var visited = new List();
+ 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 { [Entry] = [0x7D8903A6, 0x4E800420] });
+ Assert.False(result.IsComplete);
+ Assert.False(result.WasTruncated);
+ Assert.True(result.MaySkipReturn);
+ }
+
+ [Fact]
+ public void ConditionalCallDoesNotTraverseATargetAlsoUsedByATailBranch()
+ {
+ var result = Analyze(new Dictionary
+ {
+ [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 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();
+}
diff --git a/translator/tests/Translator.Tests/LrRelativeAnalysisCompletenessTests.cs b/translator/tests/Translator.Tests/LrRelativeAnalysisCompletenessTests.cs
new file mode 100644
index 0000000..d5439c8
--- /dev/null
+++ b/translator/tests/Translator.Tests/LrRelativeAnalysisCompletenessTests.cs
@@ -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() : [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 { 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();
+ }
+}
From 8705e957c7c9533844fa4a9a54b0a0939a4d0297 Mon Sep 17 00:00:00 2001
From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com>
Date: Mon, 14 Sep 2026 11:00:08 -0400
Subject: [PATCH 11/14] Fix autohide cursor + mute hotkey (#211)
* Fix autohide cursor + mute hotkey
* fixes
* Fix rebinding + spacing fixes
---
runtime/include/runtime_config.h | 13 ++++++++++++
runtime/src/settings_overlay.cpp | 36 ++++++++++++++++++++++++++------
2 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h
index c8cdf80..4167572 100644
--- a/runtime/include/runtime_config.h
+++ b/runtime/include/runtime_config.h
@@ -91,6 +91,7 @@ struct RuntimeUserConfig {
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
std::array, 12> controllerButtons;
std::optional rumbleEnabled;
+ std::optional muteHotkey;
std::map controllerExpressions;
};
@@ -411,6 +412,9 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
}
config.rumbleEnabled = FindConfigValue(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;
diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp
index 9354800..8127eae 100644
--- a/runtime/src/settings_overlay.cpp
+++ b/runtime/src/settings_overlay.cpp
@@ -83,6 +83,7 @@ int g_soundEffectsVolumePercent =
int g_uiVolumePercent = static_cast(std::lround(RuntimeConfigFile::UiVolume(1.0f) * 100.0f));
int g_voicesVolumePercent = static_cast(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 = [] {
@@ -432,7 +433,7 @@ const char* KeyBindingName(int scancode) {
}
}
-enum class RebindKind { KeyboardButton, KeyboardAxis, Controller };
+enum class RebindKind { KeyboardButton, KeyboardAxis, Controller, MuteHotkey };
struct RebindState {
bool active = false;
bool openPopup = false;
@@ -498,6 +499,11 @@ void CompleteRebind(uint32_t value) {
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(value);
+ RuntimeConfigFile::SetMuteHotkey(g_muteHotkey);
+ g_rebind.active = false;
+ return;
} else if (capture.kind == RebindKind::KeyboardButton) {
PADSetKeyButtonBinding(capture.port, {static_cast(value), capture.target});
} else {
@@ -520,7 +526,9 @@ void DrawRebindPrompt() {
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."
- : "Press a keyboard key or click a mouse button.");
+ : 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(g_rebind.deadline - Clock::now()).count();
@@ -542,7 +550,8 @@ void DrawRebindPrompt() {
}
const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr);
for (int i = 1; i <= 5 && g_rebind.active; ++i)
- if (!overControl && (mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast(-i - 1));
+ if (!overControl && g_rebind.kind != RebindKind::MuteHotkey &&
+ (mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast(-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);
@@ -565,10 +574,11 @@ void DrawRebindPrompt() {
ImGui::EndPopup();
}
-void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target) {
+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(220.0f, 0.0f))) BeginRebind(kind, target, label);
- ImGui::SameLine();
+ 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);
}
@@ -955,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
@@ -1255,6 +1269,7 @@ void DrawTopBar() {
const std::string audioMenuLabel = audioLabel + "###AudioSettingsMenu";
if (ImGui::BeginMenu(audioMenuLabel.c_str())) {
DrawAudioSettings();
+ DrawRebindPrompt();
ImGui::EndMenu();
}
@@ -1297,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();
}
}
@@ -1363,6 +1381,12 @@ void HandleEvents(const AuroraEvent* events) noexcept {
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(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;
}
From 8ec3a1b752dffaee1343552ee4c7e707c97dc7d0 Mon Sep 17 00:00:00 2001
From: theofficialgman <28281419+theofficialgman@users.noreply.github.com>
Date: Mon, 14 Sep 2026 12:27:49 -0400
Subject: [PATCH 12/14] 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
---
Launcher/prepare-portable-tools.sh | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh
index 5585c2f..6517786 100755
--- a/Launcher/prepare-portable-tools.sh
+++ b/Launcher/prepare-portable-tools.sh
@@ -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"
From 6fb593749ee701545a7f1b77a8b0d505ee6d0f29 Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Mon, 14 Sep 2026 20:36:18 +0200
Subject: [PATCH 13/14] version
---
Launcher/Build-Installer.ps1 | 2 +-
.../WiiCompiled.Setup.Common.Cli.csproj | 2 +-
.../WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj | 2 +-
Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +-
Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj | 2 +-
Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +-
.../WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj | 2 +-
7 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1
index eb70930..6113d7f 100644
--- a/Launcher/Build-Installer.ps1
+++ b/Launcher/Build-Installer.ps1
@@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo
$manifest = [ordered]@{
SchemaVersion = 2
- ProductVersion = '0.2.31'
+ ProductVersion = '0.2.32'
ExpectedGameId = $pins.GameId
ExpectedDolSha256 = $pins.DolSha256
ExpectedRelSha256 = $pins.RelSha256
diff --git a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
index 97e8ecb..e58d15b 100644
--- a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
+++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Common.Cli
WiiCompiled.Setup.Common.Cli
- 0.2.31
+ 0.2.32
patchzy
WiiCompiled
Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1
diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
index aebdc1b..056ef5c 100644
--- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
+++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
@@ -5,7 +5,7 @@
enable
WiiCompiled.Setup.Common
WiiCompiled.Setup.Common
- 0.2.31
+ 0.2.32
patchzy
WiiCompiled
Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers
diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs
index b8f8ea6..ebe9964 100644
--- a/Launcher/WiiCompiled.Setup.Linux/Models.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.31";
+ public const string Version = "0.2.32";
}
/// One installed product's record inside install-state.json.
diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
index 84d3269..639ec22 100644
--- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
+++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj
@@ -6,7 +6,7 @@
enable
WiiCompiled.Setup.Linux
WiiCompiled.Setup.Linux
- 0.2.31
+ 0.2.32
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled on Linux
diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs
index 7995f17..4cdba8a 100644
--- a/Launcher/WiiCompiled.Setup.Windows/Program.cs
+++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs
@@ -121,7 +121,7 @@ internal static class PlatformChecks
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
- public const string Version = "0.2.31";
+ public const string Version = "0.2.32";
///
/// The setup executable is copied into the installation under this name. It is the launcher and
diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
index 0efaa73..9559e6f 100644
--- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
+++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj
@@ -7,7 +7,7 @@
WiiCompiled.Setup
WiiCompiled.Setup.Windows
app.manifest
- 0.2.31
+ 0.2.32
patchzy
WiiCompiled
Command-line installer and launcher for WiiCompiled
From 7e6604c4157d3a6f937d1a03ac9818fd85bba0a9 Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Tue, 15 Sep 2026 22:53:20 +0200
Subject: [PATCH 14/14] Why did this change the readme?
I merged a PR and missed it changed the readme...
---
README.md | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/README.md b/README.md
index 0922cfd..aceca10 100644
--- a/README.md
+++ b/README.md
@@ -66,26 +66,6 @@ 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 (1–100%, 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
and the same functions.