mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-12 01:38:50 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 149cfef608 | |||
| 25c69ae28e | |||
| 0bb15f0a44 | |||
| 466d06d7db | |||
| 8769cf6dea | |||
| 452b478bb3 | |||
| 407f8a7190 | |||
| c2289e4ba4 |
@@ -3,3 +3,4 @@
|
||||
|
||||
# Patch files must stay LF: git apply matches context bytes against LF upstream sources
|
||||
*.patch -text
|
||||
translator/tests/Translator.Tests/TestAssets/**/*.bin binary
|
||||
|
||||
@@ -46,22 +46,3 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
|
||||
|
||||
cpp:
|
||||
name: C++ runtime tests (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest, macos-14]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Configure
|
||||
run: cmake -S runtime/tests -B build/tests -DCMAKE_BUILD_TYPE=Release
|
||||
- name: Build
|
||||
run: cmake --build build/tests --config Release --parallel
|
||||
- name: Test
|
||||
run: ctest --test-dir build/tests -C Release --output-on-failure --no-tests=error
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fails the release build when a fact duplicated across the repo stops agreeing with the copy
|
||||
# that owns it (recomp.yml). Scripts read pinned facts through Get-MkwProjectPins, but three
|
||||
# consumers can't read YAML (the C++ runtime header, the C# constants, hand-written lists on
|
||||
# consumers can't read YAML (the C++ runtime header, the C# constants, shell scripts, and hand-written lists on
|
||||
# both sides of the C#/PowerShell boundary), so those are checked here instead.
|
||||
[CmdletBinding()]
|
||||
param([string]$RepositoryRoot)
|
||||
@@ -58,6 +58,12 @@ $hostUri = Get-CapturedValue $retroWfcPayload 'CurrentRetroWfcPayloadUri\s*=\s*"
|
||||
if ($hostUri -cne $pins.RetroWfcPayloadUri) {
|
||||
Add-Failure "InputValidation.CurrentRetroWfcPayloadUri is '$hostUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
|
||||
}
|
||||
$macosSetup = Read-SourceFile (Join-Path $launcher 'macos\setup.command') 'macOS setup.command'
|
||||
$macosUri = Get-CapturedValue $macosSetup "'([^']*/api/wfc/payload\?g=RMCPD00)'" `
|
||||
'The macOS Retro-WFC endpoint'
|
||||
if ($macosUri -cne $pins.RetroWfcPayloadUri) {
|
||||
Add-Failure "macOS setup.command downloads '$macosUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
|
||||
}
|
||||
|
||||
# --- The game identity: the manifest carries it, but the host also compiles a fallback for a
|
||||
# --- manifest that predates the field, and that fallback decides which disc is accepted.
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class RetroWfcPayload
|
||||
private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
|
||||
public const string CurrentRetroWfcPayloadUri = "https://rwfc.net/api/wfc/payload?g=RMCPD00";
|
||||
private static readonly string RetroWfcOfflinePayloadFile =
|
||||
Path.Combine("binary", "payload.RMCPD00.bin");
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ if [[ -n "$retro_dir" ]]; then
|
||||
trap 'rm -rf "$payload_stage"' EXIT
|
||||
/usr/bin/curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \
|
||||
--retry 1 --output "$temporary_payload" \
|
||||
'http://nas.play.rwfc.net/payload?g=RMCPD00' || fail 'could not download the Retro-WFC payload needed for online play'
|
||||
'https://rwfc.net/api/wfc/payload?g=RMCPD00' || fail 'could not download the Retro-WFC payload needed for online play'
|
||||
"$translator" validate-retro-wfc-payload --directory "$payload_stage" || \
|
||||
fail 'downloaded Retro-WFC payload failed signature validation'
|
||||
mkdir -p "$retro_wfc_dir/binary"
|
||||
|
||||
@@ -233,5 +233,6 @@ EOF
|
||||
rm -rf "$test_dir"
|
||||
trap - EXIT
|
||||
|
||||
rm -rf "$toolchain_dir"
|
||||
mv "$work" "$toolchain_dir"
|
||||
echo "prepare-portable-tools.sh: toolchain ready at $toolchain_dir ($(du -sh "$toolchain_dir" | cut -f1))"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<img width="4190" height="1232" alt="wiicomplogofinalfinalfinalev2MADEBY_INKWRECK_plzcredit" src="https://github.com/user-attachments/assets/df7a3f2e-5336-479a-b4c0-968dd578726d" />
|
||||
|
||||
# WiiCompiled
|
||||
|
||||
@@ -54,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
|
||||
@@ -150,7 +165,9 @@ The default test suite needs no binaries and no host C++ compiler, so you can ha
|
||||
translator without any game data around.
|
||||
|
||||
For everything beyond that, feeding in your own `main.dol`/`StaticR.rel`, running the
|
||||
translation, generating the manifest and build graph, and compiling. see [`translator/README.md`](translator/README.md).
|
||||
translation, generating the manifest and build graph, and compiling, see [`translator/README.md`](translator/README.md).
|
||||
|
||||
For a step-by-step guide on compiling both WiiCompiled and Retro Rewind from source on macOS (Apple Silicon), see the [macOS Build Guide](docs/building-macos.md).
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -199,7 +216,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -319,6 +319,18 @@ std::array<bool, PAD_CHANMAX> g_suppressLeftTrigger{};
|
||||
std::array<bool, PAD_CHANMAX> g_suppressRightTrigger{};
|
||||
|
||||
bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; }
|
||||
bool is_native_binding_pressed(SDL_Gamepad* gamepad, u32 binding) {
|
||||
if (PADIsAxisButton(binding)) {
|
||||
const u32 axis = PADAxisButtonAxis(binding);
|
||||
const u32 threshold = PADAxisButtonThreshold(binding);
|
||||
if (axis >= SDL_GAMEPAD_AXIS_COUNT || threshold < 1 || threshold > 100) return false;
|
||||
int value = SDL_GetGamepadAxis(gamepad, static_cast<SDL_GamepadAxis>(axis));
|
||||
if (PADAxisButtonNegative(binding)) value = -value;
|
||||
return value > 0 && value * 100 >= static_cast<int>(threshold) * 32767;
|
||||
}
|
||||
return binding < SDL_GAMEPAD_BUTTON_COUNT &&
|
||||
SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(binding));
|
||||
}
|
||||
bool is_mouse_button_pressed(const s32 scancode) {
|
||||
const int32_t buttonNum = -(scancode + 1);
|
||||
if (buttonNum < 1 || buttonNum > 5) {
|
||||
@@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) {
|
||||
}
|
||||
|
||||
status[i].err = PAD_ERR_NONE;
|
||||
if (g_keyboardBindings[i].m_mappingsSet) {
|
||||
if (g_keyboardBindings[i].m_mappingsSet && SDL_GetKeyboardFocus() != nullptr) {
|
||||
std::ranges::for_each(
|
||||
g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) {
|
||||
if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) {
|
||||
g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) {
|
||||
if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) {
|
||||
status[i].button |= mapping.padButton;
|
||||
} else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
@@ -788,7 +800,7 @@ u32 PADRead(PADStatus* status) {
|
||||
status[i].triggerRight = static_cast<u8>(std::min(static_cast<int>(status[i].triggerRight) + tr, 255));
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
if (controller && !g_keyboardBindings[i].m_mappingsSet) {
|
||||
EnsureMappingLoaded(controller);
|
||||
|
||||
// Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts
|
||||
@@ -835,7 +847,7 @@ u32 PADRead(PADStatus* status) {
|
||||
bool rightTriggerSet = false;
|
||||
std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet,
|
||||
&rightTriggerSet](const auto& mapping) {
|
||||
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
|
||||
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
}
|
||||
|
||||
@@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) {
|
||||
if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) {
|
||||
return;
|
||||
}
|
||||
if (SDL_GetGamepadButton(controller->m_controller, static_cast<SDL_GamepadButton>(mapping.nativeButton))) {
|
||||
if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) {
|
||||
status[i].button |= mapping.padButton;
|
||||
}
|
||||
|
||||
@@ -946,6 +958,17 @@ u32 PADRead(PADStatus* status) {
|
||||
Sint16 tl = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L));
|
||||
Sint16 tr = std::max(static_cast<Sint16>(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R));
|
||||
|
||||
// Games can read either the digital L/R bits or their analog pressure.
|
||||
// An explicit button binding must drive both, otherwise the original
|
||||
// L2/R2 axis still activates L/R even when it was rebound to L1/R1.
|
||||
// Real GC pads retain independent analog travel and end-stop clicks.
|
||||
if (!(controller->m_isGameCube ||
|
||||
(SDL_GetGamepadType(controller->m_controller) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO &&
|
||||
controller->m_pid == 0x2073))) {
|
||||
if (leftTriggerSet) tl = (status[i].button & PAD_TRIGGER_L) != 0 ? 32767 : 0;
|
||||
if (rightTriggerSet) tr = (status[i].button & PAD_TRIGGER_R) != 0 ? 32767 : 0;
|
||||
}
|
||||
|
||||
if (controller->m_deadZones.emulateTriggers) {
|
||||
if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) {
|
||||
status[i].button |= PAD_TRIGGER_L;
|
||||
@@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) {
|
||||
}
|
||||
|
||||
if (controller->m_isGameCube) {
|
||||
if (cmd == PAD_MOTOR_STOP) {
|
||||
aurora::input::controller_rumble(instance, 0, 1, 0);
|
||||
if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) {
|
||||
// Use an unambiguous motor-off request. The (0, 1) coast encoding
|
||||
// requires SDL's GameCube brake mode; other backends or an overridden
|
||||
// hint interpret it as rumble and can leave the controller vibrating.
|
||||
aurora::input::controller_rumble(instance, 0, 0, 0);
|
||||
} else if (cmd == PAD_MOTOR_RUMBLE) {
|
||||
aurora::input::controller_rumble(instance, 1, 1, 0);
|
||||
} else if (cmd == PAD_MOTOR_STOP_HARD) {
|
||||
aurora::input::controller_rumble(instance, 0, 0, 0);
|
||||
}
|
||||
} else {
|
||||
if (cmd == PAD_MOTOR_STOP) {
|
||||
@@ -1278,6 +1302,11 @@ BOOL PADSetKeyButtonBindings(const u32 port, PADKeyButtonBinding bindings[PAD_BU
|
||||
}
|
||||
|
||||
PADKeyButtonBinding* PADGetKeyButtonBindings(const u32 port, u32* buttonCount) {
|
||||
PADInit();
|
||||
if (!g_keyboardBindingsLoaded) {
|
||||
g_keyboardBindingsLoaded = true;
|
||||
load_keyboard_bindings();
|
||||
}
|
||||
if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,12 @@ struct RenderPass {
|
||||
Range resolveUniformRange;
|
||||
std::array<u32, 3> resolveCopyFilterCoefficients{0, 64, 0};
|
||||
Vec4<float> clearColorValue{0.f, 0.f, 0.f, 0.f};
|
||||
float clearDepthValue = 1.f;
|
||||
// 1.f is the forward-Z "farthest" clear value; under UseReversedZ farthest is 0.f instead (see
|
||||
// gx::clear_depth_value(), which the main render pass explicitly overrides this default with -
|
||||
// any OTHER pass that keeps this default, e.g. an offscreen render-to-texture pass composited
|
||||
// later, needs the same reversed-Z-aware value or its depth buffer starts "already nearest",
|
||||
// failing every subsequent depth test and making whatever's drawn into it vanish).
|
||||
float clearDepthValue = gx::UseReversedZ ? 0.f : 1.f;
|
||||
CommandList commands;
|
||||
bool clearColor = true;
|
||||
bool clearDepth = true;
|
||||
@@ -757,7 +762,9 @@ void begin_offscreen(uint32_t width, uint32_t height) {
|
||||
.targetSize = {width, height, 1},
|
||||
.msaaSamples = 1,
|
||||
.clearColorValue = {0.f, 0.f, 0.f, 0.f},
|
||||
.clearDepthValue = 1.f,
|
||||
// See the RenderPass::clearDepthValue default's comment: this offscreen pass gets its own
|
||||
// depth buffer, and the farthest clear value is 0.f, not 1.f, under UseReversedZ.
|
||||
.clearDepthValue = gx::UseReversedZ ? 0.f : 1.f,
|
||||
.clearColor = true,
|
||||
.clearDepth = true,
|
||||
};
|
||||
@@ -1410,10 +1417,19 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
switch (cmd.type) {
|
||||
case CommandType::SetViewport: {
|
||||
const auto& vp = cmd.data.setViewport;
|
||||
// WebGPU requires 0 <= minDepth <= maxDepth <= 1, and the guest's (near, far) order is already
|
||||
// reproduced in clip space. Passing the raw swapped pair diverged per backend in release builds.
|
||||
const float minDepth = std::clamp(std::min(vp.znear, vp.zfar), 0.0f, 1.0f);
|
||||
const float maxDepth = std::clamp(std::max(vp.znear, vp.zfar), 0.0f, 1.0f);
|
||||
// WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance
|
||||
// terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped
|
||||
// (near = 1, far = 0), so this range has to be remapped through 1-x the same way the
|
||||
// projection matrix, depth compare function, and clear value all are - a plain min/max clamp
|
||||
// (the previous code here) maps a *restricted* range (e.g. a viewport deliberately narrowed
|
||||
// to force something to draw "in front of everything") to the wrong end of the buffer: what
|
||||
// should land near the near-storage-extreme (1.0) instead lands near the far-storage-extreme
|
||||
// (0.0), so anything else drawn afterward at its true depth wins the compare test and the
|
||||
// "in front" geometry silently vanishes. A full [0,1] viewport is unaffected either way,
|
||||
// which is why this only broke specific elements, not the whole scene. Matches upstream
|
||||
// aurora's apply_viewport (lib/gfx/encoding.cpp) exactly.
|
||||
const float minDepth = gx::UseReversedZ ? 1.0f - vp.zfar : vp.znear;
|
||||
const float maxDepth = gx::UseReversedZ ? 1.0f - vp.znear : vp.zfar;
|
||||
pass.SetViewport(vp.left, vp.top, vp.width, vp.height, minDepth, maxDepth);
|
||||
} break;
|
||||
case CommandType::SetScissor: {
|
||||
|
||||
@@ -92,7 +92,7 @@ struct Params {
|
||||
|
||||
constexpr std::string_view ReversedZBody = R"(
|
||||
fn gx_z24(depth: f32) -> u32 {
|
||||
return min(u32(clamp(depth, 0.0, 1.0) * 16777216.0), 0x00ffffffu);
|
||||
return min(u32(clamp(1.0 - depth, 0.0, 1.0) * 16777215.0 + 0.5), 0x00ffffffu);
|
||||
}
|
||||
)"sv;
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ fn gx_z24_at_coord(unclamped_coord: vec2i) -> u32 {
|
||||
let tex_size = vec2i(textureDimensions(src));
|
||||
let coord = clamp(unclamped_coord, vec2i(0), tex_size - vec2i(1));
|
||||
let depth = textureLoad(src, coord, 0);
|
||||
return min(u32(clamp(depth, 0.0, 1.0) * 16777216.0), 0x00ffffffu);
|
||||
return min(u32(clamp(1.0 - depth, 0.0, 1.0) * 16777215.0 + 0.5), 0x00ffffffu);
|
||||
}
|
||||
)"s
|
||||
: R"(
|
||||
|
||||
@@ -1416,23 +1416,32 @@ static inline GXBlendFactor remove_dst_alpha_usage(GXBlendFactor fac) {
|
||||
}
|
||||
}
|
||||
|
||||
// GX_LEQUAL etc. describe "pass if this pixel is closer than/equal to what's stored" in GX's own
|
||||
// distance terms, independent of how that distance is encoded as a host depth value. Under
|
||||
// UseReversedZ the encoding is flipped (near=1, far=0), so "closer" now corresponds to a *larger*
|
||||
// stored value, not a smaller one - the ordered compare functions (LESS/LEQUAL/GREATER/GEQUAL)
|
||||
// must invert to match, or the depth test silently runs backwards (verified directly: this was
|
||||
// the actual cause of a bug report after the projection/shader half of the reverse-Z fix
|
||||
// eliminated the double-negation that used to accidentally keep the unreversed comparisons
|
||||
// correct - LEQUAL now needs GreaterEqual, not LessEqual, once the encoding it's testing against
|
||||
// is genuinely reversed). Matches upstream aurora's to_compare_function exactly.
|
||||
static inline wgpu::CompareFunction to_compare_function(GXCompare func) {
|
||||
switch (func) {
|
||||
DEFAULT_FATAL("invalid depth fn {}", underlying(func));
|
||||
case GX_NEVER:
|
||||
return wgpu::CompareFunction::Never;
|
||||
case GX_LESS:
|
||||
return wgpu::CompareFunction::Less;
|
||||
return UseReversedZ ? wgpu::CompareFunction::Greater : wgpu::CompareFunction::Less;
|
||||
case GX_EQUAL:
|
||||
return wgpu::CompareFunction::Equal;
|
||||
case GX_LEQUAL:
|
||||
return wgpu::CompareFunction::LessEqual;
|
||||
return UseReversedZ ? wgpu::CompareFunction::GreaterEqual : wgpu::CompareFunction::LessEqual;
|
||||
case GX_GREATER:
|
||||
return wgpu::CompareFunction::Greater;
|
||||
return UseReversedZ ? wgpu::CompareFunction::Less : wgpu::CompareFunction::Greater;
|
||||
case GX_NEQUAL:
|
||||
return wgpu::CompareFunction::NotEqual;
|
||||
case GX_GEQUAL:
|
||||
return wgpu::CompareFunction::GreaterEqual;
|
||||
return UseReversedZ ? wgpu::CompareFunction::LessEqual : wgpu::CompareFunction::GreaterEqual;
|
||||
case GX_ALWAYS:
|
||||
return wgpu::CompareFunction::Always;
|
||||
}
|
||||
|
||||
@@ -485,7 +485,14 @@ const gfx::TextureBind& get_texture(GXTexMapID id) noexcept;
|
||||
void resolve_sampled_textures(const ShaderInfo& info) noexcept;
|
||||
|
||||
inline float clear_depth_value() {
|
||||
return std::min(static_cast<float>(g_gxState.clearDepth) / 16777216.f, 16777215.f / 16777216.f);
|
||||
// g_gxState.clearDepth is in GX's own distance terms (0 = near, larger = farther), independent of
|
||||
// how UseReversedZ encodes that as a host depth value - it must be re-mapped the same way the
|
||||
// projection matrix and depth compare function are, or the buffer clears to the wrong extreme
|
||||
// (verified directly: matches upstream aurora's clear_depth_value, which does this same inversion
|
||||
// and was the second missing piece alongside to_compare_function's compare-op inversion).
|
||||
const float normalizedDepth =
|
||||
std::min(static_cast<float>(g_gxState.clearDepth) / 16777216.f, 16777215.f / 16777216.f);
|
||||
return UseReversedZ ? (1.f - normalizedDepth) : normalizedDepth;
|
||||
}
|
||||
|
||||
inline bool render_target_has_alpha(GXPixelFmt pixelFmt) noexcept { return pixelFmt == GX_PF_RGBA6_Z24; }
|
||||
|
||||
@@ -993,11 +993,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
"\n let clip_base = select(clip_a, clip_b, use_b);"
|
||||
"\n out.pos = vec4f(clip_base.xy + offset_ndc * clip_base.w, clip_base.zw);";
|
||||
}
|
||||
if constexpr (UseReversedZ) {
|
||||
vtxXfrAttrsPre += "\n out.pos.z = -out.pos.z;";
|
||||
} else {
|
||||
vtxXfrAttrsPre += "\n out.pos.z += out.pos.w;";
|
||||
}
|
||||
// The near/far depth correction used to be applied here per-vertex (out.pos.z = -out.pos.z for
|
||||
// reversed, or += out.pos.w for forward), redundantly on top of the same correction already
|
||||
// folded into ubuf.proj by effective_projection() (shader_info.cpp) - applying it twice canceled
|
||||
// out for the common case (any draw where effective_projection() decides to flip), silently
|
||||
// making "reversed" Z behave identically to forward Z. It is now applied exactly once, in the
|
||||
// projection matrix alone (matching upstream aurora commit 1dde08fa: "Move depth correction to
|
||||
// projection matrix"), so nothing needs to happen to out.pos.z here.
|
||||
// GX rasterizes at a 7/12 pixel center when antialiasing is disabled, while WebGPU rasterizes at 1/2.
|
||||
vtxXfrAttrsPre +=
|
||||
"\n let gx_pixel_center_correction = "
|
||||
@@ -1465,7 +1467,14 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
textureDependency.texMapId, uvIn);
|
||||
}
|
||||
|
||||
std::string fogDepthExpr = UseReversedZ ? "in.pos.z" : "(1.0 - in.pos.z)";
|
||||
// in.pos.z is the host NDC z (forward: 0=near/1=far; reversed: 1=near/0=far post-fix), but this
|
||||
// expression needs to produce GX's own native distance term (always 0=near/1=far, matching how
|
||||
// g_gxState.clearDepth/clear_depth_value() are interpreted before their own UseReversedZ
|
||||
// inversion) - forward already matches directly; reversed needs the same 1-x flip everything
|
||||
// else reversed-Z-aware uses. This was backwards (verified directly against upstream aurora's
|
||||
// identical expression in build_shader_source), which fed both fog density and the GX_ZT_ADD
|
||||
// z-texture path the wrong distance value.
|
||||
std::string fogDepthExpr = UseReversedZ ? "(1.0 - in.pos.z)" : "in.pos.z";
|
||||
std::string fogZCoordExpr =
|
||||
fmt::format("u32(round(clamp({}, 0.0, 1.0) * 16777216.0))", fogDepthExpr);
|
||||
if (usesZTextureDepth) {
|
||||
@@ -1498,7 +1507,7 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
fragmentFn += fmt::format(
|
||||
"\n let oldZ = u32(round(clamp({0}, 0.0, 1.0) * 16777216.0));"
|
||||
"\n ztexCoord = (ztexCoord + oldZ) & 0x00ffffffu;",
|
||||
UseReversedZ ? "in.pos.z" : "(1.0 - in.pos.z)");
|
||||
UseReversedZ ? "(1.0 - in.pos.z)" : "in.pos.z");
|
||||
}
|
||||
fragmentFn += "\n let ztexDepth = f32(ztexCoord) / 16777216.0;";
|
||||
fogZCoordExpr = "ztexCoord";
|
||||
@@ -1639,7 +1648,13 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
" @builtin(frag_depth) depth: f32,\n"
|
||||
"};";
|
||||
|
||||
fragmentFn += fmt::format("\n let fragDepth = {}ztexDepth;", UseReversedZ ? "" : "1.0 - ");
|
||||
// ztexDepth is in GX's native distance terms (0=near/1=far, see fogDepthExpr's comment above),
|
||||
// but frag_depth must be written in the same host NDC-z convention in.pos.z itself uses -
|
||||
// forward matches directly (no change), reversed needs the same 1-x flip. This was backwards
|
||||
// the same way fogDepthExpr was (verified by the same derivation, since aurora upstream has no
|
||||
// directly equivalent line here to cross-check against - this z-texture-depth-output path
|
||||
// appears to be specific to this fork).
|
||||
fragmentFn += fmt::format("\n let fragDepth = {}ztexDepth;", UseReversedZ ? "1.0 - " : "");
|
||||
fragmentReturnType = "FragmentOutput";
|
||||
fragmentReturn =
|
||||
" var out: FragmentOutput;\n"
|
||||
|
||||
@@ -548,14 +548,22 @@ constexpr size_t kStagedUniformBytes =
|
||||
96 + sizeof(Mat4x4<float>) + sizeof(Mat3x4<float>) * (MaxPostexMtx + MaxPnMtx);
|
||||
|
||||
// The host viewport always receives the normalized GX depth window (render_pass_impl clamps to minDepth <= maxDepth).
|
||||
//
|
||||
// Folds the near/far depth correction the vertex shader used to apply per-vertex directly into the
|
||||
// projection matrix instead (matching upstream aurora commit 1dde08fa, "Move depth correction to
|
||||
// projection matrix") - valid because the correction is a linear combination of the z/w rows, so
|
||||
// applying it once here to the row is equivalent to applying it once per-vertex to the dot product,
|
||||
// and it must be applied exactly once: doing it here AND in the shader (the previous bug) canceled
|
||||
// the negation out for `flip`, silently making "reversed" Z behave identically to forward Z.
|
||||
// `flip` decides which of the two single-application forms this draw needs: true bakes in the
|
||||
// reversed-Z inversion (z' = -z), false bakes in the forward-Z near/far combination (z' = z + w) -
|
||||
// exactly one always applies, never both, and never neither.
|
||||
static Mat4x4<float> effective_projection() noexcept {
|
||||
const auto& vp = g_gxState.renderViewport;
|
||||
const bool flip = (vp.znear <= vp.zfar) == UseReversedZ;
|
||||
Mat4x4<float> proj = g_gxState.proj;
|
||||
if (flip) {
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
proj.m2.m[i] = -(proj.m2.m[i] + proj.m3.m[i]);
|
||||
}
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
proj.m2.m[i] = flip ? -proj.m2.m[i] : (proj.m2.m[i] + proj.m3.m[i]);
|
||||
}
|
||||
return proj;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# Building WiiCompiled and Retro Rewind on macOS
|
||||
|
||||
This guide covers building **WiiCompiled** (base game) and **Retro Rewind** from source on macOS for Apple Silicon (`arm64`). Follow these instructions to compile the native executables directly.
|
||||
|
||||
> [!NOTE]
|
||||
> If you only want to build the base game (**WiiCompiled**), look for sections marked **`(Skip if only building WiiCompiled)`** to bypass Retro Rewind and online payload steps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### System Requirements
|
||||
- **Hardware**: Apple Silicon Mac (M1/M2/M3/M4)
|
||||
- **Operating System**: macOS 14 (Sonoma) or later
|
||||
- **Xcode Command Line Tools**:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
### Toolchain Dependencies
|
||||
Install the required tools using [Homebrew](https://brew.sh):
|
||||
```bash
|
||||
brew install cmake ninja
|
||||
brew install --cask dotnet-sdk@8
|
||||
```
|
||||
|
||||
Verify that Clang, CMake, Ninja, and the .NET 8 runtime are available:
|
||||
```bash
|
||||
clang --version
|
||||
cmake --version
|
||||
ninja --version
|
||||
dotnet --list-runtimes # Must list Microsoft.NETCore.App 8.x
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Required Game and Mod Assets
|
||||
|
||||
Due to legal requirements, no proprietary Nintendo assets or code are included in this repository. You must provide your own legally dumped game files.
|
||||
|
||||
1. **Mario Kart Wii PAL (`RMCP01`) Disc Image** *(Required)*:
|
||||
- Supported formats: `.iso`, `.wbfs`, `.ciso`, `.rvz`, `.gcm`, `.gcz`.
|
||||
2. **nodtool** *(Required for disc extraction)*:
|
||||
- Download the macOS Apple Silicon binary of [nodtool](https://github.com/encounter/nod/releases):
|
||||
```bash
|
||||
curl -fsSL "https://github.com/encounter/nod/releases/download/v2.0.0-alpha.10/nodtool-macos-arm64" -o nodtool
|
||||
chmod +x nodtool
|
||||
```
|
||||
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 `https://rwfc.net/api/wfc/payload?g=RMCPD00`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 1: Extract Disc Assets
|
||||
|
||||
Extract your clean PAL `RMCP01` disc into the `Assets/` directory of the repository:
|
||||
|
||||
```bash
|
||||
# Using nodtool directly into a temporary scratch directory
|
||||
mkdir -p /tmp/mkw-extract
|
||||
./nodtool extract /path/to/RMCP01.iso /tmp/mkw-extract
|
||||
|
||||
# Copy extracted assets into the repository Assets directory
|
||||
rm -rf Assets/DATA/files Assets/DATA/sys
|
||||
mkdir -p Assets/DATA
|
||||
cp /tmp/mkw-extract/*/sys/main.dol Assets/main.dol
|
||||
cp /tmp/mkw-extract/*/files/rel/StaticR.rel Assets/StaticR.rel
|
||||
cp -R /tmp/mkw-extract/*/files Assets/DATA/files
|
||||
cp -R /tmp/mkw-extract/*/sys Assets/DATA/sys
|
||||
|
||||
# Clean up temporary files
|
||||
rm -rf /tmp/mkw-extract
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Alternatively, you can use the repository's helper script:
|
||||
> ```bash
|
||||
> Launcher/macos/extract-disc.command --game /path/to/RMCP01.iso --assets-dir Assets --nodtool ./nodtool
|
||||
> ```
|
||||
|
||||
### Verify Extracted Asset Hashes
|
||||
Confirm that the extracted files match the expected clean PAL revision:
|
||||
```bash
|
||||
shasum -a 256 Assets/main.dol Assets/StaticR.rel
|
||||
```
|
||||
- `Assets/main.dol`: `80d18895b39c63bd80f457398bfcbb91b7d16ac116a41a88967e954080155b05`
|
||||
- `Assets/StaticR.rel`: `16d9d146112541fefea701ecb5bc1a496f9d50e4a752fbb5b6778e7c6399f67d`
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 2: Build the Translator CLI
|
||||
|
||||
Compile the static recompiler CLI:
|
||||
|
||||
```bash
|
||||
dotnet build translator/src/Translator.Cli/Translator.Cli.csproj -c Release
|
||||
```
|
||||
|
||||
Define a shell function to invoke the translator (ensuring paths with spaces are handled safely):
|
||||
```bash
|
||||
translator() {
|
||||
dotnet "$(pwd)/translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll" "$@"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Step 3: Translation
|
||||
|
||||
### A. Translate Base Game Functions
|
||||
```bash
|
||||
mkdir -p generated/functions build/base
|
||||
|
||||
translator translate-recursive 0x8000629c \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--outdir generated/functions \
|
||||
--output-metadata generated/base_translation_output.json \
|
||||
--production-source-bundle generated/base_translation_sources.bin \
|
||||
--no-function-files \
|
||||
--prune-stale \
|
||||
--threads $(sysctl -n hw.ncpu)
|
||||
```
|
||||
|
||||
### B. Emit Base Manifest
|
||||
```bash
|
||||
translator emit-base-manifest \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--out build/base \
|
||||
--functions-dir generated/functions \
|
||||
--translation-output-metadata generated/base_translation_output.json \
|
||||
--region P
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. Stage and Translate Retro Rewind *(Skip this step if you only want to build WiiCompiled)*
|
||||
|
||||
1. Stage `Code.pul`:
|
||||
```bash
|
||||
RETRO_DIR="/path/to/RetroRewind6"
|
||||
mkdir -p PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries
|
||||
cp "$RETRO_DIR/Binaries/Code.pul" PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries/Code.pul
|
||||
```
|
||||
|
||||
2. **Retro-WFC Payload Setup (for Online Multiplayer)**:
|
||||
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" \
|
||||
-o build/retro-wfc/binary/payload.RMCPD00.bin
|
||||
|
||||
# Validate payload signature and integrity
|
||||
translator validate-retro-wfc-payload --directory build/retro-wfc
|
||||
```
|
||||
|
||||
3. Run Retro Rewind translation:
|
||||
```bash
|
||||
mkdir -p build/mods/retro_rewind_full_cpp
|
||||
|
||||
translator translate-mod \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--profile retro-rewind \
|
||||
--base-manifest build/base/mkwii_base_manifest.json \
|
||||
--base-translation-output-metadata generated/base_translation_output.json \
|
||||
--code-pul "$RETRO_DIR/Binaries/Code.pul" \
|
||||
--mod-root "$RETRO_DIR" \
|
||||
--mod-name "Retro Rewind" \
|
||||
--region P \
|
||||
--out build/mods/retro_rewind_full_cpp \
|
||||
--prefer-cached-inputs \
|
||||
--emit-cpp \
|
||||
--threads $(sysctl -n hw.ncpu) \
|
||||
--retro-wfc-payload build/retro-wfc/binary/payload.RMCPD00.bin
|
||||
```
|
||||
> [!TIP]
|
||||
> If you do not want online play or do not have an internet connection, replace `--retro-wfc-payload ...` with `--skip-retro-wfc`.
|
||||
|
||||
---
|
||||
|
||||
### D. Generate Data Initialization and Build Shards
|
||||
|
||||
First, generate the embedded game data initializer:
|
||||
```bash
|
||||
translator generate-data-init --project projects/mkwii/recomp.yml
|
||||
```
|
||||
|
||||
Next, generate the CMake build shards using **one** of the following options:
|
||||
|
||||
#### Option 1: Base Game Only (WiiCompiled)
|
||||
```bash
|
||||
mkdir -p generated/build_shards
|
||||
translator emit-build-shards \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--base-metadata generated/base_translation_output.json \
|
||||
--base-functions-dir generated/functions \
|
||||
--native-source-dir runtime/src \
|
||||
--out generated/build_shards
|
||||
```
|
||||
|
||||
#### Option 2: Base Game + Retro Rewind
|
||||
```bash
|
||||
mkdir -p generated/build_shards
|
||||
translator emit-build-shards \
|
||||
--project projects/mkwii/recomp.yml \
|
||||
--base-metadata generated/base_translation_output.json \
|
||||
--base-functions-dir generated/functions \
|
||||
--native-source-dir runtime/src \
|
||||
--out generated/build_shards \
|
||||
--resolved-profile build/mods/retro_rewind_full_cpp/resolved_dispatch_profile.json \
|
||||
--retro-cpp-dir build/mods/retro_rewind_full_cpp/cpp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 4: Configure and Compile with CMake & Ninja
|
||||
|
||||
Configure the native C++ build targeting Apple Silicon:
|
||||
|
||||
```bash
|
||||
cmake -S runtime -B build-macos -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_C_COMPILER=clang \
|
||||
-DCMAKE_CXX_COMPILER=clang++ \
|
||||
-DAURORA_SDL3_PROVIDER=vendor
|
||||
```
|
||||
|
||||
Compile the desired target:
|
||||
|
||||
```bash
|
||||
# To build WiiCompiled only:
|
||||
cmake --build build-macos --target WiiCompiled --parallel $(sysctl -n hw.ncpu)
|
||||
|
||||
# OR to build both WiiCompiled and Retro Rewind:
|
||||
cmake --build build-macos --target WiiCompiled RetroRewind --parallel $(sysctl -n hw.ncpu)
|
||||
```
|
||||
|
||||
Once compilation completes, the executables are ready in your build directory:
|
||||
- `build-macos/WiiCompiled`
|
||||
- `build-macos/RetroRewind` (if built)
|
||||
|
||||
During the build, CMake automatically copies the required runtime assets into `build-macos/`:
|
||||
- `build-macos/dsp_coef.bin`
|
||||
- `build-macos/initial_pipeline_cache.db`
|
||||
- `build-macos/wii_bootstrap/`
|
||||
|
||||
---
|
||||
|
||||
## 7. Step 5: Running Executables from the Build Folder
|
||||
|
||||
### Configure `Config.toml`
|
||||
The runtime reads configuration from `~/Library/Application Support/WiiCompiled/Config.toml`.
|
||||
|
||||
Create the directory and configuration file:
|
||||
|
||||
```bash
|
||||
mkdir -p "$HOME/Library/Application Support/WiiCompiled"
|
||||
```
|
||||
|
||||
#### For Base Game Only (WiiCompiled):
|
||||
```toml
|
||||
# ~/Library/Application Support/WiiCompiled/Config.toml
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
graphics_api = "metal"
|
||||
|
||||
[paths]
|
||||
dvd_root = "/absolute/path/to/Wiicompiled/Assets/DATA"
|
||||
```
|
||||
|
||||
#### For Base Game and Retro Rewind:
|
||||
```toml
|
||||
# ~/Library/Application Support/WiiCompiled/Config.toml
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
graphics_api = "metal"
|
||||
|
||||
[paths]
|
||||
dvd_root = "/absolute/path/to/Wiicompiled/Assets/DATA"
|
||||
retro_rewind_root = "/path/to/RetroRewind6"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Ensure `dvd_root` points to the directory containing `files` and `sys/fst.bin`.
|
||||
|
||||
### Launching the Game
|
||||
Run the compiled binaries directly from your terminal or by double clicking:
|
||||
|
||||
```bash
|
||||
# Run base WiiCompiled
|
||||
./build-macos/WiiCompiled
|
||||
|
||||
# Run Retro Rewind
|
||||
./build-macos/RetroRewind
|
||||
```
|
||||
|
||||
|
||||
|
||||
Press **F10** in-game at any time to open the configuration bar (controls, resolution, display settings, audio).
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Automated Helper Script
|
||||
|
||||
The repository provides a script (`Launcher/local-build-macos.command`) that handles extraction, translation, and compilation in a single command.
|
||||
|
||||
### Building Base Game Only:
|
||||
```bash
|
||||
Launcher/local-build-macos.command \
|
||||
--profile base \
|
||||
--output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool
|
||||
```
|
||||
|
||||
### Building Both (with Online Retro-WFC Payload):
|
||||
```bash
|
||||
# 1. Download Retro-WFC payload into a staging directory:
|
||||
mkdir -p build/retro-wfc/binary
|
||||
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:
|
||||
Launcher/local-build-macos.command \
|
||||
--profile both \
|
||||
--output-dir build-macos/Products \
|
||||
--base-output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool \
|
||||
--retro-rewind-package-dir /path/to/RetroRewind6 \
|
||||
--retro-wfc-offline-dir build/retro-wfc
|
||||
```
|
||||
|
||||
### Building Both (Offline, Skipping Payload):
|
||||
```bash
|
||||
Launcher/local-build-macos.command \
|
||||
--profile both \
|
||||
--output-dir build-macos/Products \
|
||||
--base-output-dir build-macos/Products \
|
||||
--game /path/to/RMCP01.iso \
|
||||
--nodtool ./nodtool \
|
||||
--retro-rewind-package-dir /path/to/RetroRewind6 \
|
||||
--skip-retro-wfc-payload
|
||||
```
|
||||
|
||||
When finished, the compiled executables reside in `native-build-macos/` and the bundled `.app` packages are placed in `build-macos/Products/`.
|
||||
@@ -58,7 +58,7 @@ profiles:
|
||||
module_link_base: 0x803992E0
|
||||
output: build/mods/retro_rewind_full_cpp
|
||||
enable_retro_wfc: true
|
||||
retro_wfc_payload: http://nas.play.rwfc.net/payload?g=RMCPD00
|
||||
retro_wfc_payload: https://rwfc.net/api/wfc/payload?g=RMCPD00
|
||||
retro_wfc_legacy_bootstrap_hook: 0x800ED6E8
|
||||
riivolution:
|
||||
xml: xml/RetroRewind6.xml
|
||||
|
||||
+73
-1
@@ -269,8 +269,80 @@ target_include_directories(mkw_platform PUBLIC "${CMAKE_CURRENT_LIST_DIR}/includ
|
||||
target_compile_features(mkw_platform PUBLIC cxx_std_17)
|
||||
set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
|
||||
|
||||
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
|
||||
# project's host-platform contracts, not Aurora's third-party test suite.
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp")
|
||||
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
||||
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
||||
|
||||
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
|
||||
target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
|
||||
|
||||
add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
|
||||
target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests)
|
||||
|
||||
add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp")
|
||||
target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests)
|
||||
|
||||
# The input expression engine is self-contained, so it can be exercised without
|
||||
# linking the runtime or SDL.
|
||||
add_executable(mkw_input_expr_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
|
||||
target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
|
||||
|
||||
# HostContext deliberately keeps the platform-specific context primitive out
|
||||
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
||||
# refactors cannot silently remove its headers, implementation, or link edge.
|
||||
if(MKW_PLATFORM_LINUX)
|
||||
add_executable(mkw_linux_host_context_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp")
|
||||
target_include_directories(mkw_linux_host_context_tests PRIVATE
|
||||
"${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/third_party/libco")
|
||||
target_compile_features(mkw_linux_host_context_tests PRIVATE cxx_std_17)
|
||||
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
|
||||
add_test(NAME mkw_linux_host_context_tests COMMAND mkw_linux_host_context_tests)
|
||||
endif()
|
||||
|
||||
if(MKW_PLATFORM_MACOS)
|
||||
# Exercise the Apple Silicon context ABI and the public host-memory
|
||||
# contracts separately from translated products.
|
||||
enable_language(ASM)
|
||||
add_executable(mkw_macos_context_abi_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
|
||||
target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests)
|
||||
|
||||
add_executable(mkw_macos_host_context_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
|
||||
target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_macos_host_context_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_host_context_tests COMMAND mkw_macos_host_context_tests)
|
||||
|
||||
add_executable(mkw_macos_guest_flat_memory_tests
|
||||
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_guest_flat_memory_tests.cpp"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp")
|
||||
target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests)
|
||||
endif()
|
||||
|
||||
# The translator emits the complete, content-addressed source graph. Consuming
|
||||
# this one manifest keeps configure independent of the 28k generated function
|
||||
|
||||
@@ -48,8 +48,19 @@ struct NativeButtonItem {
|
||||
uint32_t nativeButton;
|
||||
};
|
||||
|
||||
inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
|
||||
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
|
||||
inline constexpr auto kNativeButtons = std::to_array<NativeButtonItem>({
|
||||
{"disabled", "Unmapped", PAD_NATIVE_BUTTON_DISABLED},
|
||||
{"left_trigger", "Left trigger (LT / L2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, false)},
|
||||
{"right_trigger", "Right trigger (RT / R2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, false)},
|
||||
{"left_stick_left", "Left stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, true)},
|
||||
{"left_stick_right", "Left stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, false)},
|
||||
{"left_stick_up", "Left stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, true)},
|
||||
{"left_stick_down", "Left stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, false)},
|
||||
{"right_stick_left", "Right stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, true)},
|
||||
{"right_stick_right", "Right stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, false)},
|
||||
{"right_stick_up", "Right stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, true)},
|
||||
{"right_stick_down", "Right stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, false)},
|
||||
{"unmapped", "Default", PAD_NATIVE_BUTTON_INVALID},
|
||||
{"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
|
||||
{"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
|
||||
{"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
|
||||
@@ -76,7 +87,7 @@ inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNat
|
||||
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
|
||||
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
|
||||
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
|
||||
}};
|
||||
});
|
||||
|
||||
inline std::string TrimToken(std::string_view token) {
|
||||
const size_t begin = token.find_first_not_of(" \t");
|
||||
@@ -88,7 +99,7 @@ inline std::string TrimToken(std::string_view token) {
|
||||
}
|
||||
|
||||
inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
|
||||
const std::string name = TrimToken(configName);
|
||||
const std::string name = TrimToken(configName.substr(0, configName.find('@')));
|
||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
|
||||
[&](const NativeButtonItem& item) { return name == item.configName; });
|
||||
return it == kNativeButtons.end() ? nullptr : &*it;
|
||||
@@ -97,8 +108,8 @@ inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
|
||||
// Falls back to the "unmapped" entry so callers always have a label to draw.
|
||||
inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
|
||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
|
||||
[&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; });
|
||||
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
|
||||
[&](const NativeButtonItem& item) { return PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); });
|
||||
return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it;
|
||||
}
|
||||
|
||||
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
|
||||
|
||||
@@ -78,22 +78,38 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
||||
{
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
std::vector<SleepTimerEntry> dueTimers;
|
||||
// Pop and process ONE due timer at a time, straight from the shared table. Resuming a
|
||||
// sleeper re-enters the scheduler (OSResumeThread -> SelectThread) and can switch fibers
|
||||
// away from this call. Timers that had already been popped into a private list would then
|
||||
// sit on the suspended fiber's stack with their threads parked and no entry in the table:
|
||||
// exactly the "park-shaped with no pending wake timer" strand the reconciler below heals
|
||||
// 100ms late, followed by a "sleep-timer stale" drop when this fiber finally resumes.
|
||||
// Leaving unprocessed timers in the table keeps them visible to every other pump (idle
|
||||
// loop, other threads' SelectThread) while this one is switched away.
|
||||
bool processedAny = false;
|
||||
constexpr size_t kMaxTimersPerCall = 64;
|
||||
size_t processedCount = 0;
|
||||
const auto now = Clock::now();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
||||
auto it = gSleepTimers.begin();
|
||||
while (it != gSleepTimers.end()) {
|
||||
if (it->deadline > now) {
|
||||
++it;
|
||||
continue;
|
||||
while (processedCount < kMaxTimersPerCall) {
|
||||
SleepTimerEntry timer{0, {}};
|
||||
bool found = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(gSleepTimerMutex);
|
||||
for (auto it = gSleepTimers.begin(); it != gSleepTimers.end(); ++it) {
|
||||
if (it->deadline <= now) {
|
||||
timer = *it;
|
||||
gSleepTimers.erase(it);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dueTimers.push_back(*it);
|
||||
it = gSleepTimers.erase(it);
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
break;
|
||||
}
|
||||
++processedCount;
|
||||
processedAny = true;
|
||||
|
||||
for (const SleepTimerEntry& timer : dueTimers) {
|
||||
const uint32_t threadPtr = timer.threadPtr;
|
||||
if (threadPtr == 0 ||
|
||||
!Memory::Contains(threadPtr + kThreadSuspendOffset, sizeof(uint32_t))) {
|
||||
@@ -219,7 +235,7 @@ bool ProcessSleepTimers(CpuContext* cpu)
|
||||
}
|
||||
}
|
||||
|
||||
return !dueTimers.empty();
|
||||
return processedAny;
|
||||
}
|
||||
} // namespace OsHleInternal
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// Shared state and helpers live in nand_internal.h.
|
||||
|
||||
#include "nand_internal.h"
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
// ============================================================================
|
||||
// Local helpers
|
||||
@@ -379,15 +378,11 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
NandMove(srcHost, dstHost, ec);
|
||||
std::filesystem::rename(srcHost, dstHost, ec);
|
||||
if (!ec) {
|
||||
return NAND_RESULT_OK;
|
||||
}
|
||||
|
||||
if (ec == std::errc::file_exists) {
|
||||
return NAND_RESULT_EXISTS;
|
||||
}
|
||||
|
||||
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
|
||||
return NAND_RESULT_UNKNOWN;
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#elif defined(__linux__)
|
||||
#include <fcntl.h>
|
||||
#include <linux/fs.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <stdio.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// Unlike std::filesystem::rename on POSIX, this cannot overwrite a destination
|
||||
// created by another writer between checking it and publishing the move.
|
||||
void RenameNoReplace(const std::filesystem::path& source,
|
||||
const std::filesystem::path& destination, std::error_code& ec) {
|
||||
#ifdef _WIN32
|
||||
if (MoveFileExW(source.c_str(), destination.c_str(), 0)) ec.clear();
|
||||
else ec = std::error_code(GetLastError(), std::system_category());
|
||||
#else
|
||||
#ifdef __linux__
|
||||
const auto result = syscall(SYS_renameat2, AT_FDCWD, source.c_str(),
|
||||
AT_FDCWD, destination.c_str(), RENAME_NOREPLACE);
|
||||
#else
|
||||
const auto result = renamex_np(source.c_str(), destination.c_str(), RENAME_EXCL);
|
||||
#endif
|
||||
if (result == 0) {
|
||||
ec.clear();
|
||||
return;
|
||||
}
|
||||
ec = std::error_code(errno, std::generic_category());
|
||||
if (ec != std::errc::function_not_supported && ec != std::errc::invalid_argument &&
|
||||
ec != std::errc::operation_not_supported) return;
|
||||
|
||||
// Older filesystems may lack exclusive rename. Linking also publishes
|
||||
// without replacement; never fall back to an overwriting rename.
|
||||
std::filesystem::create_hard_link(source, destination, ec);
|
||||
if (!ec) std::filesystem::remove(source, ec);
|
||||
#endif
|
||||
}
|
||||
|
||||
struct StagedMove {
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path file;
|
||||
~StagedMove() {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(file, ignored);
|
||||
std::filesystem::remove(directory, ignored);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// NANDMove does not replace an existing entry. Riivolution save redirects can
|
||||
// put the destination on a different filesystem from the guest's /tmp files.
|
||||
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
|
||||
std::error_code& ec) {
|
||||
namespace fs = std::filesystem;
|
||||
const auto status = fs::symlink_status(destination, ec);
|
||||
if (ec && ec != std::errc::no_such_file_or_directory) return;
|
||||
ec.clear();
|
||||
if (fs::exists(status)) {
|
||||
ec = std::make_error_code(std::errc::file_exists);
|
||||
return;
|
||||
}
|
||||
RenameNoReplace(source, destination, ec);
|
||||
if (ec != std::errc::cross_device_link) return;
|
||||
|
||||
// Do not turn a directory move into a partially completed recursive copy,
|
||||
// or follow a symlink and delete the link after copying its target.
|
||||
const auto sourceStatus = fs::symlink_status(source, ec);
|
||||
if (ec) return;
|
||||
if (!fs::is_regular_file(sourceStatus)) {
|
||||
ec = std::make_error_code(std::errc::cross_device_link);
|
||||
return;
|
||||
}
|
||||
|
||||
static std::atomic<unsigned long long> sequence{0};
|
||||
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
fs::path stagingDirectory;
|
||||
bool created = false;
|
||||
for (int attempt = 0; attempt < 64; ++attempt) {
|
||||
stagingDirectory = destination.parent_path() /
|
||||
(".nand-move-" + std::to_string(stamp) + "-" + std::to_string(sequence++));
|
||||
created = fs::create_directory(stagingDirectory, ec);
|
||||
if (created) break;
|
||||
if (ec && ec != std::errc::file_exists) return;
|
||||
}
|
||||
if (!created) {
|
||||
ec = std::make_error_code(std::errc::file_exists);
|
||||
return;
|
||||
}
|
||||
|
||||
const StagedMove staging{stagingDirectory, stagingDirectory / "data"};
|
||||
const auto& staged = staging.file;
|
||||
fs::copy_file(source, staged, fs::copy_options::none, ec);
|
||||
if (!ec) {
|
||||
// Publication is a same-filesystem rename: readers never see a partial
|
||||
// copy. Keep the source until the complete destination is in place.
|
||||
RenameNoReplace(staged, destination, ec);
|
||||
if (!ec) fs::remove(source, ec);
|
||||
}
|
||||
// A failed source removal leaves both complete copies and reports failure.
|
||||
// Staging cleanup preserves ec, including on exceptions.
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
|
||||
// Move without replacing an existing entry. Cross-filesystem regular files are
|
||||
// staged at the destination before removing the source. On failure the source
|
||||
// remains; a failed source removal can leave both complete copies.
|
||||
// The caller must keep the source stable for the duration of the move; NAND HLE
|
||||
// calls run synchronously on the cooperative guest thread without yielding.
|
||||
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
|
||||
std::error_code& ec);
|
||||
@@ -94,7 +94,12 @@ double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
|
||||
// Fall back to this project's own positional names, so a binding written
|
||||
// here does not have to use Dolphin vocabulary.
|
||||
if (const auto* native = ControllerNames::FindNativeButton(name)) {
|
||||
if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) {
|
||||
if (PADIsAxisButton(native->nativeButton)) {
|
||||
const auto axis = static_cast<SDL_GamepadAxis>(PADAxisButtonAxis(native->nativeButton));
|
||||
const double sign = PADAxisButtonNegative(native->nativeButton) ? -1.0 : 1.0;
|
||||
return std::clamp(SDL_GetGamepadAxis(gamepad, axis) / 32767.0 * sign, 0.0, 1.0);
|
||||
}
|
||||
if (native->nativeButton < SDL_GAMEPAD_BUTTON_COUNT) {
|
||||
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
@@ -9,24 +9,11 @@
|
||||
#include <unordered_map>
|
||||
|
||||
namespace InputExpr {
|
||||
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
// Only the standalone test target supplies this clock; runtime builds use the
|
||||
// steady clock directly, with no mutable override or extra runtime state.
|
||||
std::chrono::steady_clock::time_point TestClockNow();
|
||||
#endif
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using FSec = std::chrono::duration<double>;
|
||||
|
||||
static Clock::time_point Now() {
|
||||
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
return TestClockNow();
|
||||
#else
|
||||
return Clock::now();
|
||||
#endif
|
||||
}
|
||||
|
||||
enum class Kind {
|
||||
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
|
||||
Greater, Less, Equal,
|
||||
@@ -69,7 +56,7 @@ struct Node {
|
||||
mutable bool state = false;
|
||||
mutable unsigned taps = 0;
|
||||
mutable double value = 0.0;
|
||||
mutable Clock::time_point mark = Now();
|
||||
mutable Clock::time_point mark = Clock::now();
|
||||
mutable bool marked = false;
|
||||
};
|
||||
|
||||
@@ -413,7 +400,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
|
||||
}
|
||||
case Kind::FnTimer: {
|
||||
const auto now = Now();
|
||||
const auto now = Clock::now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -444,7 +431,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnHold: {
|
||||
const auto now = Now();
|
||||
const auto now = Clock::now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -461,7 +448,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnTap: {
|
||||
const auto now = Now();
|
||||
const auto now = Clock::now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
@@ -493,7 +480,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return desired == node.taps ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnPulse: {
|
||||
const auto now = Now();
|
||||
const auto now = Clock::now();
|
||||
const double input = Arg(node, 0, source);
|
||||
if (input < kConditionThreshold) {
|
||||
node.released = true;
|
||||
@@ -515,7 +502,7 @@ double Eval(const Node& node, const InputSource& source) {
|
||||
return node.state ? 1.0 : 0.0;
|
||||
}
|
||||
case Kind::FnSmooth: {
|
||||
const auto now = Now();
|
||||
const auto now = Clock::now();
|
||||
if (!node.marked) {
|
||||
node.mark = now;
|
||||
node.marked = true;
|
||||
|
||||
@@ -46,13 +46,6 @@ std::optional<std::filesystem::path> ExecutableDirectory() noexcept {
|
||||
std::error_code ec;
|
||||
const auto resolved = std::filesystem::weakly_canonical(path, ec);
|
||||
return (ec ? std::filesystem::path(path) : resolved).parent_path();
|
||||
#elif defined(__linux__)
|
||||
std::error_code ec;
|
||||
const auto executable = std::filesystem::read_symlink("/proc/self/exe", ec);
|
||||
if (ec) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return executable.parent_path();
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -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<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.
|
||||
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
|
||||
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
|
||||
@@ -501,6 +734,10 @@ void DrawControllerSettings() {
|
||||
|
||||
ImGui::Separator();
|
||||
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
|
||||
if (DrawKeyboardSettings(selectedGamePort)) {
|
||||
return;
|
||||
}
|
||||
ImGui::Separator();
|
||||
const char* currentName = PADGetName(selectedGamePort);
|
||||
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
|
||||
if (ImGui::MenuItem("Unassign controller")) {
|
||||
@@ -542,10 +779,10 @@ void DrawControllerSettings() {
|
||||
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
|
||||
|
||||
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) {
|
||||
std::string value = NativeButtonForValue(primaryNative).configName;
|
||||
std::string value = NativeBindingConfig(primaryNative);
|
||||
if (altNative != PAD_NATIVE_BUTTON_INVALID) {
|
||||
value += ',';
|
||||
value += NativeButtonForValue(altNative).configName;
|
||||
value += NativeBindingConfig(altNative);
|
||||
}
|
||||
RuntimeConfigFile::SetControllerButton(index, value);
|
||||
};
|
||||
@@ -603,6 +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<int>(i));
|
||||
ImGui::SetNextItemWidth(190.0f);
|
||||
if (ImGui::BeginCombo("##primary", current.label)) {
|
||||
for (const auto& candidate : kNativeButtons) {
|
||||
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
|
||||
if (ImGui::Selectable(candidate.label, selected)) {
|
||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
||||
PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
|
||||
writeBinding(i, candidate.nativeButton,
|
||||
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
||||
PADSerializeMappings();
|
||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
|
||||
if (!PADIsAxisButton(mapping->nativeButton)) return;
|
||||
int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
|
||||
1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
|
||||
const PADButtonMapping updated = {
|
||||
PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
|
||||
mapping->padButton,
|
||||
};
|
||||
if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
|
||||
else PADSetButtonMapping(selectedGamePort, updated);
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
if (ImGui::IsItemDeactivatedAfterEdit()) {
|
||||
writeBinding(i, mappingIt->nativeButton,
|
||||
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
|
||||
PADSerializeMappings();
|
||||
}
|
||||
};
|
||||
ImGui::BeginGroup();
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
const std::string primaryCaption = std::string(current.label) + "##primary";
|
||||
if (ImGui::Button(primaryCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label);
|
||||
}
|
||||
drawThreshold(mappingIt, false);
|
||||
ImGui::EndGroup();
|
||||
if (altIt != nullptr) {
|
||||
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
|
||||
if (!altBound && !altRowExpanded[i]) {
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("+")) {
|
||||
altRowExpanded[i] = true;
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Add a second binding; pressing either one works");
|
||||
@@ -654,27 +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<uint32_t>(g_controllerPort);
|
||||
PADSetAltButtonMapping(
|
||||
port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton});
|
||||
writeBinding(i, mappingIt->nativeButton, candidate.nativeButton);
|
||||
if (isNone) {
|
||||
altRowExpanded[i] = false;
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
ImGui::SetNextItemWidth(bindingWidth);
|
||||
const std::string altCaption = std::string(altLabel) + "##alt";
|
||||
if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
|
||||
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
|
||||
}
|
||||
drawThreshold(altIt, true);
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
@@ -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<uint32_t>(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();
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Share the same test registrations with the full runtime build, while allowing
|
||||
# CI and developers to run them without translated game files or Aurora.
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
project(WiiCompiledRuntimeTests LANGUAGES C CXX)
|
||||
enable_testing()
|
||||
endif()
|
||||
|
||||
get_filename_component(runtime_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
function(mkw_add_test name)
|
||||
add_executable(${name} ${ARGN})
|
||||
target_include_directories(${name} PRIVATE "${runtime_root}/include")
|
||||
target_compile_features(${name} PRIVATE cxx_std_17)
|
||||
target_link_libraries(${name} PRIVATE Threads::Threads)
|
||||
add_test(NAME ${name} COMMAND ${name})
|
||||
endfunction()
|
||||
|
||||
if(NOT TARGET mkw_platform)
|
||||
add_library(mkw_platform STATIC "${runtime_root}/src/platform/host_platform.cpp")
|
||||
target_include_directories(mkw_platform PUBLIC "${runtime_root}/include")
|
||||
target_compile_features(mkw_platform PUBLIC cxx_std_17)
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(mkw_platform PUBLIC shell32 ole32)
|
||||
endif()
|
||||
mkw_add_test(mkw_platform_paths_tests platform_paths_tests.cpp)
|
||||
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
||||
|
||||
mkw_add_test(mkw_nand_save_tests nand_save_tests.cpp)
|
||||
mkw_add_test(mkw_nand_move_tests nand_move_tests.cpp
|
||||
"${runtime_root}/src/hle/storage/nand_file_ops.cpp")
|
||||
target_include_directories(mkw_nand_move_tests PRIVATE "${runtime_root}/src/hle/storage")
|
||||
mkw_add_test(mkw_nand_settings_tests nand_settings_tests.cpp)
|
||||
mkw_add_test(mkw_sc_serial_tests sc_serial_tests.cpp)
|
||||
mkw_add_test(mkw_input_expr_tests test_expr.cpp "${runtime_root}/src/input_expr.cpp")
|
||||
target_compile_definitions(mkw_input_expr_tests PRIVATE MKW_INPUT_EXPR_TEST_CLOCK)
|
||||
|
||||
if(WIN32)
|
||||
mkw_add_test(mkw_windows_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp")
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
if(NOT TARGET mkw::libco)
|
||||
add_library(mkw_libco STATIC "${runtime_root}/third_party/libco/libco.c")
|
||||
add_library(mkw::libco ALIAS mkw_libco)
|
||||
target_include_directories(mkw_libco PUBLIC "${runtime_root}/third_party/libco")
|
||||
endif()
|
||||
mkw_add_test(mkw_linux_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp")
|
||||
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
|
||||
elseif(APPLE)
|
||||
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64|aarch64)$")
|
||||
message(FATAL_ERROR "The macOS runtime tests require Apple Silicon")
|
||||
endif()
|
||||
enable_language(ASM)
|
||||
set(context_asm "${runtime_root}/src/platform/macos/co_switch.S")
|
||||
mkw_add_test(mkw_macos_context_abi_tests macos_context_abi_tests.cpp "${context_asm}")
|
||||
mkw_add_test(mkw_macos_host_context_tests host_context_tests.cpp
|
||||
"${runtime_root}/src/host_context.cpp" "${context_asm}")
|
||||
mkw_add_test(mkw_macos_guest_flat_memory_tests macos_guest_flat_memory_tests.cpp
|
||||
"${runtime_root}/src/guest_flat_memory_macos.cpp")
|
||||
endif()
|
||||
@@ -1,181 +0,0 @@
|
||||
#include "nand_file_ops.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <csignal>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static void Require(bool condition, const char* message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static void Write(const fs::path& path, const std::string& data) {
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output << data;
|
||||
output.close();
|
||||
Require(bool(output), "Fixture write failed");
|
||||
}
|
||||
|
||||
static std::string Read(const fs::path& path) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
Require(bool(input), "Fixture read failed");
|
||||
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
// Competing moves must have exactly one winner, retain the losing source, and
|
||||
// publish the winner's complete contents. Exercise both direct and staged moves.
|
||||
static void CheckCompetingMoves(const fs::path& sourceRoot, const fs::path& destinationRoot) {
|
||||
const auto left = sourceRoot / "race-left";
|
||||
const auto right = sourceRoot / "race-right";
|
||||
const auto target = destinationRoot / "race-target";
|
||||
const std::string leftBytes(65536, 'L'), rightBytes(65536, 'R');
|
||||
for (int attempt = 0; attempt < 128; ++attempt) {
|
||||
Write(left, leftBytes);
|
||||
Write(right, rightBytes);
|
||||
std::atomic<int> ready{0};
|
||||
std::error_code leftError, rightError;
|
||||
auto move = [&](const fs::path& source, std::error_code& ec) {
|
||||
++ready;
|
||||
while (ready.load() != 2) std::this_thread::yield();
|
||||
NandMove(source, target, ec);
|
||||
};
|
||||
std::thread first(move, std::cref(left), std::ref(leftError));
|
||||
std::thread second(move, std::cref(right), std::ref(rightError));
|
||||
first.join();
|
||||
second.join();
|
||||
Require(bool(leftError) != bool(rightError), "Competing moves must have exactly one winner");
|
||||
const bool leftWon = !leftError;
|
||||
Require((leftWon ? rightError : leftError) == std::errc::file_exists,
|
||||
"Losing move must report an existing destination");
|
||||
Require(Read(target) == (leftWon ? leftBytes : rightBytes), "Winner's contents were overwritten");
|
||||
Require(!fs::exists(leftWon ? left : right), "Winning source was not removed");
|
||||
Require(Read(leftWon ? right : left) == (leftWon ? rightBytes : leftBytes),
|
||||
"Losing source must remain intact");
|
||||
fs::remove(leftWon ? right : left);
|
||||
fs::remove(target);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const auto name = "wiicomp-nand-move-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
const auto root = fs::temp_directory_path() / name;
|
||||
fs::path other;
|
||||
try {
|
||||
fs::create_directory(root);
|
||||
const auto source = root / "banner.bin";
|
||||
const auto destination = root / "moved.bin";
|
||||
std::error_code ec;
|
||||
Write(source, "banner");
|
||||
NandMove(source, destination, ec);
|
||||
Require(!ec && !fs::exists(source) && Read(destination) == "banner", "Same-device move failed");
|
||||
CheckCompetingMoves(root, root);
|
||||
Write(source, "keep source");
|
||||
NandMove(source, destination, ec);
|
||||
Require(ec == std::errc::file_exists && Read(source) == "keep source" && Read(destination) == "banner",
|
||||
"Existing destination must not be overwritten");
|
||||
NandMove(root / "missing", root / "absent", ec);
|
||||
Require(bool(ec) && !fs::exists(root / "absent"), "Missing source must fail");
|
||||
NandMove(source, root / "missing-parent/file", ec);
|
||||
Require(bool(ec) && Read(source) == "keep source", "Missing parent must preserve source");
|
||||
fs::create_directory(root / "directory");
|
||||
Write(root / "directory/child", "child");
|
||||
NandMove(root / "directory", root / "renamed-directory", ec);
|
||||
Require(!ec && Read(root / "renamed-directory/child") == "child", "Same-device directory move regressed");
|
||||
|
||||
#ifndef _WIN32
|
||||
fs::create_symlink(root / "missing", root / "same-link");
|
||||
NandMove(root / "same-link", root / "moved-link", ec);
|
||||
Require(!ec && fs::is_symlink(fs::symlink_status(root / "moved-link")) &&
|
||||
!fs::is_symlink(fs::symlink_status(root / "same-link")), "Same-device symlink move regressed");
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
// /dev/shm is a separate tmpfs on ordinary Linux systems, including CI
|
||||
// and WSL. Fail rather than silently passing without exercising EXDEV.
|
||||
other = fs::path("/dev/shm") / name;
|
||||
fs::create_directory(other);
|
||||
struct stat left{}, right{};
|
||||
Require(::stat(root.c_str(), &left) == 0 && ::stat(other.c_str(), &right) == 0 && left.st_dev != right.st_dev,
|
||||
"Cross-device test requires /tmp and /dev/shm on separate filesystems");
|
||||
CheckCompetingMoves(root, other);
|
||||
Require(fs::is_empty(other), "Competing moves left staging files behind");
|
||||
const auto target = other / "banner.bin";
|
||||
const std::string bytes = std::string(8192, '\0') + "banner payload";
|
||||
Write(source, bytes);
|
||||
fs::rename(source, target, ec);
|
||||
Require(ec == std::errc::cross_device_link, "Fixture must reproduce the original EXDEV failure");
|
||||
NandMove(source, target, ec);
|
||||
Require(!ec && !fs::exists(source) && Read(target) == bytes, "Cross-device move must preserve every byte");
|
||||
|
||||
Write(source, "do not overwrite");
|
||||
NandMove(source, target, ec);
|
||||
Require(ec == std::errc::file_exists && Read(source) == "do not overwrite" && Read(target) == bytes,
|
||||
"Cross-device move must preserve an existing destination");
|
||||
fs::remove(target);
|
||||
fs::create_symlink(other / "missing", target);
|
||||
NandMove(source, target, ec);
|
||||
Require(ec == std::errc::file_exists && fs::is_symlink(target) && Read(source) == "do not overwrite",
|
||||
"Dangling destination symlink must not be replaced");
|
||||
fs::remove(target);
|
||||
|
||||
NandMove(root / "renamed-directory", other / "directory", ec);
|
||||
Require(ec == std::errc::cross_device_link && Read(root / "renamed-directory/child") == "child" &&
|
||||
!fs::exists(other / "directory"), "Unsupported directory move must leave source intact");
|
||||
fs::create_symlink(source, root / "link");
|
||||
NandMove(root / "link", other / "link", ec);
|
||||
Require(ec == std::errc::cross_device_link && fs::is_symlink(root / "link") && !fs::exists(other / "link"),
|
||||
"Cross-device source symlinks must not be dereferenced");
|
||||
|
||||
// Force a real write failure after a partial copy without filling disk.
|
||||
Write(source, bytes);
|
||||
struct rlimit saved{}, limited{};
|
||||
Require(getrlimit(RLIMIT_FSIZE, &saved) == 0, "Cannot read file-size limit");
|
||||
limited = saved;
|
||||
limited.rlim_cur = 1024;
|
||||
const auto oldHandler = std::signal(SIGXFSZ, SIG_IGN);
|
||||
Require(setrlimit(RLIMIT_FSIZE, &limited) == 0, "Cannot set file-size limit");
|
||||
NandMove(source, target, ec);
|
||||
const auto copyError = ec;
|
||||
const auto restored = setrlimit(RLIMIT_FSIZE, &saved);
|
||||
std::signal(SIGXFSZ, oldHandler);
|
||||
Require(restored == 0, "Cannot restore file-size limit");
|
||||
Require(bool(copyError) && Read(source) == bytes && !fs::exists(target) && fs::is_empty(other),
|
||||
"Failed copy must retain source and remove partial staging files");
|
||||
|
||||
Require(geteuid() != 0, "Run permission tests as an unprivileged user");
|
||||
fs::permissions(root, fs::perms::owner_read | fs::perms::owner_exec);
|
||||
NandMove(source, target, ec);
|
||||
fs::permissions(root, fs::perms::owner_all);
|
||||
Require(bool(ec) && Read(source) == bytes && Read(target) == bytes,
|
||||
"Failed source deletion must leave both complete copies");
|
||||
Require(std::distance(fs::directory_iterator(other), fs::directory_iterator{}) == 1,
|
||||
"Move must clean up staging directory");
|
||||
fs::remove_all(other);
|
||||
#endif
|
||||
fs::remove_all(root);
|
||||
std::cout << "NAND move tests passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
std::error_code ignored;
|
||||
fs::permissions(root, fs::perms::owner_all, ignored);
|
||||
fs::remove_all(root, ignored);
|
||||
if (!other.empty()) fs::remove_all(other, ignored);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,7 @@
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#ifndef MKW_INPUT_EXPR_TEST_CLOCK
|
||||
#error "Build this test through CMake so the deterministic clock is enabled"
|
||||
#endif
|
||||
|
||||
static std::chrono::steady_clock::time_point g_now{};
|
||||
namespace InputExpr {
|
||||
std::chrono::steady_clock::time_point TestClockNow() { return g_now; }
|
||||
}
|
||||
#include <thread>
|
||||
|
||||
static int g_failures = 0;
|
||||
static std::map<std::string, double> g_inputs;
|
||||
@@ -48,7 +41,7 @@ static bool Pressed(const InputExpr::Expression& e) {
|
||||
return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
|
||||
}
|
||||
|
||||
static void AdvanceTime(int ms) { g_now += std::chrono::milliseconds(ms); }
|
||||
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
||||
|
||||
int main() {
|
||||
std::printf("Dolphin expression engine\n");
|
||||
@@ -89,7 +82,7 @@ int main() {
|
||||
auto hold = Compile("hold(`H`, 0.05)");
|
||||
g_inputs["H"] = 1.0;
|
||||
Check(!Pressed(hold), "hold not satisfied immediately");
|
||||
AdvanceTime(70);
|
||||
Sleep(70);
|
||||
Check(Pressed(hold), "hold satisfied after the interval");
|
||||
g_inputs["H"] = 0.0;
|
||||
Check(!Pressed(hold), "hold clears on release");
|
||||
@@ -100,7 +93,7 @@ int main() {
|
||||
pulse.Evaluate(Source());
|
||||
g_inputs["P"] = 1.0;
|
||||
Check(Pressed(pulse), "pulse fires on rising edge");
|
||||
AdvanceTime(80);
|
||||
Sleep(80);
|
||||
Check(!Pressed(pulse), "pulse expires");
|
||||
|
||||
// The timing-window idiom seen in shared Dolphin configs.
|
||||
@@ -109,9 +102,9 @@ int main() {
|
||||
window.Evaluate(Source());
|
||||
g_inputs["W"] = 1.0;
|
||||
Check(!Pressed(window), "window closed before its start");
|
||||
AdvanceTime(90);
|
||||
Sleep(90);
|
||||
Check(Pressed(window), "window open between the two pulses");
|
||||
AdvanceTime(90);
|
||||
Sleep(90);
|
||||
Check(!Pressed(window), "window closed after its end");
|
||||
|
||||
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
|
||||
@@ -121,7 +114,7 @@ int main() {
|
||||
int low = 0;
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
(Pressed(timer) ? high : low)++;
|
||||
AdvanceTime(5);
|
||||
Sleep(5);
|
||||
}
|
||||
Check(high > 5 && low > 5, "timer alternates high and low");
|
||||
|
||||
@@ -137,7 +130,7 @@ int main() {
|
||||
high = low = 0;
|
||||
for (int i = 0; i < 60; ++i) {
|
||||
(Pressed(dolphinLine) ? high : low)++;
|
||||
AdvanceTime(2);
|
||||
Sleep(2);
|
||||
}
|
||||
Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
|
||||
|
||||
@@ -199,7 +192,7 @@ int main() {
|
||||
auto sm = Compile("smooth(`A`, 0)");
|
||||
g_inputs["A"] = 1.0;
|
||||
sm.Evaluate(Source());
|
||||
AdvanceTime(5);
|
||||
Sleep(5);
|
||||
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -1891,6 +1891,7 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
|
||||
overlayBuild,
|
||||
continuationPlan,
|
||||
retroWfcResolvedExecutableHooks,
|
||||
patchPlan,
|
||||
kamekFunctionStarts,
|
||||
moduleLinkBase,
|
||||
selected.CodeSize,
|
||||
@@ -2231,6 +2232,7 @@ int EmitModCpp(
|
||||
OverlayBuildResult overlayBuild,
|
||||
ContinuationPlan continuationPlan,
|
||||
IReadOnlyCollection<RetroWfcExecutableHookPlan>? retroWfcExecutableHooks,
|
||||
KamekPatchPlan patchPlan,
|
||||
IReadOnlyList<ModFunctionStart> kamekFunctionStarts,
|
||||
uint moduleLinkBase,
|
||||
uint moduleLinkedCodeSize,
|
||||
@@ -2272,14 +2274,25 @@ int EmitModCpp(
|
||||
.ToHashSet();
|
||||
var queuedContinuationAddresses = continuationPlan.Entries.Select(e => e.Address).ToHashSet();
|
||||
var discoveredContinuationQueue = new Queue<ContinuationEntry>();
|
||||
var linkedHookLrBasesByTarget = retroWfcExecutableHooks is null
|
||||
? new Dictionary<uint, uint[]>()
|
||||
: retroWfcExecutableHooks
|
||||
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
||||
.GroupBy(h => h.TargetAddress!.Value)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(h => h.ContinuationAddress).Distinct().ToArray());
|
||||
var hookLrBases = new List<(uint TargetAddress, uint ContinuationAddress)>();
|
||||
if (retroWfcExecutableHooks is not null)
|
||||
{
|
||||
hookLrBases.AddRange(
|
||||
retroWfcExecutableHooks
|
||||
.Where(h => h.TargetAddress.HasValue && RetroWfcHookSetsLinkRegister(h))
|
||||
.Select(h => (h.TargetAddress!.Value, h.ContinuationAddress)));
|
||||
}
|
||||
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<uint, uint>()
|
||||
@@ -2750,123 +2763,8 @@ IEnumerable<uint> DirectModuleTargets(FunctionTranslationResult result, uint mod
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result)
|
||||
{
|
||||
var lrOffsets = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
int? ctrOffset = null;
|
||||
|
||||
foreach (var instruction in result.Instructions)
|
||||
{
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
if (mnemonic == "mflr" && TryGetInstructionReg(instruction, 0, out var lrDest))
|
||||
{
|
||||
lrOffsets[lrDest] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((mnemonic == "mr" || mnemonic == "or") &&
|
||||
TryGetInstructionReg(instruction, 0, out var moveDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var moveSource) &&
|
||||
(mnemonic == "mr" ||
|
||||
(instruction.Operands.Count >= 3 &&
|
||||
instruction.Operands[2] is PpcRegisterOperand moveSource2 &&
|
||||
string.Equals(NormalizeInstructionReg(moveSource2.Name), moveSource, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
if (lrOffsets.TryGetValue(moveSource, out var sourceOffset))
|
||||
{
|
||||
lrOffsets[moveDest] = sourceOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
lrOffsets.Remove(moveDest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "addi" &&
|
||||
TryGetInstructionReg(instruction, 0, out var addDest) &&
|
||||
TryGetInstructionReg(instruction, 1, out var addBase) &&
|
||||
TryGetInstructionImm(instruction, 2, out var imm))
|
||||
{
|
||||
if (lrOffsets.TryGetValue(addBase, out var baseOffset))
|
||||
{
|
||||
lrOffsets[addDest] = checked(baseOffset + imm);
|
||||
}
|
||||
else
|
||||
{
|
||||
lrOffsets.Remove(addDest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "mtctr" && TryGetInstructionReg(instruction, 0, out var ctrSource))
|
||||
{
|
||||
ctrOffset = lrOffsets.TryGetValue(ctrSource, out var sourceOffset) ? sourceOffset : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mnemonic == "bctr")
|
||||
{
|
||||
if (ctrOffset.HasValue)
|
||||
{
|
||||
yield return ctrOffset.Value;
|
||||
}
|
||||
ctrOffset = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryInstructionWritesDest(instruction, out var dest))
|
||||
{
|
||||
lrOffsets.Remove(dest);
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryGetInstructionReg(PpcInstruction instruction, int index, out string register)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcRegisterOperand operand)
|
||||
{
|
||||
register = NormalizeInstructionReg(operand.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
register = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetInstructionImm(PpcInstruction instruction, int index, out int immediate)
|
||||
{
|
||||
if (instruction.Operands.Count > index && instruction.Operands[index] is PpcImmediateOperand operand)
|
||||
{
|
||||
immediate = operand.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
immediate = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryInstructionWritesDest(PpcInstruction instruction, out string destination)
|
||||
{
|
||||
destination = string.Empty;
|
||||
if (instruction.Operands.Count == 0 || instruction.Operands[0] is not PpcRegisterOperand operand)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mnemonic = instruction.Mnemonic.ToLowerInvariant();
|
||||
if (mnemonic.StartsWith("st", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("b", StringComparison.Ordinal) ||
|
||||
mnemonic.StartsWith("cmp", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = NormalizeInstructionReg(operand.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
static string NormalizeInstructionReg(string register) => register.ToLowerInvariant();
|
||||
}
|
||||
IEnumerable<int> DiscoverLrRelativeIndirectJumpOffsets(FunctionTranslationResult result) =>
|
||||
ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(result.Instructions);
|
||||
|
||||
static bool RetroWfcHookSetsLinkRegister(RetroWfcExecutableHookPlan hook) =>
|
||||
hook.TypeName is "call" or "branchCtrLink" ||
|
||||
|
||||
@@ -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<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.Linq;
|
||||
using Translator.Core.Disassembly;
|
||||
using Translator.Core.Mods;
|
||||
using Translator.Core.Mods.Mkwii;
|
||||
using Translator.Core.Parsing.Kamek;
|
||||
@@ -128,6 +130,54 @@ public class ContinuationPlannerTests
|
||||
Assert.Contains("Retro WFC executable hook continuation", entry.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_DiscoversSkipReturnOffset()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x3BFF0014u), // addi r31, r31, 20
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x7FE803A6u), // mtlr r31
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x4E800020u), // blr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
|
||||
var offset = Assert.Single(offsets);
|
||||
Assert.Equal(20, offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_IgnoresStandardLrRestore()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x93E10008u), // stw r31, 8(r1)
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x83E10008u), // lwz r31, 8(r1)
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x7FE803A6u), // mtlr r31
|
||||
PpcDecoder.Decode(0x8180D8F8, 0x4E800020u), // blr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions);
|
||||
Assert.Empty(offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverLrRelativeIndirectJumpOffsets_SupportsBctrOffset()
|
||||
{
|
||||
var instructions = new[]
|
||||
{
|
||||
PpcDecoder.Decode(0x8180D8E8, 0x7FE802A6u), // mflr r31
|
||||
PpcDecoder.Decode(0x8180D8EC, 0x397F0008u), // addi r11, r31, 8
|
||||
PpcDecoder.Decode(0x8180D8F0, 0x7D6903A6u), // mtctr r11
|
||||
PpcDecoder.Decode(0x8180D8F4, 0x4E800420u), // bctr
|
||||
};
|
||||
|
||||
var offsets = ContinuationPlanner.DiscoverLrRelativeIndirectJumpOffsets(instructions).ToArray();
|
||||
var offset = Assert.Single(offsets);
|
||||
Assert.Equal(8, offset);
|
||||
}
|
||||
|
||||
private static KamekChunk EmptyChunk() =>
|
||||
new(
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,39 @@ public class RetroWfcPayloadLoweringTests
|
||||
Assert.Equal("moduleFunction", pointer.TargetKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionPayloadValidatesAndTranslatesEverySupportedPatch()
|
||||
{
|
||||
var payloadRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"TestAssets",
|
||||
"RetroWfcPayload");
|
||||
WiiCompiled.Setup.Common.RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(payloadRoot);
|
||||
var payloadPath = Path.Combine(
|
||||
payloadRoot,
|
||||
"binary",
|
||||
"payload.RMCPD00.bin");
|
||||
var payload = File.ReadAllBytes(payloadPath);
|
||||
|
||||
var result = RetroWfcPayload.Parse(
|
||||
payload,
|
||||
ProductionPayloadManifest(),
|
||||
0x81800000u,
|
||||
0x00200000u,
|
||||
"TestAssets/RetroWfcPayload/binary/payload.RMCPD00.bin");
|
||||
|
||||
Assert.Equal("RMCPD00", result.Summary.Game);
|
||||
Assert.Equal(payload.Length, result.Summary.PayloadImageSize);
|
||||
Assert.True(result.LoweringPlan.IsPlannable);
|
||||
Assert.Empty(result.LoweringPlan.Issues);
|
||||
Assert.NotEmpty(result.LoweringPlan.StaticBytePatches);
|
||||
Assert.NotEmpty(result.LoweringPlan.ExecutableHooks);
|
||||
Assert.NotEmpty(result.LoweringPlan.StaticPointers);
|
||||
Assert.All(result.LoweringPlan.ExecutableHooks, hook => Assert.NotNull(hook.TargetAddress));
|
||||
Assert.All(result.LoweringPlan.StaticPointers, pointer => Assert.NotNull(pointer.TargetAddress));
|
||||
Assert.NotEmpty(result.Summary.InitializationCallbacks);
|
||||
}
|
||||
|
||||
private static BaseManifest TestManifest() =>
|
||||
new(
|
||||
"test",
|
||||
@@ -52,6 +85,51 @@ public class RetroWfcPayloadLoweringTests
|
||||
],
|
||||
"ranges.json");
|
||||
|
||||
// The payload parser needs the base image's address classes and containing
|
||||
// function ranges to prove every patch can be lowered. A single synthetic
|
||||
// executable and writable ranges are sufficient here: the assertions above
|
||||
// test the real production payload without checking proprietary game bytes
|
||||
// into CI. The split also proves pointer patches lower as data writes.
|
||||
private static BaseManifest ProductionPayloadManifest() =>
|
||||
new(
|
||||
"test",
|
||||
1,
|
||||
"RMCP01",
|
||||
"P",
|
||||
"",
|
||||
0,
|
||||
[
|
||||
new BaseSectionMetadata(
|
||||
".synthetic-text",
|
||||
"synthetic.dol",
|
||||
0x80000000u,
|
||||
0x80800000u,
|
||||
true,
|
||||
false,
|
||||
"synthetic_text.bin",
|
||||
0),
|
||||
new BaseSectionMetadata(
|
||||
".synthetic-data",
|
||||
"synthetic.dol",
|
||||
0x80800000u,
|
||||
0x81000000u,
|
||||
false,
|
||||
true,
|
||||
"synthetic_data.bin",
|
||||
0)
|
||||
],
|
||||
[
|
||||
new BaseFunctionRangeMetadata(
|
||||
0x80000000u,
|
||||
0x80800000u,
|
||||
"synthetic_base",
|
||||
".synthetic-text",
|
||||
0,
|
||||
"test",
|
||||
["Executable"])
|
||||
],
|
||||
"ranges.json");
|
||||
|
||||
private static byte[] BuildSharedPayloadFixture()
|
||||
{
|
||||
var payload = new byte[0x240];
|
||||
|
||||
BIN
Binary file not shown.
@@ -26,6 +26,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Translator.Core\Translator.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Translator.Cli\Translator.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\Launcher\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="TestAssets\RetroWfcPayload\binary\payload.RMCPD00.bin" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user