From be153e0fa0cddd27511dcbca81ae4e5863225ef9 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:07:05 -0400 Subject: [PATCH] Add Dolphin-compatible input expressions and GCPadNew.ini import, DualSense L/R remapping, vibration toggle (#89) * Add Dolphin-compatible input expressions and GCPadNew.ini import Rebased onto current main; addresses both CodeRabbit reviews on #89. - Expression engine matching Dolphin's semantics: doubles rather than booleans, 0.5 press threshold, & as min, | as max, and the functions if, min, max, clamp, abs, sqrt, pow, sin, cos, tan, deadzone, timer, toggle, hold, tap, pulse and smooth. Timing uses a steady clock in seconds, as Dolphin does, so a copied expression behaves identically. - Expressions bind to the GameCube buttons and triggers, combined with the existing button mapping rather than replacing it, and are skipped while the settings overlay holds input. - Import reads [GCPadN] from the Dolphin config directory or from GCPadNew.ini beside the executable. Stick axes are not expression driven and keep their normal mapping. - Fixes #74: a digital button bound to L or R now reports a fully pulled analog trigger, plus a PlayStation preset and a vibration toggle. Review fixes: config paths round-trip through RuntimeConfigFile::PathToUtf8 and PathFromUtf8 so non-ASCII paths open correctly on Windows, and the duplicated exists branch is gone; the tap count is clamped before the unsigned conversion; the expression editor uses resizable storage via ImGuiInputTextFlags_CallbackResize so a long expression cannot be saved truncated; clamp bounds are ordered before std::clamp; is included for std::strtod; non-finite values are rejected at the evaluator boundary as well as at the deadzone and timer divisions; and InputBindings::Reload() runs from InitializeRuntimeSettings rather than the vibration handler. runtime/tests/test_expr.cpp covers operator precedence, each stateful function and every case raised in review. Third review round: smooth() guards NaN as well as infinity so a zero rate cannot latch a non-finite value in node state; division evaluates both operands so stateful functions in the left subtree still update when the divisor is zero; the expression editor clears stale errors when the port changes; and runtime/tests/test_expr.cpp is registered with CTest as mkw_input_expr_tests, following the existing test targets. * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update runtime/src/input_expr.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.md | 14 +- runtime/CMakeLists.txt | 9 + runtime/include/controller_button_names.h | 159 ++++++ runtime/include/input_bindings.h | 67 +++ runtime/include/input_expr.h | 53 ++ runtime/include/runtime_config.h | 33 ++ runtime/src/hle/input/pad.cpp | 68 +++ runtime/src/input_bindings.cpp | 302 +++++++++++ runtime/src/input_expr.cpp | 631 ++++++++++++++++++++++ runtime/src/settings_overlay.cpp | 225 +++++--- runtime/tests/test_expr.cpp | 219 ++++++++ 11 files changed, 1693 insertions(+), 87 deletions(-) create mode 100644 runtime/include/controller_button_names.h create mode 100644 runtime/include/input_bindings.h create mode 100644 runtime/include/input_expr.h create mode 100644 runtime/src/input_bindings.cpp create mode 100644 runtime/src/input_expr.cpp create mode 100644 runtime/tests/test_expr.cpp diff --git a/README.md b/README.md index 3201692..7317b86 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ Press **F10** while the game window has focus: - Internal resolution - FPS counter - Controller assignment for all four ports -- Full per-controller button mapping +- Full per-controller button mapping, including the bumpers +- Dolphin-syntax input expressions and GCPadNew.ini import +- Controller vibration on/off - Volume, instant mute, and the music ducking toggle Everything you change is saved to `Config.toml` on the spot and restored next launch. @@ -56,6 +58,16 @@ Controllers are fed to the game as a GameCube controller. Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra inputs like paddles, touchpads and share buttons show up when the hardware reports them. + +**Dolphin-compatible input expressions.** +Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators +(`!` `&` `|` `^`) and the same functions (`if`, `min`, `max`, `clamp`, `timer`, `toggle`, `hold`, +`tap`, `pulse`, `smooth`, `deadzone`), evaluated against the same wall-clock timing. A Dolphin +`GCPadNew.ini` can be imported directly from the F10 bar. Stick axes are not covered by expressions +and keep their normal mapping. + +**Vibration toggle.** +Force feedback can be turned off for every port at once. The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the adapter must be switched to the WinUSB driver once (Zadig). diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6fd79e2..be057c7 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -277,6 +277,15 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +# The input expression engine is self-contained, so it can be exercised without +# linking the runtime or SDL. +add_executable(mkw_input_expr_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp") +target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17) +add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests) + # HostContext deliberately keeps the platform-specific context primitive out # of fiber_manager.cpp. Exercise the Linux libco handoff directly so future # refactors cannot silently remove its headers, implementation, or link edge. diff --git a/runtime/include/controller_button_names.h b/runtime/include/controller_button_names.h new file mode 100644 index 0000000..d0d2b81 --- /dev/null +++ b/runtime/include/controller_button_names.h @@ -0,0 +1,159 @@ +#pragma once + +// The single vocabulary shared by everything that has to turn a Config.toml +// controller name into a real button: the F10 settings bar, the macro engine, +// and the startup mapping pass. Keeping one table here means a name that the +// settings bar offers is always a name the config parser accepts, and vice +// versa; the two used to drift because each side carried its own copy. + +#include +#include +#include +#include +#include + +#include +#include + +namespace ControllerNames { + +// A GameCube button as the game sees it, with the Config.toml key that selects +// it. Order matches RuntimeConfigFile::kControllerButtonKeys. +struct GameCubeButtonItem { + const char* configKey; + const char* label; + PADButton padButton; +}; + +inline constexpr std::array kGameCubeButtons = {{ + {"a", "A", PAD_BUTTON_A}, + {"b", "B", PAD_BUTTON_B}, + {"x", "X", PAD_BUTTON_X}, + {"y", "Y", PAD_BUTTON_Y}, + {"start", "Start", PAD_BUTTON_START}, + {"z", "Z", PAD_TRIGGER_Z}, + {"l", "L", PAD_TRIGGER_L}, + {"r", "R", PAD_TRIGGER_R}, + {"up", "D-pad Up", PAD_BUTTON_UP}, + {"down", "D-pad Down", PAD_BUTTON_DOWN}, + {"left", "D-pad Left", PAD_BUTTON_LEFT}, + {"right", "D-pad Right", PAD_BUTTON_RIGHT}, +}}; + +// A physical button on the host pad. Names are positional (south/east/...) +// rather than Xbox-labelled so one config reads the same on any hardware. +struct NativeButtonItem { + const char* configName; + const char* label; + uint32_t nativeButton; +}; + +inline constexpr std::array kNativeButtons = {{ + {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID}, + {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH}, + {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST}, + {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST}, + {"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH}, + {"back", "Back / Select / Create", SDL_GAMEPAD_BUTTON_BACK}, + {"guide", "Guide / Home / PS", SDL_GAMEPAD_BUTTON_GUIDE}, + {"start", "Start / Options", SDL_GAMEPAD_BUTTON_START}, + {"left_stick", "Left stick click (L3)", SDL_GAMEPAD_BUTTON_LEFT_STICK}, + {"right_stick", "Right stick click (R3)", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + {"left_shoulder", "Left bumper (LB / L1)", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {"right_shoulder", "Right bumper (RB / R1)", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP}, + {"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + {"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + {"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {"misc1", "Misc 1 / Share / Mic", SDL_GAMEPAD_BUTTON_MISC1}, + {"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1}, + {"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1}, + {"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2}, + {"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2}, + {"touchpad", "Touchpad click", SDL_GAMEPAD_BUTTON_TOUCHPAD}, + {"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2}, + {"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3}, + {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4}, + {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5}, + {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6}, +}}; + +inline std::string TrimToken(std::string_view token) { + const size_t begin = token.find_first_not_of(" \t"); + if (begin == std::string_view::npos) { + return {}; + } + const size_t end = token.find_last_not_of(" \t"); + return std::string(token.substr(begin, end - begin + 1)); +} + +inline const NativeButtonItem* FindNativeButton(std::string_view configName) { + const std::string name = TrimToken(configName); + const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), + [&](const NativeButtonItem& item) { return name == item.configName; }); + return it == kNativeButtons.end() ? nullptr : &*it; +} + +// Falls back to the "unmapped" entry so callers always have a label to draw. +inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { + const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), + [&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; }); + return it == kNativeButtons.end() ? kNativeButtons.front() : *it; +} + +inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) { + const std::string key = TrimToken(configKey); + const auto it = std::find_if(kGameCubeButtons.begin(), kGameCubeButtons.end(), + [&](const GameCubeButtonItem& item) { return key == item.configKey; }); + return it == kGameCubeButtons.end() ? nullptr : &*it; +} + +// "up" or "up,a" -> the OR of those GC button bits. Unknown names are skipped so +// a typo costs one button instead of the whole macro. +inline uint16_t GameCubeMaskFromKeys(std::string_view keys) { + uint16_t mask = 0; + size_t begin = 0; + while (begin <= keys.size()) { + const size_t comma = keys.find(',', begin); + const std::string_view token = + keys.substr(begin, comma == std::string_view::npos ? std::string_view::npos : comma - begin); + if (const GameCubeButtonItem* item = FindGameCubeButton(token)) { + mask |= static_cast(item->padButton); + } + if (comma == std::string_view::npos) { + break; + } + begin = comma + 1; + } + return mask; +} + +inline std::string GameCubeKeysFromMask(uint16_t mask) { + std::string keys; + for (const auto& item : kGameCubeButtons) { + if ((mask & static_cast(item.padButton)) == 0) { + continue; + } + if (!keys.empty()) { + keys += ','; + } + keys += item.configKey; + } + return keys; +} + +inline std::string GameCubeLabelsFromMask(uint16_t mask) { + std::string labels; + for (const auto& item : kGameCubeButtons) { + if ((mask & static_cast(item.padButton)) == 0) { + continue; + } + if (!labels.empty()) { + labels += " + "; + } + labels += item.label; + } + return labels.empty() ? std::string("None") : labels; +} + +} // namespace ControllerNames diff --git a/runtime/include/input_bindings.h b/runtime/include/input_bindings.h new file mode 100644 index 0000000..37d664b3 --- /dev/null +++ b/runtime/include/input_bindings.h @@ -0,0 +1,67 @@ +#pragma once + +// Per-port expression bindings for the GameCube controls, plus import of a +// Dolphin GCPadNew.ini. + +#include +#include +#include + +#include + +namespace InputBindings { + +// The controls an expression can drive, in Dolphin's own naming so an +// imported config maps across without translation. +struct ControlInfo { + const char* dolphinName; + const char* label; + uint16_t padButton; // 0 for the analog-only controls below + int analog; // 0 none, 1 trigger L, 2 trigger R +}; + +inline constexpr std::array kControls = {{ + {"Buttons/A", "A", PAD_BUTTON_A, 0}, + {"Buttons/B", "B", PAD_BUTTON_B, 0}, + {"Buttons/X", "X", PAD_BUTTON_X, 0}, + {"Buttons/Y", "Y", PAD_BUTTON_Y, 0}, + {"Buttons/Z", "Z", PAD_TRIGGER_Z, 0}, + {"Buttons/Start", "Start", PAD_BUTTON_START, 0}, + {"D-Pad/Up", "D-pad Up", PAD_BUTTON_UP, 0}, + {"D-Pad/Down", "D-pad Down", PAD_BUTTON_DOWN, 0}, + {"D-Pad/Left", "D-pad Left", PAD_BUTTON_LEFT, 0}, + {"D-Pad/Right", "D-pad Right", PAD_BUTTON_RIGHT, 0}, + {"Triggers/L", "L", PAD_TRIGGER_L, 1}, + {"Triggers/R", "R", PAD_TRIGGER_R, 2}, + {"Triggers/L-Analog", "L analog", 0, 1}, + {"Triggers/R-Analog", "R analog", 0, 2}, +}}; + +void Reload() noexcept; + +// The pad library has PADBlockInput but no matching query, so the settings +// overlay reports its own state here. +void SetInputBlocked(bool blocked) noexcept; +bool InputBlocked() noexcept; + +// Mix expression output into a freshly read status set. Call once per guest +// PADRead, after every other input source has been merged. +void Apply(PADStatus* statuses) noexcept; + +std::string GetExpression(uint32_t port, size_t control) noexcept; +// Returns false and fills error if the text does not parse; the binding is +// left unchanged in that case. +bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept; + +// True while the control's expression is above the press threshold. +bool IsActive(uint32_t port, size_t control) noexcept; + +// The default Dolphin config location on Windows, then next to the executable. +std::string DefaultDolphinConfigPath() noexcept; + +// Imports [GCPad] into the given port. Returns the number of controls +// imported, or -1 on failure with error filled. +int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, + std::string& summary, std::string& error) noexcept; + +} // namespace InputBindings diff --git a/runtime/include/input_expr.h b/runtime/include/input_expr.h new file mode 100644 index 0000000..8fbb149 --- /dev/null +++ b/runtime/include/input_expr.h @@ -0,0 +1,53 @@ +#pragma once + +// Dolphin-compatible input expressions. +// +// Values are doubles in Dolphin's ControlState style; a control counts as +// pressed above kConditionThreshold. Timing matches Dolphin: wall-clock +// seconds on a steady clock, so an expression copied from GCPadNew.ini +// behaves the same here as it does there. + +#include +#include +#include +#include +#include + +namespace InputExpr { + +inline constexpr double kConditionThreshold = 0.5; + +// Resolves a backtick-quoted input name to its current value. +using InputSource = std::function; + +struct Node; + +class Expression { +public: + Expression(); + ~Expression(); + Expression(Expression&&) noexcept; + Expression& operator=(Expression&&) noexcept; + + // Returns false and fills error on a syntax problem. + static bool Parse(const std::string& text, Expression& out, std::string& error); + + bool Empty() const { return m_root == nullptr; } + double Evaluate(const InputSource& source) const; + + // Input names the expression references, for diagnostics. + std::vector ReferencedInputs() const; + +private: + std::unique_ptr m_root; +}; + +// Parses a Dolphin GCPadNew.ini and returns the expression text for each +// control of the requested pad, keyed by Dolphin's own control names +// ("Buttons/A", "D-Pad/Up", "Triggers/L", ...). Returns false if the file +// cannot be read or the section is missing. +bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex, + std::vector>& controls, + std::string& deviceName, std::string& error); + +} // namespace InputExpr diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 7606677..c8cdf80 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +90,8 @@ struct RuntimeUserConfig { // comma-separated SDL-style physical button names ("south", or // "dpad_up,left_shoulder") as values; pressing either bound button counts. std::array, 12> controllerButtons; + std::optional rumbleEnabled; + std::map controllerExpressions; }; namespace RuntimeConfigFile { @@ -407,6 +410,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { FindConfigValue(document, "controller", buttonKeys[index]); } + config.rumbleEnabled = FindConfigValue(document, "controller", "rumble"); + + if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr; + section != nullptr && section->is_table()) { + for (const auto& [key, value] : section->as_table()) { + if (key.rfind("expr_", 0) == 0 && value.is_string()) { + config.controllerExpressions[key] = value.as_string(); + } + } + } + config.widescreen = FindConfigValue(document, "video", "widescreen"); config.windowPosX = FindConfigInt(document, "video", "window_x"); config.windowPosY = FindConfigInt(document, "video", "window_y"); @@ -677,6 +691,25 @@ inline bool SetControllerButton(size_t index, std::string value) { return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value)); } +inline std::string ControllerExpression(const std::string& key) { + const auto it = Get().controllerExpressions.find(key); + return it == Get().controllerExpressions.end() ? std::string() : it->second; +} + +inline bool SetControllerExpression(const std::string& key, const std::string& value) { + Mutable().controllerExpressions[key] = value; + return WriteSetting("controller", key, FormatString(value)); +} + +inline bool RumbleEnabled(bool fallback = true) { + return Get().rumbleEnabled.value_or(fallback); +} + +inline bool SetRumbleEnabled(bool value) { + Mutable().rumbleEnabled = value; + return WriteSetting("controller", "rumble", value ? "true" : "false"); +} + inline bool SetAudioVolume(float value) { value = std::clamp(value, 0.0f, 1.0f); Mutable().audioVolume = value; diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 1939a84..23ff800 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -1,18 +1,75 @@ #include "hle_stubs.h" #include "memory.h" #include "hle/controller_status_contract.h" +#include "input_bindings.h" #include "wii_remote_input.h" #include +#include #include #include #include #include +#include #include namespace { +std::atomic g_rumbleEnabled{true}; + +bool NativeButtonHeld(SDL_Gamepad* gamepad, uint32_t nativeButton) { + if (gamepad == nullptr || nativeButton == PAD_NATIVE_BUTTON_INVALID || + nativeButton >= SDL_GAMEPAD_BUTTON_COUNT) { + return false; + } + return SDL_GetGamepadButton(gamepad, static_cast(nativeButton)); +} + +// A digital button bound to L or R has no analog travel of its own. On real +// hardware the click only engages at full depression, so report a full pull. +void FillTriggersHeldByButtons(PADStatus* statuses) { + if (InputBindings::InputBlocked()) { + return; + } + for (uint32_t port = 0; port < PAD_CHANMAX; ++port) { + if (statuses[port].err != PAD_ERR_NONE) { + continue; + } + const s32 index = PADGetIndexForPort(port); + if (index < 0) { + continue; + } + SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast(index)); + if (gamepad == nullptr) { + continue; + } + const auto scan = [&](PADButtonMapping* mappings, u32 count) { + if (mappings == nullptr) { + return; + } + for (u32 i = 0; i < count; ++i) { + const PADButtonMapping& mapping = mappings[i]; + if (mapping.padButton != PAD_TRIGGER_L && mapping.padButton != PAD_TRIGGER_R) { + continue; + } + if (!NativeButtonHeld(gamepad, mapping.nativeButton)) { + continue; + } + if (mapping.padButton == PAD_TRIGGER_L) { + statuses[port].triggerLeft = 255; + } else { + statuses[port].triggerRight = 255; + } + } + }; + u32 count = 0; + scan(PADGetButtonMappings(port, &count), count); + count = 0; + scan(PADGetAltButtonMappings(port, &count), count); + } +} + void WritePadStatus(uint32_t base, const PADStatus& status) { const auto guestStatus = PadStatusContract::Encode({ status.button, @@ -32,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) { } // namespace +extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled) +{ + g_rumbleEnabled.store(enabled, std::memory_order_relaxed); +} + extern "C" uint32_t PAD__Init_HLE() { return PADInit() ? 1u : 0u; @@ -54,6 +116,9 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) // between "connected" and "no controller" every time the overlay toggles. WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX); + FillTriggersHeldByButtons(statuses); + InputBindings::Apply(statuses); + try { for (uint32_t i = 0; i < PAD_CHANMAX; ++i) { WritePadStatus(statusPtr + static_cast(i * PadStatusContract::kGuestStatusSize), @@ -81,6 +146,9 @@ PPC_NATIVE_OVERRIDE(801AF1E4, PAD__Recalibrate_HLE, uint32_t, (uint32_t mask), ( extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command) { + if (command == PAD_MOTOR_RUMBLE && !g_rumbleEnabled.load(std::memory_order_relaxed)) { + command = PAD_MOTOR_STOP; + } PADControlMotor(chan, command); } PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command)); diff --git a/runtime/src/input_bindings.cpp b/runtime/src/input_bindings.cpp new file mode 100644 index 0000000..6ffa984 --- /dev/null +++ b/runtime/src/input_bindings.cpp @@ -0,0 +1,302 @@ +#include "input_bindings.h" + +#include "controller_button_names.h" +#include "input_expr.h" +#include "runtime_config.h" +#include "runtime_log.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace InputBindings { +namespace { + +struct Binding { + std::string text; + InputExpr::Expression expr; + bool active = false; +}; + +std::mutex g_mutex; +std::array, PAD_CHANMAX> g_bindings; +bool g_anyBound = false; +bool g_inputBlocked = false; + +// Dolphin input names, mapped onto SDL. XInput-style names are exact; DInput +// "Button " indices follow the common PlayStation layout, which is what +// DInput reports for a DualShock/DualSense. Other pads may number differently. +const std::unordered_map& ButtonNames() { + static const std::unordered_map table = { + {"Button A", SDL_GAMEPAD_BUTTON_SOUTH}, {"Button B", SDL_GAMEPAD_BUTTON_EAST}, + {"Button X", SDL_GAMEPAD_BUTTON_WEST}, {"Button Y", SDL_GAMEPAD_BUTTON_NORTH}, + {"Shoulder L", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {"Shoulder R", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {"Thumb L", SDL_GAMEPAD_BUTTON_LEFT_STICK}, {"Thumb R", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + {"Start", SDL_GAMEPAD_BUTTON_START}, {"Back", SDL_GAMEPAD_BUTTON_BACK}, + {"Guide", SDL_GAMEPAD_BUTTON_GUIDE}, + {"Pad N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Pad S", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + {"Pad W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Pad E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {"Hat 0 N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Hat 0 S", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + {"Hat 0 W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Hat 0 E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {"Button 0", SDL_GAMEPAD_BUTTON_WEST}, {"Button 1", SDL_GAMEPAD_BUTTON_SOUTH}, + {"Button 2", SDL_GAMEPAD_BUTTON_EAST}, {"Button 3", SDL_GAMEPAD_BUTTON_NORTH}, + {"Button 4", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {"Button 5", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {"Button 8", SDL_GAMEPAD_BUTTON_BACK}, {"Button 9", SDL_GAMEPAD_BUTTON_START}, + {"Button 10", SDL_GAMEPAD_BUTTON_LEFT_STICK}, + {"Button 11", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + {"Button 12", SDL_GAMEPAD_BUTTON_GUIDE}, {"Button 13", SDL_GAMEPAD_BUTTON_TOUCHPAD}, + }; + return table; +} + +// Signed axis names: SDL axis plus the direction that counts as positive. +struct AxisRef { + SDL_GamepadAxis axis; + int sign; +}; + +const std::unordered_map& AxisNames() { + static const std::unordered_map table = { + {"Axis X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Axis X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}}, + {"Axis Y-", {SDL_GAMEPAD_AXIS_LEFTY, -1}}, {"Axis Y+", {SDL_GAMEPAD_AXIS_LEFTY, 1}}, + {"Axis Z-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}}, {"Axis Z+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}}, + {"Axis Zr-", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},{"Axis Zr+", {SDL_GAMEPAD_AXIS_RIGHTY, 1}}, + {"Left X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Left X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}}, + {"Left Y-", {SDL_GAMEPAD_AXIS_LEFTY, 1}}, {"Left Y+", {SDL_GAMEPAD_AXIS_LEFTY, -1}}, + {"Right X-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}},{"Right X+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}}, + {"Right Y-", {SDL_GAMEPAD_AXIS_RIGHTY, 1}}, {"Right Y+", {SDL_GAMEPAD_AXIS_RIGHTY, -1}}, + {"Full Axis Xr+", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}}, + {"Full Axis Yr+", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}}, + {"Trigger L", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}}, + {"Trigger R", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}}, + }; + return table; +} + +double ReadInput(SDL_Gamepad* gamepad, const std::string& name) { + if (gamepad == nullptr) { + return 0.0; + } + if (const auto it = ButtonNames().find(name); it != ButtonNames().end()) { + return SDL_GetGamepadButton(gamepad, it->second) ? 1.0 : 0.0; + } + if (const auto it = AxisNames().find(name); it != AxisNames().end()) { + const double raw = SDL_GetGamepadAxis(gamepad, it->second.axis) / 32767.0; + return std::clamp(raw * it->second.sign, 0.0, 1.0); + } + // Fall back to this project's own positional names, so a binding written + // here does not have to use Dolphin vocabulary. + if (const auto* native = ControllerNames::FindNativeButton(name)) { + if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) { + return SDL_GetGamepadButton(gamepad, static_cast(native->nativeButton)) ? 1.0 + : 0.0; + } + } + return 0.0; +} + +SDL_Gamepad* GamepadForPort(uint32_t port) { + const s32 index = PADGetIndexForPort(port); + return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast(index)); +} + +size_t ControlIndexForDolphinName(const std::string& name) { + for (size_t i = 0; i < kControls.size(); ++i) { + if (name == kControls[i].dolphinName) { + return i; + } + } + return kControls.size(); +} + +void RecomputeAnyBoundLocked() { + g_anyBound = false; + for (const auto& port : g_bindings) { + for (const auto& binding : port) { + if (!binding.expr.Empty()) { + g_anyBound = true; + return; + } + } + } +} + +std::string ConfigKey(uint32_t port, size_t control) { + std::string key = "expr_" + std::to_string(port + 1) + "_"; + for (const char* c = kControls[control].dolphinName; *c != '\0'; ++c) { + key += (*c == '/' || *c == '-') ? '_' : static_cast(std::tolower(*c)); + } + return key; +} + +} // namespace + +void SetInputBlocked(bool blocked) noexcept { + std::lock_guard lock(g_mutex); + g_inputBlocked = blocked; +} + +bool InputBlocked() noexcept { + std::lock_guard lock(g_mutex); + return g_inputBlocked; +} + +void Reload() noexcept { + std::lock_guard lock(g_mutex); + for (uint32_t port = 0; port < PAD_CHANMAX; ++port) { + for (size_t control = 0; control < kControls.size(); ++control) { + Binding& binding = g_bindings[port][control]; + binding = Binding{}; + binding.text = RuntimeConfigFile::ControllerExpression(ConfigKey(port, control)); + std::string error; + if (!binding.text.empty() && + !InputExpr::Expression::Parse(binding.text, binding.expr, error)) { + RT_LOG(RT_TAG_CONFIG) << "expression for port " << (port + 1) << " " + << kControls[control].dolphinName << ": " << error << std::endl; + } + } + } + RecomputeAnyBoundLocked(); +} + +void Apply(PADStatus* statuses) noexcept { + if (statuses == nullptr) { + return; + } + std::lock_guard lock(g_mutex); + if (!g_anyBound) { + return; + } + const bool blocked = g_inputBlocked; + for (uint32_t port = 0; port < PAD_CHANMAX; ++port) { + if (statuses[port].err != PAD_ERR_NONE) { + continue; + } + SDL_Gamepad* gamepad = GamepadForPort(port); + const InputExpr::InputSource source = [gamepad](const std::string& name) { + return ReadInput(gamepad, name); + }; + for (size_t control = 0; control < kControls.size(); ++control) { + Binding& binding = g_bindings[port][control]; + if (binding.expr.Empty()) { + continue; + } + if (blocked) { + binding.active = false; + continue; + } + const double value = binding.expr.Evaluate(source); + binding.active = value > InputExpr::kConditionThreshold; + const ControlInfo& info = kControls[control]; + if (info.padButton != 0 && binding.active) { + statuses[port].button |= info.padButton; + } + if (info.analog != 0) { + const double safe = std::isfinite(value) ? std::clamp(value, 0.0, 1.0) : 0.0; + const auto scaled = static_cast(safe * 255.0); + uint8_t& target = + info.analog == 1 ? statuses[port].triggerLeft : statuses[port].triggerRight; + target = std::max(target, scaled); + } + } + } +} + +std::string GetExpression(uint32_t port, size_t control) noexcept { + if (port >= PAD_CHANMAX || control >= kControls.size()) { + return {}; + } + std::lock_guard lock(g_mutex); + return g_bindings[port][control].text; +} + +bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept { + if (port >= PAD_CHANMAX || control >= kControls.size()) { + error = "invalid control"; + return false; + } + InputExpr::Expression parsed; + if (!InputExpr::Expression::Parse(text, parsed, error)) { + return false; + } + { + std::lock_guard lock(g_mutex); + Binding& binding = g_bindings[port][control]; + binding.text = text; + binding.expr = std::move(parsed); + binding.active = false; + RecomputeAnyBoundLocked(); + } + RuntimeConfigFile::SetControllerExpression(ConfigKey(port, control), text); + return true; +} + +bool IsActive(uint32_t port, size_t control) noexcept { + if (port >= PAD_CHANMAX || control >= kControls.size()) { + return false; + } + std::lock_guard lock(g_mutex); + return g_bindings[port][control].active; +} + +std::string DefaultDolphinConfigPath() noexcept { + std::error_code ec; + if (const char* appdata = std::getenv("APPDATA"); appdata != nullptr) { + const std::filesystem::path roaming = + std::filesystem::path(appdata) / "Dolphin Emulator" / "Config" / "GCPadNew.ini"; + if (std::filesystem::exists(roaming, ec)) { + return RuntimeConfigFile::PathToUtf8(roaming); + } + } + const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory(); + return RuntimeConfigFile::PathToUtf8(executableDirectory ? *executableDirectory / "GCPadNew.ini" + : std::filesystem::path("GCPadNew.ini")); +} + +int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, std::string& summary, + std::string& error) noexcept { + std::vector> controls; + std::string device; + if (!InputExpr::ReadDolphinConfig(RuntimeConfigFile::PathFromUtf8(path), padIndex, controls, device, + error)) { + return -1; + } + + int imported = 0; + std::vector skipped; + for (const auto& [name, text] : controls) { + const size_t control = ControlIndexForDolphinName(name); + if (control == kControls.size()) { + if (name.rfind("Main Stick/", 0) == 0 || name.rfind("C-Stick/", 0) == 0) { + skipped.push_back(name); + } + continue; + } + std::string parseError; + if (!SetExpression(port, control, text, parseError)) { + skipped.push_back(name); + RT_LOG(RT_TAG_CONFIG) << "import " << name << ": " << parseError << std::endl; + continue; + } + ++imported; + } + + summary = "Imported " + std::to_string(imported) + " controls"; + if (!device.empty()) { + summary += " from " + device; + } + if (!skipped.empty()) { + summary += "; skipped " + std::to_string(skipped.size()) + + " (stick axes and unsupported inputs keep their existing mapping)"; + } + return imported; +} + +} // namespace InputBindings diff --git a/runtime/src/input_expr.cpp b/runtime/src/input_expr.cpp new file mode 100644 index 0000000..d9c73c3 --- /dev/null +++ b/runtime/src/input_expr.cpp @@ -0,0 +1,631 @@ +#include "input_expr.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace InputExpr { +namespace { + +using Clock = std::chrono::steady_clock; +using FSec = std::chrono::duration; + +enum class Kind { + Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor, + Greater, Less, Equal, + FnIf, FnMin, FnMax, FnClamp, FnAbs, FnSqrt, FnPow, FnSin, FnCos, FnTan, + FnDeadzone, FnTimer, FnToggle, FnHold, FnTap, FnPulse, FnSmooth, FnNot, +}; + +struct FnInfo { + Kind kind; + int minArgs; + int maxArgs; +}; + +const std::unordered_map& FunctionTable() { + static const std::unordered_map table = { + {"not", {Kind::FnNot, 1, 1}}, {"if", {Kind::FnIf, 3, 3}}, + {"min", {Kind::FnMin, 2, 2}}, {"max", {Kind::FnMax, 2, 2}}, + {"clamp", {Kind::FnClamp, 3, 3}}, {"abs", {Kind::FnAbs, 1, 1}}, + {"sqrt", {Kind::FnSqrt, 1, 1}}, {"pow", {Kind::FnPow, 2, 2}}, + {"sin", {Kind::FnSin, 1, 1}}, {"cos", {Kind::FnCos, 1, 1}}, + {"tan", {Kind::FnTan, 1, 1}}, {"deadzone", {Kind::FnDeadzone, 2, 2}}, + {"timer", {Kind::FnTimer, 1, 1}}, {"toggle", {Kind::FnToggle, 1, 2}}, + {"hold", {Kind::FnHold, 2, 2}}, {"tap", {Kind::FnTap, 2, 3}}, + {"pulse", {Kind::FnPulse, 2, 2}}, {"smooth", {Kind::FnSmooth, 2, 3}}, + }; + return table; +} + +} // namespace + +struct Node { + Kind kind; + double literal = 0.0; + std::string input; + std::vector> args; + + // Per-instance state for the stateful functions. Mutable because Evaluate + // is logically a read of current input state. + mutable bool released = false; + mutable bool state = false; + mutable unsigned taps = 0; + mutable double value = 0.0; + mutable Clock::time_point mark = Clock::now(); + mutable bool marked = false; +}; + +namespace { + +// ---- tokenizer ---------------------------------------------------------- + +struct Token { + enum Type { End, Input, Number, Ident, Op, LParen, RParen, Comma } type = End; + std::string text; +}; + +class Lexer { +public: + explicit Lexer(const std::string& text) : m_text(text) {} + + bool Next(Token& tok, std::string& error) { + while (m_pos < m_text.size() && std::isspace(static_cast(m_text[m_pos]))) { + ++m_pos; + } + if (m_pos >= m_text.size()) { + tok = Token{}; + return true; + } + const char c = m_text[m_pos]; + if (c == '`') { + const size_t close = m_text.find('`', m_pos + 1); + if (close == std::string::npos) { + error = "unterminated ` in expression"; + return false; + } + tok.type = Token::Input; + tok.text = m_text.substr(m_pos + 1, close - m_pos - 1); + m_pos = close + 1; + return true; + } + if (std::isdigit(static_cast(c)) || c == '.') { + size_t end = m_pos; + while (end < m_text.size() && + (std::isdigit(static_cast(m_text[end])) || m_text[end] == '.')) { + ++end; + } + tok.type = Token::Number; + tok.text = m_text.substr(m_pos, end - m_pos); + m_pos = end; + return true; + } + if (std::isalpha(static_cast(c)) || c == '_') { + size_t end = m_pos; + while (end < m_text.size() && + (std::isalnum(static_cast(m_text[end])) || m_text[end] == '_' || + m_text[end] == ' ')) { + ++end; + } + // Trailing spaces belong to the separator, not the identifier. + while (end > m_pos && m_text[end - 1] == ' ') { + --end; + } + tok.type = Token::Ident; + tok.text = m_text.substr(m_pos, end - m_pos); + m_pos = end; + return true; + } + if (c == '(') { tok.type = Token::LParen; ++m_pos; return true; } + if (c == ')') { tok.type = Token::RParen; ++m_pos; return true; } + if (c == ',') { tok.type = Token::Comma; ++m_pos; return true; } + if (std::string("!&|^+-*/><=").find(c) != std::string::npos) { + tok.type = Token::Op; + tok.text = std::string(1, c); + ++m_pos; + return true; + } + error = std::string("unexpected character '") + c + "' in expression"; + return false; + } + + size_t Position() const { return m_pos; } + +private: + const std::string& m_text; + size_t m_pos = 0; +}; + +// ---- parser ------------------------------------------------------------- + +using NodePtr = std::unique_ptr; + +class Parser { +public: + explicit Parser(const std::string& text) : m_lexer(text) { Advance(); } + + NodePtr ParseExpression(std::string& error) { + NodePtr node = ParseBinary(0, error); + if (!node) { + return nullptr; + } + if (m_failed) { + error = m_lexError; + return nullptr; + } + if (m_tok.type != Token::End) { + error = "unexpected trailing input in expression"; + return nullptr; + } + return node; + } + +private: + void Advance() { + if (!m_lexer.Next(m_tok, m_lexError)) { + m_tok = Token{}; + m_failed = true; + } + } + + static int Precedence(const std::string& op) { + if (op == "|") return 1; + if (op == "^") return 2; + if (op == "&") return 3; + if (op == ">" || op == "<" || op == "=") return 4; + if (op == "+" || op == "-") return 5; + if (op == "*" || op == "/") return 6; + return -1; + } + + static Kind BinaryKind(const std::string& op) { + if (op == "|") return Kind::Or; + if (op == "^") return Kind::Xor; + if (op == "&") return Kind::And; + if (op == ">") return Kind::Greater; + if (op == "<") return Kind::Less; + if (op == "=") return Kind::Equal; + if (op == "+") return Kind::Add; + if (op == "-") return Kind::Sub; + if (op == "*") return Kind::Mul; + return Kind::Div; + } + + NodePtr ParseBinary(int minPrec, std::string& error) { + NodePtr lhs = ParseUnary(error); + if (!lhs) { + return nullptr; + } + while (m_tok.type == Token::Op) { + const int prec = Precedence(m_tok.text); + if (prec < 0 || prec < minPrec) { + break; + } + const std::string op = m_tok.text; + Advance(); + NodePtr rhs = ParseBinary(prec + 1, error); + if (!rhs) { + return nullptr; + } + auto node = std::make_unique(); + node->kind = BinaryKind(op); + node->args.push_back(std::move(lhs)); + node->args.push_back(std::move(rhs)); + lhs = std::move(node); + } + return lhs; + } + + NodePtr ParseUnary(std::string& error) { + if (m_failed) { + error = m_lexError; + return nullptr; + } + if (m_tok.type == Token::Op && (m_tok.text == "!" || m_tok.text == "-" || m_tok.text == "+")) { + const std::string op = m_tok.text; + Advance(); + NodePtr inner = ParseUnary(error); + if (!inner) { + return nullptr; + } + if (op == "+") { + return inner; + } + auto node = std::make_unique(); + if (op == "!") { + node->kind = Kind::Not; + node->args.push_back(std::move(inner)); + } else { + node->kind = Kind::Sub; + auto zero = std::make_unique(); + zero->kind = Kind::Literal; + node->args.push_back(std::move(zero)); + node->args.push_back(std::move(inner)); + } + return node; + } + return ParsePrimary(error); + } + + NodePtr ParsePrimary(std::string& error) { + if (m_failed) { + error = m_lexError; + return nullptr; + } + switch (m_tok.type) { + case Token::Input: { + auto node = std::make_unique(); + node->kind = Kind::Input; + node->input = m_tok.text; + Advance(); + return node; + } + case Token::Number: { + auto node = std::make_unique(); + node->kind = Kind::Literal; + node->literal = std::strtod(m_tok.text.c_str(), nullptr); + Advance(); + return node; + } + case Token::LParen: { + Advance(); + NodePtr inner = ParseBinary(0, error); + if (!inner) { + return nullptr; + } + if (m_tok.type != Token::RParen) { + error = "expected closing paren"; + return nullptr; + } + Advance(); + return inner; + } + case Token::Ident: { + const std::string name = m_tok.text; + Advance(); + if (m_tok.type != Token::LParen) { + // A bare identifier is an input name, as Dolphin allows for + // simple cases such as "Start" or "LSHIFT". + auto node = std::make_unique(); + node->kind = Kind::Input; + node->input = name; + return node; + } + const auto it = FunctionTable().find(name); + if (it == FunctionTable().end()) { + error = "unknown function '" + name + "'"; + return nullptr; + } + Advance(); + auto node = std::make_unique(); + node->kind = it->second.kind; + if (m_tok.type != Token::RParen) { + while (true) { + NodePtr arg = ParseBinary(0, error); + if (!arg) { + return nullptr; + } + node->args.push_back(std::move(arg)); + if (m_tok.type != Token::Comma) { + break; + } + Advance(); + } + } + if (m_tok.type != Token::RParen) { + error = "expected closing paren after " + name + " arguments"; + return nullptr; + } + Advance(); + const int count = static_cast(node->args.size()); + if (count < it->second.minArgs || count > it->second.maxArgs) { + error = name + " takes " + std::to_string(it->second.minArgs) + " to " + + std::to_string(it->second.maxArgs) + " arguments"; + return nullptr; + } + return node; + } + default: + error = "expected start of expression"; + return nullptr; + } + } + + Lexer m_lexer; + Token m_tok; + std::string m_lexError; + bool m_failed = false; +}; + +// ---- evaluator ---------------------------------------------------------- + +double Eval(const Node& node, const InputSource& source); + +double Arg(const Node& node, size_t index, const InputSource& source) { + return Eval(*node.args[index], source); +} + +double Eval(const Node& node, const InputSource& source) { + switch (node.kind) { + case Kind::Literal: return node.literal; + case Kind::Input: return source ? source(node.input) : 0.0; + case Kind::Not: + case Kind::FnNot: return 1.0 - Arg(node, 0, source); + case Kind::Add: return Arg(node, 0, source) + Arg(node, 1, source); + case Kind::Sub: return Arg(node, 0, source) - Arg(node, 1, source); + case Kind::Mul: return Arg(node, 0, source) * Arg(node, 1, source); + case Kind::Div: { + // Both sides are evaluated even when the divisor is zero: the left + // subtree may hold stateful functions that need their frame update. + const double lhs = Arg(node, 0, source); + const double rhs = Arg(node, 1, source); + return rhs == 0.0 ? 0.0 : lhs / rhs; + } + case Kind::And: return std::min(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::Or: return std::max(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::Xor: { + const double a = Arg(node, 0, source); + const double b = Arg(node, 1, source); + return std::max(std::min(a, 1.0 - b), std::min(b, 1.0 - a)); + } + case Kind::Greater: return Arg(node, 0, source) > Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::Less: return Arg(node, 0, source) < Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::Equal: return Arg(node, 0, source) == Arg(node, 1, source) ? 1.0 : 0.0; + case Kind::FnIf: + return Arg(node, 0, source) > kConditionThreshold ? Arg(node, 1, source) : Arg(node, 2, source); + case Kind::FnMin: return std::min(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnMax: return std::max(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnClamp: { + const double v = Arg(node, 0, source); + double lo = Arg(node, 1, source); + double hi = Arg(node, 2, source); + if (lo > hi) { + std::swap(lo, hi); + } + return std::clamp(v, lo, hi); + } + case Kind::FnAbs: return std::abs(Arg(node, 0, source)); + case Kind::FnSqrt: return std::sqrt(Arg(node, 0, source)); + case Kind::FnPow: return std::pow(Arg(node, 0, source), Arg(node, 1, source)); + case Kind::FnSin: return std::sin(Arg(node, 0, source)); + case Kind::FnCos: return std::cos(Arg(node, 0, source)); + case Kind::FnTan: return std::tan(Arg(node, 0, source)); + case Kind::FnDeadzone: { + const double v = Arg(node, 0, source); + const double dz = std::clamp(Arg(node, 1, source), 0.0, 0.999); + return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v); + } + case Kind::FnTimer: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double period = Arg(node, 0, source); + double progress = std::chrono::duration_cast(now - node.mark).count() / period; + if (!std::isfinite(progress) || progress < 0.0) { + progress = 0.0; + node.mark = now; + } else if (progress >= 1.0) { + const double resets = std::floor(progress); + node.mark += std::chrono::duration_cast(FSec(period * resets)); + progress -= resets; + } + return progress; + } + case Kind::FnToggle: { + const double inner = Arg(node, 0, source); + if (inner < kConditionThreshold) { + node.released = true; + } else if (node.released) { + node.released = false; + node.state = !node.state; + } + if (node.args.size() == 2 && Arg(node, 1, source) > kConditionThreshold) { + node.state = false; + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnHold: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double input = Arg(node, 0, source); + if (input < kConditionThreshold) { + node.state = false; + node.mark = now; + } else if (!node.state) { + if (std::chrono::duration_cast(now - node.mark).count() >= Arg(node, 1, source)) { + node.state = true; + } + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnTap: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double elapsed = std::chrono::duration_cast(now - node.mark).count(); + const double input = Arg(node, 0, source); + const bool timeUp = elapsed > Arg(node, 1, source); + // The count is user authored, so a negative or huge value must not + // reach the unsigned conversion. + double requested = node.args.size() == 3 ? Arg(node, 2, source) : 2.0; + if (!std::isfinite(requested)) { + requested = 2.0; + } + const auto desired = static_cast(std::clamp(requested + 0.5, 1.0, 64.0)); + if (input < kConditionThreshold) { + node.released = true; + if (node.taps > 0 && timeUp) { + node.taps = 0; + } + return 0.0; + } + if (node.released) { + if (node.taps == 0) { + node.mark = now; + } + ++node.taps; + node.released = false; + } + return desired == node.taps ? 1.0 : 0.0; + } + case Kind::FnPulse: { + const auto now = Clock::now(); + const double input = Arg(node, 0, source); + if (input < kConditionThreshold) { + node.released = true; + } else if (node.released) { + node.released = false; + const double requested = Arg(node, 1, source); + const double safe = std::isfinite(requested) ? std::clamp(requested, 0.0, 3600.0) : 0.0; + const auto seconds = std::chrono::duration_cast(FSec(safe)); + if (node.state) { + node.mark += seconds; + } else { + node.state = true; + node.mark = now + seconds; + } + } + if (node.state && now >= node.mark) { + node.state = false; + } + return node.state ? 1.0 : 0.0; + } + case Kind::FnSmooth: { + const auto now = Clock::now(); + if (!node.marked) { + node.mark = now; + node.marked = true; + } + const double elapsed = std::chrono::duration_cast(now - node.mark).count(); + node.mark = now; + const double desired = Arg(node, 0, source); + const double up = Arg(node, 1, source); + const double down = node.args.size() == 3 ? Arg(node, 2, source) : up; + const double rate = (desired < node.value) ? down : up; + const double maxMove = elapsed / rate; + if (!std::isfinite(maxMove)) { + node.value = desired; + } else { + const double diff = desired - node.value; + node.value += std::copysign(std::min(maxMove, std::abs(diff)), diff); + } + return node.value; + } + } + return 0.0; +} + +void Collect(const Node& node, std::vector& out) { + if (node.kind == Kind::Input) { + if (std::find(out.begin(), out.end(), node.input) == out.end()) { + out.push_back(node.input); + } + } + for (const auto& arg : node.args) { + Collect(*arg, out); + } +} + +std::string Trim(const std::string& text) { + const size_t begin = text.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) { + return {}; + } + return text.substr(begin, text.find_last_not_of(" \t\r\n") - begin + 1); +} + +} // namespace + +Expression::Expression() = default; +Expression::~Expression() = default; +Expression::Expression(Expression&&) noexcept = default; +Expression& Expression::operator=(Expression&&) noexcept = default; + +bool Expression::Parse(const std::string& text, Expression& out, std::string& error) { + out.m_root.reset(); + if (Trim(text).empty()) { + return true; + } + Parser parser(text); + NodePtr root = parser.ParseExpression(error); + if (!root) { + return false; + } + out.m_root = std::move(root); + return true; +} + +double Expression::Evaluate(const InputSource& source) const { + if (m_root == nullptr) { + return 0.0; + } + const double value = Eval(*m_root, source); + return std::isfinite(value) ? value : 0.0; +} + +std::vector Expression::ReferencedInputs() const { + std::vector out; + if (m_root) { + Collect(*m_root, out); + } + return out; +} + +bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex, + std::vector>& controls, + std::string& deviceName, std::string& error) { + std::ifstream file(path); + if (!file) { + error = "could not open " + path.string(); + return false; + } + const std::string wanted = "[GCPad" + std::to_string(padIndex) + "]"; + bool inSection = false; + bool found = false; + std::string line; + controls.clear(); + deviceName.clear(); + while (std::getline(file, line)) { + const std::string trimmed = Trim(line); + if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') { + continue; + } + if (trimmed.front() == '[') { + inSection = trimmed == wanted; + found = found || inSection; + continue; + } + if (!inSection) { + continue; + } + const size_t eq = trimmed.find('='); + if (eq == std::string::npos) { + continue; + } + const std::string key = Trim(trimmed.substr(0, eq)); + const std::string value = Trim(trimmed.substr(eq + 1)); + if (key == "Device") { + deviceName = value; + } else if (!value.empty()) { + controls.emplace_back(key, value); + } + } + if (!found) { + error = wanted + " not found in " + path.string(); + return false; + } + return true; +} + +} // namespace InputExpr diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index f2e753f..0429be6 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -1,6 +1,8 @@ #include "settings_overlay.h" #include "audio_backend.h" +#include "controller_button_names.h" #include "controller_mapping_wizard.h" +#include "input_bindings.h" #include "game_graphics_options.h" #include "music_attenuation.h" #include "runtime_config.h" @@ -34,6 +36,8 @@ #endif #include + +extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled); #include #include #include @@ -65,6 +69,7 @@ const char* GraphicsApiDisplayName() { } bool g_topBarVisible = false; +bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true); int g_controllerPort = 0; float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f); int g_audioVolumePercent = static_cast(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f)); @@ -106,62 +111,9 @@ std::array g_configuredControllerIndices = [] { return indices; }(); -struct ControllerButtonItem { - const char* configKey; - const char* label; - PADButton padButton; -}; - -constexpr std::array kControllerButtons = {{ - {"a", "A", PAD_BUTTON_A}, - {"b", "B", PAD_BUTTON_B}, - {"x", "X", PAD_BUTTON_X}, - {"y", "Y", PAD_BUTTON_Y}, - {"start", "Start", PAD_BUTTON_START}, - {"z", "Z", PAD_TRIGGER_Z}, - {"l", "L", PAD_TRIGGER_L}, - {"r", "R", PAD_TRIGGER_R}, - {"up", "D-pad Up", PAD_BUTTON_UP}, - {"down", "D-pad Down", PAD_BUTTON_DOWN}, - {"left", "D-pad Left", PAD_BUTTON_LEFT}, - {"right", "D-pad Right", PAD_BUTTON_RIGHT}, -}}; - -struct NativeButtonItem { - const char* configName; - const char* label; - uint32_t nativeButton; -}; - -constexpr std::array kNativeButtons = {{ - {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID}, - {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH}, - {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST}, - {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST}, - {"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH}, - {"back", "Back / Select", SDL_GAMEPAD_BUTTON_BACK}, - {"guide", "Guide / Home", SDL_GAMEPAD_BUTTON_GUIDE}, - {"start", "Start / Options", SDL_GAMEPAD_BUTTON_START}, - {"left_stick", "Left stick click", SDL_GAMEPAD_BUTTON_LEFT_STICK}, - {"right_stick", "Right stick click", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, - {"left_shoulder", "Left shoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, - {"right_shoulder", "Right shoulder", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, - {"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP}, - {"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, - {"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, - {"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, - {"misc1", "Misc 1 / Share", SDL_GAMEPAD_BUTTON_MISC1}, - {"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1}, - {"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1}, - {"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2}, - {"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2}, - {"touchpad", "Touchpad", SDL_GAMEPAD_BUTTON_TOUCHPAD}, - {"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2}, - {"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3}, - {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4}, - {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5}, - {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6}, -}}; +using ControllerNames::kNativeButtons; +using ControllerNames::NativeButtonItem; +constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons; // Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style // diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC @@ -178,6 +130,13 @@ constexpr std::array kClassicProPreset = { "dpad_up", "dpad_down", "dpad_left", "dpad_right", }; +// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share. +constexpr std::array kPlayStationPreset = { + "south", "east", "west", "north", "start", "back", + "left_shoulder", "right_shoulder", + "dpad_up", "dpad_down", "dpad_left", "dpad_right", +}; + struct ResolutionItem { const char* label; float scale; @@ -225,43 +184,25 @@ void LimitResolutionForFrameRate() { } } -const NativeButtonItem* FindNativeButton(std::string value) { - const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) { - return value == item.configName; - }); - return it == kNativeButtons.end() ? nullptr : &*it; -} +using ControllerNames::FindNativeButton; struct ControllerBindingPair { std::string primary; std::string secondary; }; -std::string TrimBindingToken(const std::string& token) { - const size_t begin = token.find_first_not_of(" \t"); - if (begin == std::string::npos) { - return {}; - } - const size_t end = token.find_last_not_of(" \t"); - return token.substr(begin, end - begin + 1); -} // Config values hold up to two comma-separated button names ("dpad_up" or // "dpad_up,left_shoulder"); pressing either one counts as the GC button. ControllerBindingPair SplitControllerBinding(const std::string& value) { const size_t comma = value.find(','); if (comma == std::string::npos) { - return {TrimBindingToken(value), {}}; + return {ControllerNames::TrimToken(value), {}}; } - return {TrimBindingToken(value.substr(0, comma)), TrimBindingToken(value.substr(comma + 1))}; + return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))}; } -const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { - const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) { - return nativeButton == item.nativeButton; - }); - return it == kNativeButtons.end() ? kNativeButtons.front() : *it; -} +using ControllerNames::NativeButtonForValue; void SetTopBarVisible(bool visible) { if (g_topBarVisible == visible) { @@ -393,10 +334,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) { } ImGui::EndDisabled(); ImGui::SameLine(); - if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) { + if (WiiRemoteInput::IsScanning()) { ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount()); - } else if (WiiRemoteInput::IsScanning()) { - ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote"); } else { ImGui::TextDisabled("Not scanning"); } @@ -457,6 +396,105 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) { } // Controller settings menu: port selection, controller assignment and button mapping. +int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) { + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* text = static_cast(data->UserData); + text->resize(static_cast(data->BufTextLen)); + data->Buf = text->data(); + } + return 0; +} + +void DrawExpressionSettings() { + ImGui::SeparatorText("Expressions (Dolphin syntax)"); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f); + ImGui::TextDisabled( + "Optional. An expression overrides nothing: its result is combined with the " + "button mapping above. Operators ! & | ^ and functions if, min, max, clamp, " + "timer, toggle, hold, tap, pulse, smooth, deadzone behave as they do in Dolphin."); + ImGui::PopTextWrapPos(); + + static std::array errors; + static std::array buffers; + static std::string importStatus; + static int loadedPort = -1; + static bool reloadBuffers = true; + const auto port = static_cast(g_controllerPort); + + if (loadedPort != g_controllerPort || reloadBuffers) { + for (size_t i = 0; i < buffers.size(); ++i) { + buffers[i] = InputBindings::GetExpression(port, i); + } + errors.fill(std::string()); + loadedPort = g_controllerPort; + reloadBuffers = false; + } + + if (ImGui::Button("Import from Dolphin")) { + const std::string path = InputBindings::DefaultDolphinConfigPath(); + std::string summary; + std::string error; + if (InputBindings::ImportDolphinConfig(path, g_controllerPort + 1, port, summary, error) < 0) { + importStatus = error; + } else { + importStatus = summary; + errors.fill(std::string()); + reloadBuffers = true; + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Reads [GCPad%d] from %%APPDATA%%\\Dolphin Emulator\\Config\\GCPadNew.ini,\n" + "or GCPadNew.ini next to the executable.", g_controllerPort + 1); + } + if (!importStatus.empty()) { + ImGui::TextDisabled("%s", importStatus.c_str()); + } + + for (size_t i = 0; i < InputBindings::kControls.size(); ++i) { + ImGui::PushID(static_cast(i) + 2000); + std::string& text = buffers[i]; + ImGui::SetNextItemWidth(300.0f); + if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1, + ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize, + ExpressionResizeCallback, &text)) { + std::string error; + errors[i] = InputBindings::SetExpression(port, i, text, error) ? std::string() : error; + } + if (InputBindings::IsActive(port, i)) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "active"); + } + if (!errors[i].empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", errors[i].c_str()); + } + ImGui::PopID(); + } +} + +void DrawRumbleSettings() { + ImGui::SeparatorText("Vibration"); + if (ImGui::Checkbox("Controller vibration", &g_rumbleEnabled)) { + PAD_HLE_SetRumbleEnabled(g_rumbleEnabled); + RuntimeConfigFile::SetRumbleEnabled(g_rumbleEnabled); + if (!g_rumbleEnabled) { + // Stop whatever is already running: the game will not send another + // motor command until its own state machine decides to. + constexpr std::array stopAll{ + PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, + }; + PADControlAllMotors(stopAll.data()); +#if defined(_WIN32) + for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + Wup028Adapter::SetRumble(port, false); + } +#endif + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Applies to every port."); + } +} + void DrawControllerSettings() { for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) { const std::string label = "Port " + std::to_string(port + 1); @@ -545,20 +583,28 @@ void DrawControllerSettings() { PADSerializeMappings(); mappings = PADGetButtonMappings(port, &mappingCount); } - ImGui::SameLine(); - if (ImGui::Button("Classic Controller Pro")) { + const auto applyPreset = [&](const std::array& preset) { const uint32_t port = static_cast(g_controllerPort); for (size_t i = 0; i < kControllerButtons.size(); ++i) { - if (const NativeButtonItem* native = FindNativeButton(kClassicProPreset[i])) { + if (const NativeButtonItem* native = FindNativeButton(preset[i])) { PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton}); PADSetAltButtonMapping(port, PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton}); - RuntimeConfigFile::SetControllerButton(i, kClassicProPreset[i]); + RuntimeConfigFile::SetControllerButton(i, preset[i]); } } altRowExpanded.fill(false); PADSerializeMappings(); mappings = PADGetButtonMappings(port, &mappingCount); + }; + + ImGui::SameLine(); + if (ImGui::Button("Classic Controller Pro")) { + applyPreset(kClassicProPreset); + } + ImGui::SameLine(); + if (ImGui::Button("PlayStation")) { + applyPreset(kPlayStationPreset); } ImGui::SeparatorText("Button mapping"); @@ -640,6 +686,8 @@ void DrawControllerSettings() { ImGui::TextUnformatted(kControllerButtons[i].label); ImGui::PopID(); } + DrawExpressionSettings(); + DrawRumbleSettings(); } void DrawAudioSettings() { @@ -994,6 +1042,8 @@ void PersistDisplayModeIfChanged() { } // namespace void InitializeRuntimeSettings() noexcept { + PAD_HLE_SetRumbleEnabled(g_rumbleEnabled); + InputBindings::Reload(); controller_mapping_wizard::LoadPersistedMappings(); ApplyConfiguredMappings(); AudioBackend::Instance().SetMasterVolume(static_cast(g_audioVolumePercent) / 100.0f); @@ -1014,6 +1064,7 @@ void InitializeRuntimeSettings() noexcept { g_strapInputAccepted.store(false, std::memory_order_relaxed); g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed); PADBlockInput(false); + InputBindings::SetInputBlocked(false); } void HandleEvents(const AuroraEvent* events) noexcept { @@ -1056,7 +1107,9 @@ void Draw() noexcept { DrawTopBar(); controller_mapping_wizard::Draw(); // The wizard captures raw presses; keep them out of the game. - PADBlockInput(controller_mapping_wizard::IsActive()); + const bool inputBlocked = controller_mapping_wizard::IsActive(); + PADBlockInput(inputBlocked); + InputBindings::SetInputBlocked(inputBlocked); DrawStartupScreen(); } diff --git a/runtime/tests/test_expr.cpp b/runtime/tests/test_expr.cpp new file mode 100644 index 0000000..c770a3c --- /dev/null +++ b/runtime/tests/test_expr.cpp @@ -0,0 +1,219 @@ +// Verifies the expression engine against Dolphin's documented semantics, +// including the exact line from the user's GCPadNew.ini. + +#include "input_expr.h" + +#include +#include +#include +#include +#include +#include + +static int g_failures = 0; +static std::map g_inputs; + +static InputExpr::InputSource Source() { + return [](const std::string& name) { + const auto it = g_inputs.find(name); + return it == g_inputs.end() ? 0.0 : it->second; + }; +} + +static void Check(bool ok, const std::string& what) { + if (!ok) { + std::printf(" FAIL: %s\n", what.c_str()); + ++g_failures; + } +} + +static InputExpr::Expression Compile(const std::string& text) { + InputExpr::Expression expr; + std::string error; + if (!InputExpr::Expression::Parse(text, expr, error)) { + std::printf(" FAIL: parse '%s': %s\n", text.c_str(), error.c_str()); + ++g_failures; + } + return expr; +} + +static bool Pressed(const InputExpr::Expression& e) { + return e.Evaluate(Source()) > InputExpr::kConditionThreshold; +} + +static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } + +int main() { + std::printf("Dolphin expression engine\n"); + + // Operators: & is min, | is max, ! is 1-x, matching Dolphin. + g_inputs["A"] = 1.0; + g_inputs["B"] = 0.0; + Check(Pressed(Compile("`A`")), "bare input"); + Check(!Pressed(Compile("!`A`")), "not"); + Check(!Pressed(Compile("`A` & `B`")), "and is min"); + Check(Pressed(Compile("`A` | `B`")), "or is max"); + Check(Pressed(Compile("`A` ^ `B`")), "xor"); + Check(!Pressed(Compile("`A` ^ `A`")), "xor of equal inputs is false"); + + // Precedence: & binds tighter than |, so this is A | (B & A). + g_inputs["B"] = 0.0; + Check(Pressed(Compile("`A` | `B` & `A`")), "& binds tighter than |"); + + // Parens and numeric literals. + Check(Pressed(Compile("(`B` | 1)")), "literal"); + Check(Pressed(Compile("min(1, `A`)")), "min"); + Check(!Pressed(Compile("min(0, `A`)")), "min with zero"); + Check(Pressed(Compile("if(`A`, 1, 0)")), "if"); + Check(Pressed(Compile("clamp(5, 0, 1)")), "clamp"); + + // toggle flips on each rising edge and holds between them. + auto toggle = Compile("toggle(`T`)"); + g_inputs["T"] = 0.0; + toggle.Evaluate(Source()); + g_inputs["T"] = 1.0; + Check(Pressed(toggle), "toggle on after first press"); + g_inputs["T"] = 0.0; + Check(Pressed(toggle), "toggle stays on after release"); + g_inputs["T"] = 1.0; + Check(!Pressed(toggle), "toggle off on second press"); + + // hold requires the input to be down for the full duration. + auto hold = Compile("hold(`H`, 0.05)"); + g_inputs["H"] = 1.0; + Check(!Pressed(hold), "hold not satisfied immediately"); + Sleep(70); + Check(Pressed(hold), "hold satisfied after the interval"); + g_inputs["H"] = 0.0; + Check(!Pressed(hold), "hold clears on release"); + + // pulse fires for the given duration after a rising edge. + auto pulse = Compile("pulse(`P`, 0.05)"); + g_inputs["P"] = 0.0; + pulse.Evaluate(Source()); + g_inputs["P"] = 1.0; + Check(Pressed(pulse), "pulse fires on rising edge"); + Sleep(80); + Check(!Pressed(pulse), "pulse expires"); + + // The timing-window idiom seen in shared Dolphin configs. + auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)"); + g_inputs["W"] = 0.0; + window.Evaluate(Source()); + g_inputs["W"] = 1.0; + Check(!Pressed(window), "window closed before its start"); + Sleep(90); + Check(Pressed(window), "window open between the two pulses"); + Sleep(90); + Check(!Pressed(window), "window closed after its end"); + + // timer ramps 0..1 and wraps, so a threshold turns it into a square wave. + auto timer = Compile("`X` & timer(0.1)"); + g_inputs["X"] = 1.0; + int high = 0; + int low = 0; + for (int i = 0; i < 40; ++i) { + (Pressed(timer) ? high : low)++; + Sleep(5); + } + Check(high > 5 && low > 5, "timer alternates high and low"); + + // The exact D-Pad/Up line from the user's GCPadNew.ini. + auto dolphinLine = Compile("`Hat 0 N` | `Button 4` & timer(0.01)"); + g_inputs["Hat 0 N"] = 0.0; + g_inputs["Button 4"] = 0.0; + Check(!Pressed(dolphinLine), "idle with nothing held"); + g_inputs["Hat 0 N"] = 1.0; + Check(Pressed(dolphinLine), "hat alone presses"); + g_inputs["Hat 0 N"] = 0.0; + g_inputs["Button 4"] = 1.0; + high = low = 0; + for (int i = 0; i < 60; ++i) { + (Pressed(dolphinLine) ? high : low)++; + Sleep(2); + } + Check(high > 5 && low > 5, "LB alternates via timer(0.01)"); + + // Regression tests for the CodeRabbit findings on PR #89. + g_inputs["A"] = 1.0; + // clamp with reversed bounds: std::clamp is UB when lo > hi. + Check(Compile("clamp(0.5, 1, 0)").Evaluate(Source()) == 0.5, "clamp tolerates reversed bounds"); + // deadzone(v, 1) would divide by zero. + { + const double v = Compile("deadzone(`A`, 1)").Evaluate(Source()); + Check(std::isfinite(v), "deadzone with dz=1 stays finite"); + } + // timer with a zero or negative period would produce inf or NaN. + for (const char* text : {"timer(0)", "timer(-1)"}) { + const double v = Compile(text).Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " stays finite"); + } + // Any non-finite result is squashed before it can reach the uint8_t cast. + for (const char* text : {"sqrt(0 - 1)", "pow(10, 10000)", "tan(1.5707963267948966)"}) { + const double v = Compile(text).Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " is sanitised at the boundary"); + } + + // tap count is user authored; negative, huge and non-finite must not reach + // the unsigned conversion. + for (const char* text : {"tap(`A`, 0.2, -1)", "tap(`A`, 0.2, 999999999)", "tap(`A`, 0.2, 0)"}) { + InputExpr::Expression e; + std::string err; + Check(InputExpr::Expression::Parse(text, e, err), std::string("parse ") + text); + const double v = e.Evaluate(Source()); + Check(std::isfinite(v), std::string(text) + " evaluates without UB"); + } + + // Exponent notation is not part of the number syntax, matching Dolphin's + // lexer; it is rejected rather than silently misparsed. + { + InputExpr::Expression e; + std::string err; + Check(!InputExpr::Expression::Parse("tap(`A`, 0.2, 1e30)", e, err), "exponent notation rejected"); + } + + // A zero divisor must not skip the left subtree: stateful functions there + // still need their per-frame update. + { + auto divToggle = Compile("toggle(`D`) / `Z`"); + g_inputs["Z"] = 0.0; + g_inputs["D"] = 0.0; + divToggle.Evaluate(Source()); + g_inputs["D"] = 1.0; + divToggle.Evaluate(Source()); // rising edge seen even though rhs is 0 + g_inputs["D"] = 0.0; + g_inputs["Z"] = 1.0; + Check(divToggle.Evaluate(Source()) > InputExpr::kConditionThreshold, + "toggle still latched while the divisor was zero"); + } + + // smooth with a zero rate divides 0 by 0; NaN must not stick in the node. + { + auto sm = Compile("smooth(`A`, 0)"); + g_inputs["A"] = 1.0; + sm.Evaluate(Source()); + Sleep(5); + Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite"); + } + + // Referenced inputs, used for diagnostics in the UI. + const auto refs = dolphinLine.ReferencedInputs(); + Check(refs.size() == 2, "two referenced inputs"); + + // Errors are reported, not silently swallowed. + InputExpr::Expression bad; + std::string error; + Check(!InputExpr::Expression::Parse("`A` & ", bad, error), "trailing operator rejected"); + Check(!InputExpr::Expression::Parse("nope(1)", bad, error), "unknown function rejected"); + Check(!InputExpr::Expression::Parse("(`A`", bad, error), "missing paren rejected"); + Check(!InputExpr::Expression::Parse("`A", bad, error), "unterminated backtick rejected"); + Check(InputExpr::Expression::Parse("", bad, error) && bad.Empty(), "empty parses to empty"); + Check(!InputExpr::Expression::Parse("hold(`A`)", bad, error), "wrong arg count rejected"); + + if (g_failures == 0) { + std::printf("all checks passed\n"); + return 0; + } + std::printf("%d check(s) failed\n", g_failures); + return 1; +}