5 Commits

Author SHA1 Message Date
patchzyy 53d8f71c68 Update README.md 2026-09-12 09:54:51 +02:00
Cristian Boehm 149cfef608 keyboard and mouse support, rebinding overhaul, analog triggers to digital inputs (#162)
* add keyboard support, analog triggers to digital input, and rebinding overhaul

* Update README.md

* Update README.md

readme typo

* implemented code rabbits suggestions

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

* add dimming when in settings and add clear mapping button

* Update settings_overlay.cpp

---------

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

* test: cover branching Kamek LR continuations

* review fix

* another review fix

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

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

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

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

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

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

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

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

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

* Harden LR-relative continuation test coverage

* Fix LR/SP continuation state tracking

---------

Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-10 15:51:16 +02:00
patchzyy 0bb15f0a44 Update building-macos.md 2026-09-10 09:48:03 +02:00
patchzyy 466d06d7db Update README with image and credit modifications
Added an image to the README and updated credits section.
2026-09-10 08:38:04 +02:00
12 changed files with 1527 additions and 188 deletions
+28 -1
View File
@@ -1,6 +1,19 @@
<img width="4190" height="1232" alt="wiicomplogofinalfinalfinalev2MADEBY_INKWRECK_plzcredit" src="https://github.com/user-attachments/assets/df7a3f2e-5336-479a-b4c0-968dd578726d" />
# WiiCompiled # WiiCompiled
<p align="center">
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Windows 10 / 11, x64" src="https://img.shields.io/badge/Windows-10%20%2F%2011%20%C2%B7%20x64-0078D4"></a>
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="Linux, x64 / ARM64" src="https://img.shields.io/badge/Linux-x64%20%2F%20ARM64-FCC624?logo=linux&amp;logoColor=white"></a>
<a href="https://github.com/patchzyy/Wiicompiled/releases"><img alt="macOS 14+, Apple Silicon" src="https://img.shields.io/badge/macOS-14%2B%20%C2%B7%20Apple%20Silicon-0A84FF?logo=apple&amp;logoColor=white"></a>
</p>
<p align="center">
<a href="#building-from-source"><img alt="PowerPC static recompilation" src="https://img.shields.io/badge/PowerPC-static%20recompilation-FF9F0A"></a>
<a href="#retro-rewind"><img alt="Retro Rewind supported" src="https://img.shields.io/badge/Retro%20Rewind-supported-FF375F"></a>
<a href="https://github.com/TeamWheelWizard/WheelWizard/releases"><img alt="Install with Wheel Wizard" src="https://img.shields.io/badge/install%20with-Wheel%20Wizard-8B5CF6"></a>
<a href="LICENSE"><img alt="License: GPLv3" src="https://img.shields.io/badge/license-GPLv3-2EA44F?logo=gnu&amp;logoColor=white"></a>
</p>
A native PC port of Mario Kart Wii, made with static recompilation. 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 There's no emulator in the loop, no interpreter, no JIT, no PowerPC
@@ -54,10 +67,24 @@ Press **F10** while the game window has focus:
Everything you change is saved to `Config.toml` on the spot and restored next launch. Everything you change is saved to `Config.toml` on the spot and restored next launch.
**Real controller support.** **Real controller support.**
Controllers are fed to the game as a GameCube controller. Controllers are fed to the game as a GameCube controller.
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the 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 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. inputs like paddles, touchpads and share buttons show up when the hardware reports them.
Both button-binding slots also accept SDL triggers and stick directions. Selecting an analog
input shows a threshold slider beneath it (1100%, default 50%); reaching that amount of travel
holds the chosen digital button. Each binding's threshold is saved independently in `Config.toml`
(for example, `a = "right_trigger@35,south"`).
**Keyboard and Mouse support.**
Keyboard and mouse are also available through **F10 > Controller settings > Keyboard and mouse**
for each port. Enabling this replaces that port's gamepad input. The default preset uses WASD
for the main stick, left mouse for A (accelerate), Space for B (brake), right mouse for R
(drift), middle mouse for Z (item), arrow keys for the D-pad (tricks), and Enter for Start.
Keys and mouse buttons can be remapped, including both sticks and triggers; mouse movement
is not used. These settings are saved in `keyboard_bindings.dat` and restored next launch.
**Dolphin-compatible input expressions.** **Dolphin-compatible input expressions.**
Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
@@ -201,7 +228,7 @@ AI coding tools were used during development of this project.
All translated output is verified against real hardware behavior and most importantly, physics accuracy is proven synced across Wii, Dolphin, and WiiCompiled (see FAQ). 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 ## Credits
- **inkwreck** - making the logo
- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this - **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
project's whole graphics layer sits on. MIT licensed. project's whole graphics layer sits on. MIT licensed.
- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering - **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
+16
View File
@@ -171,6 +171,22 @@ typedef struct PADButtonMapping {
PADButton padButton; PADButton padButton;
} PADButtonMapping; } 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 { typedef struct PADAxisMapping {
PADSignedNativeAxis nativeAxis; PADSignedNativeAxis nativeAxis;
s32 nativeButton; s32 nativeButton;
+39 -10
View File
@@ -319,6 +319,18 @@ std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{}; std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; } bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; }
bool is_native_binding_pressed(SDL_Gamepad* gamepad, u32 binding) {
if (PADIsAxisButton(binding)) {
const u32 axis = PADAxisButtonAxis(binding);
const u32 threshold = PADAxisButtonThreshold(binding);
if (axis >= SDL_GAMEPAD_AXIS_COUNT || threshold < 1 || threshold > 100) return false;
int value = SDL_GetGamepadAxis(gamepad, static_cast<SDL_GamepadAxis>(axis));
if (PADAxisButtonNegative(binding)) value = -value;
return value > 0 && value * 100 >= static_cast<int>(threshold) * 32767;
}
return binding < SDL_GAMEPAD_BUTTON_COUNT &&
SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(binding));
}
bool is_mouse_button_pressed(const s32 scancode) { bool is_mouse_button_pressed(const s32 scancode) {
const int32_t buttonNum = -(scancode + 1); const int32_t buttonNum = -(scancode + 1);
if (buttonNum < 1 || buttonNum > 5) { if (buttonNum < 1 || buttonNum > 5) {
@@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) {
} }
status[i].err = PAD_ERR_NONE; 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( std::ranges::for_each(
g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) { g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) {
if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) { if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) {
status[i].button |= mapping.padButton; status[i].button |= mapping.padButton;
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) { } else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
status[i].button |= mapping.padButton; status[i].button |= mapping.padButton;
@@ -788,7 +800,7 @@ u32 PADRead(PADStatus* status) {
status[i].triggerRight = static_cast<u8>(std::min(static_cast<int>(status[i].triggerRight) + tr, 255)); status[i].triggerRight = static_cast<u8>(std::min(static_cast<int>(status[i].triggerRight) + tr, 255));
} }
if (controller) { if (controller && !g_keyboardBindings[i].m_mappingsSet) {
EnsureMappingLoaded(controller); EnsureMappingLoaded(controller);
// Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts // 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; bool rightTriggerSet = false;
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet, std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
&rightTriggerSet](const auto& mapping) { &rightTriggerSet](const auto& mapping) {
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) { if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
status[i].button |= mapping.padButton; status[i].button |= mapping.padButton;
} }
@@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) {
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) { if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
return; return;
} }
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) { if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
status[i].button |= mapping.padButton; status[i].button |= mapping.padButton;
} }
@@ -946,6 +958,17 @@ u32 PADRead(PADStatus* status) {
Sint16 tl = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L)); Sint16 tl = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L));
Sint16 tr = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R)); Sint16 tr = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R));
// Games can read either the digital L/R bits or their analog pressure.
// An explicit button binding must drive both, otherwise the original
// L2/R2 axis still activates L/R even when it was rebound to L1/R1.
// Real GC pads retain independent analog travel and end-stop clicks.
if (!(controller->m_isGameCube ||
(SDL_GetGamepadType(controller->m_controller) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO &&
controller->m_pid == 0x2073))) {
if (leftTriggerSet) tl = (status[i].button & PAD_TRIGGER_L) != 0 ? 32767 : 0;
if (rightTriggerSet) tr = (status[i].button & PAD_TRIGGER_R) != 0 ? 32767 : 0;
}
if (controller->m_deadZones.emulateTriggers) { if (controller->m_deadZones.emulateTriggers) {
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) { if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
status[i].button |= PAD_TRIGGER_L; status[i].button |= PAD_TRIGGER_L;
@@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) {
} }
if (controller->m_isGameCube) { if (controller->m_isGameCube) {
if (cmd == PAD_MOTOR_STOP) { if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) {
aurora::input::controller_rumble(instance, 0, 1, 0); // 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) { } else if (cmd == PAD_MOTOR_RUMBLE) {
aurora::input::controller_rumble(instance, 1, 1, 0); aurora::input::controller_rumble(instance, 1, 1, 0);
} else if (cmd == PAD_MOTOR_STOP_HARD) {
aurora::input::controller_rumble(instance, 0, 0, 0);
} }
} else { } else {
if (cmd == PAD_MOTOR_STOP) { 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) { 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) { if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) {
return nullptr; return nullptr;
} }
+2 -2
View File
@@ -49,7 +49,7 @@ Due to legal requirements, no proprietary Nintendo assets or code are included i
3. **Retro Rewind Distribution** *(Skip if only building WiiCompiled)*: 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`). - 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)*: 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 ```bash
# 1. Download Retro-WFC payload into a staging directory: # 1. Download Retro-WFC payload into a staging directory:
mkdir -p build/retro-wfc/binary 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 -o build/retro-wfc/binary/payload.RMCPD00.bin
# 2. Run the automated build with the payload directory: # 2. Run the automated build with the payload directory:
+17 -6
View File
@@ -48,8 +48,19 @@ struct NativeButtonItem {
uint32_t nativeButton; uint32_t nativeButton;
}; };
inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{ inline constexpr auto kNativeButtons = std::to_array<NativeButtonItem>({
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID}, {"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}, {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
{"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST}, {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
{"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST}, {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
@@ -76,7 +87,7 @@ inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNat
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4}, {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5}, {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6}, {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
}}; });
inline std::string TrimToken(std::string_view token) { inline std::string TrimToken(std::string_view token) {
const size_t begin = token.find_first_not_of(" \t"); 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) { 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 auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
[&](const NativeButtonItem& item) { return name == item.configName; }); [&](const NativeButtonItem& item) { return name == item.configName; });
return it == kNativeButtons.end() ? nullptr : &*it; 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. // Falls back to the "unmapped" entry so callers always have a label to draw.
inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
[&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; }); [&](const NativeButtonItem& item) { return PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); });
return it == kNativeButtons.end() ? kNativeButtons.front() : *it; return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it;
} }
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) { inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
+6 -1
View File
@@ -94,7 +94,12 @@ double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
// Fall back to this project's own positional names, so a binding written // Fall back to this project's own positional names, so a binding written
// here does not have to use Dolphin vocabulary. // here does not have to use Dolphin vocabulary.
if (const auto* native = ControllerNames::FindNativeButton(name)) { if (const auto* native = ControllerNames::FindNativeButton(name)) {
if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) { if (PADIsAxisButton(native->nativeButton)) {
const auto axis = static_cast<SDL_GamepadAxis>(PADAxisButtonAxis(native->nativeButton));
const double sign = PADAxisButtonNegative(native->nativeButton) ? -1.0 : 1.0;
return std::clamp(SDL_GetGamepadAxis(gamepad, axis) / 32767.0 * sign, 0.0, 1.0);
}
if (native->nativeButton < SDL_GAMEPAD_BUTTON_COUNT) {
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0 return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
: 0.0; : 0.0;
} }
+307 -42
View File
@@ -20,6 +20,7 @@
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cctype> #include <cctype>
#include <charconv>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
@@ -186,6 +187,18 @@ void LimitResolutionForFrameRate() {
using ControllerNames::FindNativeButton; 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 { struct ControllerBindingPair {
std::string primary; std::string primary;
std::string secondary; std::string secondary;
@@ -204,6 +217,13 @@ ControllerBindingPair SplitControllerBinding(const std::string& value) {
using ControllerNames::NativeButtonForValue; 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) { void SetTopBarVisible(bool visible) {
if (g_topBarVisible == visible) { if (g_topBarVisible == visible) {
return; return;
@@ -242,7 +262,7 @@ void ApplyConfiguredMappings() {
} }
const ControllerBindingPair binding = SplitControllerBinding(*configured); const ControllerBindingPair binding = SplitControllerBinding(*configured);
if (const NativeButtonItem* native = FindNativeButton(binding.primary)) { 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 { } else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " button '" << binding.primary << "'" << std::endl; << " button '" << binding.primary << "'" << std::endl;
@@ -250,7 +270,7 @@ void ApplyConfiguredMappings() {
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID; uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
if (!binding.secondary.empty()) { if (!binding.secondary.empty()) {
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) { if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
altNative = native->nativeButton; altNative = ConfiguredNativeButton(*native, binding.secondary);
} else { } else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " secondary button '" << binding.secondary << "'" << std::endl; << " secondary button '" << binding.secondary << "'" << std::endl;
@@ -395,6 +415,219 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
ImGui::EndMenu(); ImGui::EndMenu();
} }
const char* KeyBindingName(int scancode) {
switch (scancode) {
case PAD_KEY_MOUSE_LEFT: return "Mouse left";
case PAD_KEY_MOUSE_RIGHT: return "Mouse right";
case PAD_KEY_MOUSE_MIDDLE: return "Mouse middle";
case PAD_KEY_MOUSE_X1: return "Mouse side 1";
case PAD_KEY_MOUSE_X2: return "Mouse side 2";
case PAD_KEY_INVALID: return "Unmapped";
default:
return scancode >= 0 && scancode < SDL_SCANCODE_COUNT
? SDL_GetScancodeName(static_cast<SDL_Scancode>(scancode)) : "Unknown";
}
}
enum class RebindKind { KeyboardButton, KeyboardAxis, Controller };
struct RebindState {
bool active = false;
bool openPopup = false;
RebindKind kind{};
uint32_t port = 0;
uint16_t target = 0;
bool secondary = false;
SDL_JoystickID instance = 0;
Clock::time_point deadline{};
std::string label;
std::array<bool, SDL_SCANCODE_COUNT> keys{};
uint32_t mouse = 0;
std::array<bool, SDL_GAMEPAD_BUTTON_COUNT> buttons{};
std::array<bool, SDL_GAMEPAD_AXIS_COUNT> axesReady{};
} g_rebind;
void BeginRebind(RebindKind kind, uint16_t target, const char* label, bool secondary = false) {
g_rebind = {};
g_rebind.active = true;
g_rebind.openPopup = true;
g_rebind.kind = kind;
g_rebind.port = static_cast<uint32_t>(g_controllerPort);
g_rebind.target = target;
g_rebind.secondary = secondary;
g_rebind.label = label;
g_rebind.deadline = Clock::now() + std::chrono::seconds(10);
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
std::copy_n(keys, std::min(count, static_cast<int>(g_rebind.keys.size())), g_rebind.keys.begin());
g_rebind.mouse = SDL_GetMouseState(nullptr, nullptr);
const int index = PADGetIndexForPort(g_rebind.port);
if (kind == RebindKind::Controller && index >= 0) {
if (auto* pad = PADGetSDLGamepadForIndex(index)) {
g_rebind.instance = SDL_GetGamepadID(pad);
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT; ++i)
g_rebind.buttons[i] = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i)
g_rebind.axesReady[i] = std::abs(static_cast<int>(SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i)))) < 8000;
}
}
}
void CompleteRebind(uint32_t value) {
const auto& capture = g_rebind;
if (capture.kind == RebindKind::Controller) {
const int index = PADGetIndexForPort(capture.port);
auto* pad = index >= 0 ? PADGetSDLGamepadForIndex(index) : nullptr;
if (pad == nullptr || SDL_GetGamepadID(pad) != capture.instance) {
g_rebind.active = false;
return;
}
if (capture.secondary) PADSetAltButtonMapping(capture.port, {value, capture.target});
else PADSetButtonMapping(capture.port, {value, capture.target});
uint32_t count = 0, altCount = 0;
auto* primary = PADGetButtonMappings(capture.port, &count);
auto* alternate = PADGetAltButtonMappings(capture.port, &altCount);
uint32_t primaryValue = PAD_NATIVE_BUTTON_INVALID, alternateValue = PAD_NATIVE_BUTTON_INVALID;
for (uint32_t i = 0; i < count; ++i)
if (primary[i].padButton == capture.target) primaryValue = primary[i].nativeButton;
for (uint32_t i = 0; i < altCount; ++i)
if (alternate[i].padButton == capture.target) alternateValue = alternate[i].nativeButton;
std::string config = NativeBindingConfig(primaryValue);
if (alternateValue != PAD_NATIVE_BUTTON_INVALID) config += ',' + NativeBindingConfig(alternateValue);
for (size_t i = 0; i < kControllerButtons.size(); ++i)
if (kControllerButtons[i].padButton == capture.target) RuntimeConfigFile::SetControllerButton(i, config);
} else if (capture.kind == RebindKind::KeyboardButton) {
PADSetKeyButtonBinding(capture.port, {static_cast<int32_t>(value), capture.target});
} else {
PADSetKeyAxisBinding(capture.port, {static_cast<int32_t>(value), capture.target, 1});
}
PADSerializeMappings();
g_rebind.active = false;
}
void DrawRebindPrompt() {
if (g_rebind.openPopup) {
ImGui::OpenPopup("Rebind input");
g_rebind.openPopup = false;
}
if (!ImGui::BeginPopupModal("Rebind input", &g_rebind.active, ImGuiWindowFlags_AlwaysAutoResize)) {
g_rebind.active = false;
return;
}
if (g_rebind.active) {
ImGui::Text("Rebind: %s", g_rebind.label.c_str());
ImGui::TextUnformatted(g_rebind.kind == RebindKind::Controller
? "Press a controller button, pull a trigger, or move a stick."
: "Press a keyboard key or click a mouse button.");
ImGui::TextUnformatted("Release any held input first. Backspace or Delete clears the mapping.");
ImGui::TextUnformatted("Escape can be bound. F10 is reserved for settings.");
const float remaining = std::chrono::duration<float>(g_rebind.deadline - Clock::now()).count();
ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast<int>(std::ceil(remaining))));
const bool clear = ImGui::Button("Clear mapping");
ImGui::SameLine();
if (ImGui::Button("Cancel")) g_rebind.active = false;
// UI clicks must not become mouse bindings (buttons activate on release).
const bool overControl = ImGui::IsAnyItemHovered();
if (g_rebind.active && (clear || remaining <= 0.0f)) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind != RebindKind::Controller) {
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
for (int i = 1; i < std::min(count, static_cast<int>(SDL_SCANCODE_COUNT)) && g_rebind.active; ++i) {
if (keys[i] && !g_rebind.keys[i] && i != SDL_SCANCODE_F10) CompleteRebind(i);
g_rebind.keys[i] = keys[i];
}
const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr);
for (int i = 1; i <= 5 && g_rebind.active; ++i)
if (!overControl && (mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast<uint32_t>(-i - 1));
g_rebind.mouse = mouse;
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind == RebindKind::Controller) {
auto* pad = SDL_GetGamepadFromID(g_rebind.instance);
if (pad != nullptr) {
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT && g_rebind.active; ++i) {
const bool pressed = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
if (pressed && !g_rebind.buttons[i]) CompleteRebind(i);
g_rebind.buttons[i] = pressed;
}
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT && g_rebind.active; ++i) {
const int value = SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i));
if (std::abs(value) < 8000) g_rebind.axesReady[i] = true;
if (g_rebind.axesReady[i] && std::abs(value) >= 16384)
CompleteRebind(PADEncodeAxisButton(i, value < 0));
}
}
}
}
if (!g_rebind.active) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target) {
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<int, PAD_BUTTON_COUNT> keys = {
PAD_KEY_MOUSE_LEFT, SDL_SCANCODE_SPACE, SDL_SCANCODE_E, SDL_SCANCODE_Q,
SDL_SCANCODE_RETURN, PAD_KEY_MOUSE_MIDDLE, SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT,
};
for (size_t i = 0; i < keys.size(); ++i)
PADSetKeyButtonBinding(port, {keys[i], kControllerButtons[i].padButton});
const std::array<int, PAD_AXIS_COUNT> axes = {
SDL_SCANCODE_D, SDL_SCANCODE_A, SDL_SCANCODE_W, SDL_SCANCODE_S,
SDL_SCANCODE_L, SDL_SCANCODE_J, SDL_SCANCODE_I, SDL_SCANCODE_K,
SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
};
uint32_t axisCount = 0;
auto* mappings = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i)
PADSetKeyAxisBinding(port, {axes[i], mappings[i].padAxis, 1});
PADSerializeMappings();
}
ImGui::SeparatorText("Button mapping");
for (uint32_t i = 0; i < count; ++i) {
int key = buttons[i].scancode;
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(PADGetButtonName(buttons[i].padButton), key, RebindKind::KeyboardButton, buttons[i].padButton);
ImGui::PopID();
}
ImGui::SeparatorText("Stick and trigger mapping");
uint32_t axisCount = 0;
auto* axes = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i) {
int key = axes[i].scancode;
ImGui::PushID(static_cast<int>(count + i));
const char* direction = PADGetAxisDirectionLabel(axes[i].padAxis);
const std::string label = std::string(PADGetAxisName(axes[i].padAxis)) + " " +
(direction != nullptr ? direction : "");
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(label.c_str(), key, RebindKind::KeyboardAxis, axes[i].padAxis);
ImGui::PopID();
}
return true;
}
// Controller settings menu: port selection, controller assignment and button mapping. // Controller settings menu: port selection, controller assignment and button mapping.
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) { int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
@@ -501,6 +734,10 @@ void DrawControllerSettings() {
ImGui::Separator(); ImGui::Separator();
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort); const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
if (DrawKeyboardSettings(selectedGamePort)) {
return;
}
ImGui::Separator();
const char* currentName = PADGetName(selectedGamePort); const char* currentName = PADGetName(selectedGamePort);
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None"); ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
if (ImGui::MenuItem("Unassign controller")) { if (ImGui::MenuItem("Unassign controller")) {
@@ -542,10 +779,10 @@ void DrawControllerSettings() {
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount); PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) { 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) { if (altNative != PAD_NATIVE_BUTTON_INVALID) {
value += ','; value += ',';
value += NativeButtonForValue(altNative).configName; value += NativeBindingConfig(altNative);
} }
RuntimeConfigFile::SetControllerButton(index, value); RuntimeConfigFile::SetControllerButton(index, value);
}; };
@@ -603,6 +840,11 @@ void DrawControllerSettings() {
} }
ImGui::SeparatorText("Button mapping"); 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) { for (size_t i = 0; i < kControllerButtons.size(); ++i) {
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) { auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
return mapping.padButton == kControllerButtons[i].padButton; return mapping.padButton == kControllerButtons[i].padButton;
@@ -622,30 +864,40 @@ void DrawControllerSettings() {
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton); const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(190.0f); const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
if (ImGui::BeginCombo("##primary", current.label)) { if (!PADIsAxisButton(mapping->nativeButton)) return;
for (const auto& candidate : kNativeButtons) { int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
const bool selected = candidate.nativeButton == mappingIt->nativeButton; ImGui::SetNextItemWidth(bindingWidth);
if (ImGui::Selectable(candidate.label, selected)) { if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
const uint32_t port = static_cast<uint32_t>(g_controllerPort); 1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton}); const PADButtonMapping updated = {
writeBinding(i, candidate.nativeButton, PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID); mapping->padButton,
PADSerializeMappings(); };
mappings = PADGetButtonMappings(port, &mappingCount); if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
} else PADSetButtonMapping(selectedGamePort, updated);
if (selected) {
ImGui::SetItemDefaultFocus();
}
} }
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) { if (altIt != nullptr) {
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID; const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
if (!altBound && !altRowExpanded[i]) { if (!altBound && !altRowExpanded[i]) {
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::SmallButton("+")) { if (ImGui::SmallButton("+")) {
altRowExpanded[i] = true; altRowExpanded[i] = true;
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
} }
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Add a second binding; pressing either one works"); ImGui::SetTooltip("Add a second binding; pressing either one works");
@@ -654,27 +906,15 @@ void DrawControllerSettings() {
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextUnformatted("or"); ImGui::TextUnformatted("or");
ImGui::SameLine(); ImGui::SameLine();
ImGui::BeginGroup();
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None"; const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
ImGui::SetNextItemWidth(190.0f); ImGui::SetNextItemWidth(bindingWidth);
if (ImGui::BeginCombo("##alt", altLabel)) { const std::string altCaption = std::string(altLabel) + "##alt";
for (const auto& candidate : kNativeButtons) { if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID; BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
const bool selected = candidate.nativeButton == altIt->nativeButton;
if (ImGui::Selectable(isNone ? "None" : candidate.label, selected)) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
PADSetAltButtonMapping(
port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
writeBinding(i, mappingIt->nativeButton, candidate.nativeButton);
if (isNone) {
altRowExpanded[i] = false;
}
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
} }
drawThreshold(altIt, true);
ImGui::EndGroup();
} }
} }
ImGui::SameLine(); ImGui::SameLine();
@@ -936,10 +1176,27 @@ void DrawStartupScreen() {
} }
void DrawTopBar() { void DrawTopBar() {
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) { if (!g_topBarVisible) {
return; 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::TextUnformatted("WiiCompiled");
ImGui::Separator(); ImGui::Separator();
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) { const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
@@ -968,6 +1225,9 @@ void DrawTopBar() {
if (ImGui::BeginMenu("Controller settings")) { if (ImGui::BeginMenu("Controller settings")) {
DrawControllerSettings(); 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(); ImGui::EndMenu();
} }
@@ -1074,7 +1334,12 @@ void HandleEvents(const AuroraEvent* events) noexcept {
continue; continue;
} }
controller_mapping_wizard::HandleSdlEvent(ev->sdl); controller_mapping_wizard::HandleSdlEvent(ev->sdl);
if (IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) { if (g_rebind.active && (IsToggleKey(ev->sdl, SDL_SCANCODE_BACKSPACE) ||
IsToggleKey(ev->sdl, SDL_SCANCODE_DELETE))) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
}
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
SetTopBarVisible(!g_topBarVisible); SetTopBarVisible(!g_topBarVisible);
} }
if (IsMouseActivity(ev->sdl)) { if (IsMouseActivity(ev->sdl)) {
@@ -1102,7 +1367,7 @@ void Draw() noexcept {
DrawTopBar(); DrawTopBar();
controller_mapping_wizard::Draw(); controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game. // 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); PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked); InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen(); DrawStartupScreen();
+24 -126
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
@@ -1891,6 +1891,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
overlayBuild, overlayBuild,
continuationPlan, continuationPlan,
retroWfcResolvedExecutableHooks, retroWfcResolvedExecutableHooks,
patchPlan,
kamekFunctionStarts, kamekFunctionStarts,
moduleLinkBase, moduleLinkBase,
selected.CodeSize, selected.CodeSize,
@@ -2231,6 +2232,7 @@ int EmitModCpp(
OverlayBuildResult overlayBuild, OverlayBuildResult overlayBuild,
ContinuationPlan continuationPlan, ContinuationPlan continuationPlan,
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks, IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
KamekPatchPlan patchPlan,
IReadOnlyList<ModFunctionStart> kamekFunctionStarts, IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
uint moduleLinkBase, uint moduleLinkBase,
uint moduleLinkedCodeSize, uint moduleLinkedCodeSize,
@@ -2272,14 +2274,25 @@ int EmitModCpp(
.ToHashSet(); .ToHashSet();
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet(); var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
var discoveredContinuationQueue = new Queue<ContinuationEntry>(); var discoveredContinuationQueue = new Queue<ContinuationEntry>();
var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null var hookLrBases = new List<(uint TargetAddress, uint ContinuationAddress)>();
? new Dictionary<uint, uint[]>() if (retroWfcExecutableHooks is not null)
: retroWfcExecutableHooks {
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h)) hookLrBases.AddRange(
.GroupBy(h => h.TargetAddress!.Value) retroWfcExecutableHooks
.ToDictionary( .Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
g => g.Key, .Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray()); }
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 lrContinuationCallTargets = linkedHookLrBasesByTarget.Keys.ToHashSet();
var linkedCallFallthroughLrOverrides = retroWfcExecutableHooks is null var linkedCallFallthroughLrOverrides = retroWfcExecutableHooks is null
? new Dictionary<uint, uint>() ? new Dictionary<uint, uint>()
@@ -2750,123 +2763,8 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
} }
} }
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
{ ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
var lrOffsets = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int? ctrOffset = null;
foreach (var instruction in result.Instructions)
{
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
{
lrOffsets[lrDest] = 0;
continue;
}
if ((mnemonic == "mr" || mnemonic == "or") &&
TryGetInstructionReg(instruction, 0, out var moveDest) &&
TryGetInstructionReg(instruction, 1, out var moveSource) &&
(mnemonic == "mr" ||
(instruction.Operands.Count >= 3 &&
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
{
if (lrOffsets.TryGetValue(moveSource, out var sourceOffset))
{
lrOffsets[moveDest] = sourceOffset;
}
else
{
lrOffsets.Remove(moveDest);
}
continue;
}
if (mnemonic == "addi" &&
TryGetInstructionReg(instruction, 0, out var addDest) &&
TryGetInstructionReg(instruction, 1, out var addBase) &&
TryGetInstructionImm(instruction, 2, out var imm))
{
if (lrOffsets.TryGetValue(addBase, out var baseOffset))
{
lrOffsets[addDest] = checked(baseOffset + imm);
}
else
{
lrOffsets.Remove(addDest);
}
continue;
}
if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
{
ctrOffset = lrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : null;
continue;
}
if (mnemonic == "bctr")
{
if (ctrOffset.HasValue)
{
yield return ctrOffset.Value;
}
ctrOffset = null;
continue;
}
if (TryInstructionWritesDest(instruction, out var dest))
{
lrOffsets.Remove(dest);
}
}
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
{
register = NormalizeInstructionReg(operand.Name);
return true;
}
register = string.Empty;
return false;
}
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
{
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
{
immediate = operand.Value;
return true;
}
immediate = 0;
return false;
}
static bool TryInstructionWritesDest(PpcInstruction instruction, out string destination)
{
destination = string.Empty;
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
{
return false;
}
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
{
return false;
}
destination = NormalizeInstructionReg(operand.Name);
return true;
}
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
}
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) => static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
hook.TypeName is "call" or "branchCtrLink" || hook.TypeName is "call" or "branchCtrLink" ||
@@ -1,4 +1,5 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Text.Json; using System.Text.Json;
using Translator.Core.Disassembly; using Translator.Core.Disassembly;
using Translator.Core.Parsing.Kamek; using Translator.Core.Parsing.Kamek;
@@ -273,4 +274,530 @@ public static class ContinuationPlanner
Or, Or,
AddSigned AddSigned
} }
public static IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(IReadOnlyList<PpcInstruction> instructions)
{
if (instructions.Count == 0)
{
yield break;
}
var indexByAddress = new Dictionary<uint, int>(instructions.Count);
for (var i = 0; i < instructions.Count; i++)
{
indexByAddress.TryAdd(instructions[i].Address, i);
}
var visited = new HashSet<PathState>[instructions.Count];
for (var i = 0; i < instructions.Count; i++)
{
visited[i] = new HashSet<PathState>();
}
var seenOffsets = new HashSet<int>();
var worklist = new Queue<(int Index, PathState State)>();
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<string> destinations)
{
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
{
destinations = Array.Empty<string>();
return false;
}
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
{
destinations = Array.Empty<string>();
return false;
}
if (mnemonic == "lmw")
{
var startReg = Math.Clamp(operand.Number, 0, 31);
var regs = new string[32 - startReg];
for (var r = startReg; r <= 31; r++)
{
regs[r - startReg] = $"r{r}";
}
destinations = regs;
return true;
}
destinations = [NormalizeInstructionReg(operand.Name)];
return true;
}
static bool TryGetStackStoreRange(PpcInstruction instruction, out int offset, out int size, out bool updatesStackPointer)
{
offset = 0;
size = 0;
updatesStackPointer = false;
if (!TryGetInstructionDisplacement(instruction, 1, out offset, out var baseRegister, out _) ||
baseRegister != "r1")
{
return false;
}
switch (instruction.Mnemonic.ToLowerInvariant())
{
case "stfs":
size = 4;
return true;
case "stfsu":
size = 4;
updatesStackPointer = true;
return true;
case "stfd":
size = 8;
return true;
case "stfdu":
size = 8;
updatesStackPointer = true;
return true;
case "stmw" when instruction.Operands[0] is PpcRegisterOperand register:
size = checked((32 - Math.Clamp(register.Number, 0, 31)) * 4);
return true;
default:
return false;
}
}
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
}
private sealed class PathState : IEquatable<PathState>
{
public ImmutableDictionary<string, int> LrOffsets { get; }
public int? CtrOffset { get; }
public int? LrReturnOffset { get; }
public int SpDelta { get; }
public ImmutableDictionary<int, int> StackOffsets { get; }
public PathState(
ImmutableDictionary<string, int> lrOffsets,
int? ctrOffset,
int? lrReturnOffset,
int spDelta,
ImmutableDictionary<int, int> stackOffsets)
{
LrOffsets = lrOffsets;
CtrOffset = ctrOffset;
LrReturnOffset = lrReturnOffset;
SpDelta = spDelta;
StackOffsets = stackOffsets;
}
public static readonly PathState Empty = new(
ImmutableDictionary<string, int>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
null,
0,
0,
ImmutableDictionary<int, int>.Empty);
public PathState WithLrOffset(string register, int offset) =>
LrOffsets.TryGetValue(register, out var cur) && cur == offset
? this
: new(LrOffsets.SetItem(register, offset), CtrOffset, LrReturnOffset, SpDelta, StackOffsets);
public PathState WithoutLrOffset(string register) =>
LrOffsets.ContainsKey(register)
? new(LrOffsets.Remove(register), CtrOffset, LrReturnOffset, SpDelta, StackOffsets)
: this;
public PathState WithCtrOffset(int? ctrOffset) =>
ctrOffset == CtrOffset
? this
: new(LrOffsets, ctrOffset, LrReturnOffset, SpDelta, StackOffsets);
public PathState WithLrReturnOffset(int? lrReturnOffset) =>
lrReturnOffset == LrReturnOffset
? this
: new(LrOffsets, CtrOffset, lrReturnOffset, SpDelta, StackOffsets);
public PathState WithSpDelta(int spDelta) =>
spDelta == SpDelta
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, spDelta, StackOffsets);
public PathState WithAdjustedStackPointer(int displacement)
{
// r1 can hold an LR-relative address too. Update both relations;
// guest address arithmetic wraps at 32 bits.
var updated = WithSpDelta(unchecked(SpDelta + displacement));
return LrOffsets.TryGetValue("r1", out var offset)
? updated.WithLrOffset("r1", unchecked(offset + displacement))
: updated;
}
public PathState WithStackOffset(int slot, int offset) =>
StackOffsets.TryGetValue(slot, out var cur) && cur == offset
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.SetItem(slot, offset));
public PathState WithoutStackOffset(int slot) =>
StackOffsets.ContainsKey(slot)
? new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, StackOffsets.Remove(slot))
: this;
public PathState WithoutStackOffsetsInRange(int start, int size)
{
var end = checked(start + size);
var remaining = StackOffsets;
foreach (var slot in StackOffsets.Keys)
{
if (slot < end && start < checked(slot + 4))
{
remaining = remaining.Remove(slot);
}
}
return remaining.Count == StackOffsets.Count
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, remaining);
}
public PathState WithClearedStackOffsets() =>
StackOffsets.IsEmpty
? this
: new(LrOffsets, CtrOffset, LrReturnOffset, SpDelta, ImmutableDictionary<int, int>.Empty);
public bool Equals(PathState? other)
{
if (ReferenceEquals(this, other)) return true;
if (other is null) return false;
if (CtrOffset != other.CtrOffset || LrReturnOffset != other.LrReturnOffset || SpDelta != other.SpDelta) return false;
if (LrOffsets.Count != other.LrOffsets.Count || StackOffsets.Count != other.StackOffsets.Count) return false;
foreach (var (k, v) in LrOffsets)
{
if (!other.LrOffsets.TryGetValue(k, out var otherV) || v != otherV)
{
return false;
}
}
foreach (var (k, v) in StackOffsets)
{
if (!other.StackOffsets.TryGetValue(k, out var otherV) || v != otherV)
{
return false;
}
}
return true;
}
public override bool Equals(object? obj) => obj is PathState other && Equals(other);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(CtrOffset);
hash.Add(LrReturnOffset);
hash.Add(SpDelta);
hash.Add(LrOffsets.Count);
var regHash = 0;
foreach (var (k, v) in LrOffsets)
{
regHash ^= HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(k), v);
}
hash.Add(regHash);
hash.Add(StackOffsets.Count);
var stackHash = 0;
foreach (var (k, v) in StackOffsets)
{
stackHash ^= HashCode.Combine(k, v);
}
hash.Add(stackHash);
return hash.ToHashCode();
}
}
} }
@@ -1,4 +1,6 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Linq;
using Translator.Core.Disassembly;
using Translator.Core.Mods; using Translator.Core.Mods;
using Translator.Core.Mods.Mkwii; using Translator.Core.Mods.Mkwii;
using Translator.Core.Parsing.Kamek; using Translator.Core.Parsing.Kamek;
@@ -128,6 +130,54 @@ public class ContinuationPlannerTests
Assert.Contains("Retro WFC executable hook continuation", entry.Reason); 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() => private static KamekChunk EmptyChunk() =>
new( new(
0, 0,
@@ -0,0 +1,63 @@
using Translator.Core.Analysis.Ssa;
using Translator.Core.Analysis.Representation;
using Translator.Core.CodeGen;
using Translator.Core.Ir;
using Translator.Core.Representation;
using Xunit;
namespace Translator.Tests;
// This binary-free code-generation regression must run in the default suite.
public class LrContinuationCodeGenTests
{
[Fact]
public void CodeGenerator_DispatchesGuestCallLrContinuationWithoutMarkingTargetNonReturning()
{
var function = new IrFunction(
"lr_continuation_call",
"0x800E591C",
new[]
{
new IrBasicBlock("0x800E591C", new IrInstruction[]
{
new IrAssign("lr", IrValue.Imm(unchecked((int)0x800E5920u))),
new IrCall(string.Empty, "0x8179AC3C", Array.Empty<IrValue>()),
new IrAssign("r3", IrValue.Imm(8)),
new IrReturn(null)
}),
new IrBasicBlock("0x800E5934", new IrInstruction[]
{
new IrAssign("r3", IrValue.Imm(1)),
new IrReturn(null)
})
});
var types = new RepresentationEnvironment(new Dictionary<string, ValueRepresentation>
{
["lr"] = ValueRepresentation.UInt32,
["r3"] = ValueRepresentation.UInt32
});
var signature = new FunctionAbiClassification("lr_continuation_call", ValueRepresentation.Void);
var ssa = new SsaTransformer().Convert(function);
var code = new CxxLinearCodeGenerator().Emit(
0x800E591C,
ssa,
signature,
types,
lrContinuationCallTargets: new HashSet<uint> { 0x8179AC3Cu });
var callIndex = code.IndexOf("InvokeDirectCpu<0x8179AC3Cu>(ctx);", StringComparison.Ordinal);
var fallthroughGuardIndex = code.IndexOf("if (ctx->lr != 0x800E5920u)", callIndex, StringComparison.Ordinal);
var localCaseIndex = code.IndexOf("case 0x800E5934u:", fallthroughGuardIndex, StringComparison.Ordinal);
var returnIndex = code.IndexOf("return;", localCaseIndex, StringComparison.Ordinal);
var fallthroughAssignmentIndex = code.IndexOf("r3 = 8;", callIndex, StringComparison.Ordinal);
Assert.True(callIndex >= 0);
Assert.True(fallthroughGuardIndex > callIndex);
Assert.True(localCaseIndex > fallthroughGuardIndex);
Assert.True(returnIndex > localCaseIndex);
Assert.True(fallthroughAssignmentIndex > returnIndex, code);
Assert.Contains("goto loc_800E5934;", code);
}
}
@@ -0,0 +1,448 @@
using System.Buffers.Binary;
using Translator.Core.Disassembly;
using Translator.Core.Loading;
using Translator.Core.Mods;
using Xunit;
namespace Translator.Tests;
public class LrRelativeContinuationTests
{
[Theory]
[InlineData(20, 40)]
[InlineData(40, 20)]
public void MutuallyExclusiveAdjustmentsKeepBothOffsets(int firstOffset, int secondOffset)
{
// Both arms start with the incoming LR and join at mtlr. Adding the
// offsets together invents a continuation that neither arm can reach.
var offsets = DiscoverOffsets(
0x7FE802A6u, // +00: mflr r31
0x2C030000u, // +04: cmpwi r3,0
0x4182000Cu, // +08: beq +0x14
AddiR31(firstOffset), // +0C: addi r31,r31,firstOffset
0x48000008u, // +10: b +0x18
AddiR31(secondOffset), // +14: addi r31,r31,secondOffset
0x7FE803A6u, // +18: mtlr r31
0x4E800020u);// +1C: blr
Assert.Equal(new[] { 20, 40 }, offsets);
}
[Fact]
public void NormalReturnArmDoesNotEraseSkipReturnAtSharedBlr()
{
// The normal arm writes the original LR; it must not overwrite the
// other arm's LR + 20 in the analysis of the shared return.
var offsets = DiscoverOffsets(
0x7FE802A6u, // +00: mflr r31
0x2C030000u, // +04: cmpwi r3,0
0x41820010u, // +08: beq +0x18
0x397F0014u, // +0C: addi r11,r31,20
0x7D6803A6u, // +10: mtlr r11
0x48000008u, // +14: b +0x1C
0x7FE803A6u, // +18: mtlr r31
0x4E800020u);// +1C: blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void ConditionalNormalReturnStillDiscoversSkipOnFallthrough()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x2C030000u, // cmpwi r3,0
0x4D820020u, // beqlr
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void SavedNonvolatileLrSurvivesHelperCall()
{
// r31 survives a normal ABI call even though the call replaces LR.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x48000101u, // bl helper outside this function
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void ReloadingSavedRegisterAfterMtlrDoesNotEraseSkipReturn()
{
// A hook epilogue restores the caller's r31 after committing its
// adjusted return address to LR.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x83E10008u, // lwz r31,8(r1)
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void UnknownLrWriteReplacesEarlierSkipReturn()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void UnadjustedRegisterReturnDoesNotAddAContinuation()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void LoadMultipleWordOverwritesSavedRegistersThroughR31()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0xBB610008u, // lmw r30,8(r1)
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void BlrlCallIsNotTreatedAsReturn()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x4E800021u, // blrl
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Equal(new[] { 20 }, offsets);
}
[Fact]
public void MflrAfterCallDoesNotTreatClobberedLrAsIncomingLr()
{
var offsets = DiscoverOffsets(
0x48000101u, // bl helper
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE803A6u, // mtlr r31
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void RestoredIncomingLrBeforeCtrSkipStillDiscoversOffset()
{
// The helper replaces LR, but the stack save/restore recovers the
// incoming LR before the hook jumps to the caller's continuation.
var offsets = DiscoverOffsets(
0x7C0802A6u, // mflr r0
0x90010004u, // stw r0,4(r1)
0x9421FFF0u, // stwu r1,-16(r1)
0x48000101u, // bl helper outside this function
0x38210010u, // addi r1,r1,16
0x80010004u, // lwz r0,4(r1)
0x7C0803A6u, // mtlr r0
0x7D6802A6u, // mflr r11
0x396B0008u, // addi r11,r11,8
0x7D6903A6u, // mtctr r11
0x4E800420u);// bctr
Assert.Equal(new[] { 8 }, offsets);
}
[Fact]
public void BoundedLoopBeforeCtrSkipStillDiscoversOffset()
{
// Updating an LR-derived register in a two-iteration loop must not
// starve analysis of the exit, whose target uses unchanged r31.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x7FC802A6u, // mflr r30
0x38600002u, // li r3,2
0x7C6903A6u, // mtctr r3
0x3BDE0004u, // addi r30,r30,4
0x4200FFFCu, // bdnz -4
0x397F0008u, // addi r11,r31,8
0x7D6903A6u, // mtctr r11
0x4E800420u);// bctr
Assert.Equal(new[] { 8 }, offsets);
}
[Fact]
public void UntrackedR1WriteInvalidatesStackTracking()
{
// If r1 is overwritten from an untracked source, previously saved stack slots
// must not be used to recover LR state.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x80230000u, // lwz r1,0(r3)
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void LargeStraightLineHandlerStillDiscoversSkipReturn()
{
// The analyzer's global step budget is spent one step per (instruction,
// state) pair, so a long enough handler exhausts it before reaching the
// return and silently reports no continuation at all. Main's linear
// scanner had no budget and always found the offset.
var words = new List<uint>
{
0x7FE802A6u, // mflr r31
0x3BFF0014u // addi r31,r31,20
};
for (var i = 0; i < 10_010; i++)
{
words.Add(0x60000000u); // nop
}
words.Add(0x7FE803A6u); // mtlr r31
words.Add(0x4E800020u); // blr
Assert.Equal(new[] { 20 }, DiscoverOffsets(words.ToArray()));
}
[Fact]
public void LargeBranchingHandlerStillDiscoversCtrSkip()
{
// Same budget, reached far sooner once the handler branches: this is the
// bctr shape the pre-PR scanner discovered at any function size.
var words = new List<uint> { 0x7FE802A6u }; // mflr r31
for (var i = 0; i < 160; i++)
{
var displacement = (uint)((i + 1) * 4 & 0xFFFF);
words.Add(0x2C030000u); // cmpwi r3,0
words.Add(0x4182000Cu); // beq +0xC
words.Add(0x3BDF0000u | displacement); // addi r30,r31,disp
words.Add(0x48000008u); // b +8
words.Add(0x3BBF0000u | displacement); // addi r29,r31,disp
}
words.Add(0x397F0008u); // addi r11,r31,8
words.Add(0x7D6903A6u); // mtctr r11
words.Add(0x4E800420u); // bctr
Assert.Equal(new[] { 8 }, DiscoverOffsets(words.ToArray()));
}
[Fact]
public void FloatStoreOverSavedSlotInvalidatesStackTracking()
{
// stfd writes 0x10..0x17, which covers the slot the adjusted LR was
// saved to. Only stw/stwu invalidate slots today, so the reload is
// credited with a return address the stack no longer holds.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10014u, // stw r31,0x14(r1)
0xD8410010u, // stfd f2,0x10(r1)
0x80010014u, // lwz r0,0x14(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void StoreMultipleOverSavedSlotInvalidatesStackTracking()
{
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0xBFC10008u, // stmw r30,8(r1)
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void StackPointerUpdatePreservesAdjustedLrOffset()
{
var offsets = DiscoverOffsets(
0x7C2802A6u, // mflr r1
0xDC410004u, // stfdu f2,4(r1)
0x7C2803A6u, // mtlr r1
0x4E800020u);// blr
Assert.Equal(new[] { 4 }, offsets);
}
[Fact]
public void VolatileRegisterDoesNotSurviveHelperCall()
{
// r3 is caller-saved, so the callee is free to destroy the adjusted
// return address this hook staged before the call.
var offsets = DiscoverOffsets(
0x7C6802A6u, // mflr r3
0x38630014u, // addi r3,r3,20
0x48000101u, // bl helper outside this function
0x7C6803A6u, // mtlr r3
0x4E800020u);// blr
Assert.Empty(offsets);
}
[Fact]
public void CtrDoesNotSurviveHelperCall()
{
// CTR is volatile across a call for the same reason.
var offsets = DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x7FE903A6u, // mtctr r31
0x48000101u, // bl helper outside this function
0x4E800420u);// bctr
Assert.Empty(offsets);
}
[Fact]
public void MflrR1InvalidatesOldStackSlots()
{
// After mflr r1, 8(r1) refers to incoming LR + 8, not the old
// stack slot. Its contents are unknown; do not invent a +20 return.
Assert.Empty(DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x7C2802A6u, // mflr r1
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u)); // blr
}
[Fact]
public void AddiR1UpdatesLrRelativeOffset()
{
// Like StackPointerUpdatePreservesAdjustedLrOffset, r1 holds incoming
// LR here. Updating r1 must update that relation as well as stack state.
Assert.Equal(new[] { 4 }, DiscoverOffsets(
0x7C2802A6u, // mflr r1
0x38210004u, // addi r1,r1,4
0x7C2803A6u, // mtlr r1
0x4E800020u)); // blr
}
[Theory]
[InlineData(0x38210004u, 4)] // addi r1,r1,4
[InlineData(0x30210004u, 4)] // addic r1,r1,4
[InlineData(0x94210004u, 4)] // stwu r1,4(r1)
[InlineData(0xD4410004u, 4)] // stfsu f2,4(r1)
[InlineData(0xDC410004u, 4)] // stfdu f2,4(r1)
[InlineData(0x3821FFFCu, -4)] // addi r1,r1,-4
public void StackPointerUpdatesPreserveLrRelation(uint update, int expectedOffset)
{
Assert.Equal(new[] { expectedOffset }, DiscoverOffsets(
0x7C2802A6u, // mflr r1
update,
0x7C2803A6u, // mtlr r1
0x4E800020u)); // blr
}
[Theory]
[InlineData(0x7FE1FB78u)] // mr r1,r31
[InlineData(0x383F0000u)] // addi r1,r31,0
public void CopyingLrIntoR1PreservesReturnButInvalidatesOldStack(uint copy)
{
var prefix = new uint[]
{
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
copy,
};
Assert.Equal(new[] { 20 }, DiscoverOffsets(
prefix.Concat(new uint[] { 0x7C2803A6u, 0x4E800020u }).ToArray()));
Assert.Empty(DiscoverOffsets(prefix.Concat(new uint[]
{
0x80010008u, // lwz r0,8(r1): no longer the old stack slot
0x7C0803A6u, // mtlr r0
0x4E800020u,
}).ToArray()));
}
[Fact]
public void StackPointerSelfMovePreservesSavedLr()
{
Assert.Equal(new[] { 20 }, DiscoverOffsets(
0x7FE802A6u, // mflr r31
0x3BFF0014u, // addi r31,r31,20
0x93E10008u, // stw r31,8(r1)
0x7C210B78u, // mr r1,r1
0x80010008u, // lwz r0,8(r1)
0x7C0803A6u, // mtlr r0
0x4E800020u));
}
[Fact]
public void IncompleteInstructionListDoesNotInventFallthroughAcrossGap()
{
// Defensive incomplete-input test, not a production disassembly trace:
// the missing instruction could overwrite r31 or branch elsewhere.
// Address sorting alone does not establish a fallthrough edge.
var instructions = new[]
{
PpcDecoder.Decode(0x81800000u, 0x7FE802A6u), // mflr r31
PpcDecoder.Decode(0x81800008u, 0x3BFF0014u), // addi r31,r31,20
PpcDecoder.Decode(0x8180000Cu, 0x7FE803A6u), // mtlr r31
PpcDecoder.Decode(0x81800010u, 0x4E800020u), // blr
};
Assert.Empty(ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions));
}
private static uint AddiR31(int offset) => 0x3BFF0000u | (uint)(offset & 0xFFFF);
private static int[] DiscoverOffsets(params uint[] words)
{
const uint entry = 0x81800000u;
var memory = new byte[words.Length * 4];
for (var i = 0; i < words.Length; i++)
{
BinaryPrimitives.WriteUInt32BigEndian(memory.AsSpan(i * 4, 4), words[i]);
}
var range = AddressRange.FromStartAndSize(entry, (uint)memory.Length);
var image = new ProgramImage(memory, range, range, default, "lr-continuation-test", entry);
using var disassembler = new PpcDisassembler();
// Use the production reachable-instruction traversal and ordering,
// rather than handing the planner an artificial execution trace.
var instructions = disassembler.DisassembleFunction(
image, entry, maxInstructions: words.Length + 1, maxBytes: memory.Length);
return ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions)
.Distinct().OrderBy(offset => offset).ToArray();
}
}