From 42e035d016b01cad39299413e6cb4b3bee490241 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:47:20 +0100 Subject: [PATCH 1/6] add shake to trick --- runtime/cmake/PlatformTests.cmake | 7 + runtime/include/motion_trick.h | 35 ++++ runtime/include/motion_trick_detector.h | 44 +++++ runtime/include/runtime_config.h | 25 +++ runtime/src/hle/input/pad.cpp | 2 + runtime/src/motion_trick.cpp | 220 ++++++++++++++++++++++++ runtime/src/motion_trick_detector.cpp | 71 ++++++++ runtime/src/settings_overlay.cpp | 65 +++++++ runtime/tests/motion_trick_tests.cpp | 209 ++++++++++++++++++++++ runtime/tests/runtime_config_tests.cpp | 13 ++ 10 files changed, 691 insertions(+) create mode 100644 runtime/include/motion_trick.h create mode 100644 runtime/include/motion_trick_detector.h create mode 100644 runtime/src/motion_trick.cpp create mode 100644 runtime/src/motion_trick_detector.cpp create mode 100644 runtime/tests/motion_trick_tests.cpp diff --git a/runtime/cmake/PlatformTests.cmake b/runtime/cmake/PlatformTests.cmake index a1a073ffa..26dd3d2fa 100644 --- a/runtime/cmake/PlatformTests.cmake +++ b/runtime/cmake/PlatformTests.cmake @@ -63,6 +63,13 @@ target_include_directories(mkw_input_expr_tests PRIVATE "${MKW_TEST_RUNTIME_DIR} target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17) add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests) +add_executable(mkw_motion_trick_tests + "${MKW_TEST_RUNTIME_DIR}/tests/motion_trick_tests.cpp" + "${MKW_TEST_RUNTIME_DIR}/src/motion_trick_detector.cpp") +target_include_directories(mkw_motion_trick_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}/include") +target_compile_features(mkw_motion_trick_tests PRIVATE cxx_std_17) +add_test(NAME mkw_motion_trick_tests COMMAND mkw_motion_trick_tests) + # The emulated Bluetooth controller is pure host code. add_executable(mkw_bluetooth_controller_tests "${MKW_TEST_RUNTIME_DIR}/tests/bluetooth_controller_tests.cpp" diff --git a/runtime/include/motion_trick.h b/runtime/include/motion_trick.h new file mode 100644 index 000000000..8b1985ef0 --- /dev/null +++ b/runtime/include/motion_trick.h @@ -0,0 +1,35 @@ +#pragma once + +// Shake to trick: a flick of a controller's motion sensor briefly presses the +// matching D-pad direction, which the game treats as a trick (or a wheelie on +// bikes), like shaking a Wii Remote. + +#include + +#include + +#include "motion_trick_detector.h" + +namespace MotionTrick { + +bool Enabled() noexcept; +void SetEnabled(bool enabled) noexcept; +int Sensitivity() noexcept; +void SetSensitivity(int level) noexcept; + +// The controller on this port reports a gyro or accelerometer of its own. +bool PortHasMotion(uint32_t port) noexcept; + +// Call once per guest PADRead, after every other input source has been merged. +void Apply(PADStatus* statuses) noexcept; + +struct PortStatus { + float intensity = 0.0f; // decaying peak, 1 being the trigger threshold + Direction lastTrick = Direction::None; + uint64_t lastTrickUs = 0; +}; +PortStatus Status(uint32_t port) noexcept; + +uint64_t NowUs() noexcept; + +} // namespace MotionTrick diff --git a/runtime/include/motion_trick_detector.h b/runtime/include/motion_trick_detector.h new file mode 100644 index 000000000..deb97fa3b --- /dev/null +++ b/runtime/include/motion_trick_detector.h @@ -0,0 +1,44 @@ +#pragma once + +// Shake detection for controllers with motion sensors. Pure host code, so it is +// unit-testable without SDL. + +#include + +namespace MotionTrick { + +enum class Direction : uint8_t { None, Up, Down, Left, Right }; + +inline constexpr int kMinSensitivity = 1; +inline constexpr int kMaxSensitivity = 5; +inline constexpr int kDefaultSensitivity = 3; + +// SDL gamepad sensor axes: x right, y out of the face, z toward the player. +struct MotionSample { + bool hasGyro = false; + bool hasAccel = false; + float gyro[3]{}; // rad/s: pitch, yaw, roll + float accel[3]{}; // m/s^2, gravity included +}; + +// Fed one sample per game frame; reports at most one trick per flick. +class ShakeDetector { +public: + ShakeDetector() { SetSensitivity(kDefaultSensitivity); } + + void SetSensitivity(int level); + Direction Update(const MotionSample& sample, uint64_t nowUs); + void Reset(); + + // Strength of the last sample, 1 being the trigger threshold. + float Intensity() const { return m_intensity; } + +private: + float m_gyroThreshold = 0.0f; + float m_accelThreshold = 0.0f; + float m_intensity = 0.0f; + bool m_armed = true; + uint64_t m_lastFireUs = 0; +}; + +} // namespace MotionTrick diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 2f0f7a637..3e1ee0d63 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -87,6 +87,9 @@ struct RuntimeUserConfig { // "dpad_up,left_shoulder") as values; pressing either bound button counts. std::array, 12> controllerButtons; std::optional rumbleEnabled; + // Shake a motion controller to press the D-pad, like shaking a Wii Remote. + std::optional motionTrick; + std::optional motionTrickSensitivity; std::optional muteHotkey; std::map controllerExpressions; }; @@ -390,6 +393,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { } config.rumbleEnabled = FindConfigValue(document, "controller", "rumble"); + config.motionTrick = FindConfigValue(document, "controller", "motion_trick"); + if (const auto value = FindConfigInt(document, "controller", "motion_trick_sensitivity"); value && *value >= 1 && *value <= 5) { + config.motionTrickSensitivity = *value; + } if (auto value = FindConfigInt(document, "audio", "mute_key")) { config.muteHotkey = *value; } @@ -701,6 +708,24 @@ inline bool SetRumbleEnabled(bool value) { return WriteSetting("controller", "rumble", value ? "true" : "false"); } +inline bool MotionTrickEnabled(bool fallback = false) { + return Get().motionTrick.value_or(fallback); +} + +inline bool SetMotionTrickEnabled(bool value) { + Mutable().motionTrick = value; + return WriteSetting("controller", "motion_trick", value ? "true" : "false"); +} + +inline int32_t MotionTrickSensitivity(int32_t fallback = 3) { + return Get().motionTrickSensitivity.value_or(fallback); +} + +inline bool SetMotionTrickSensitivity(int32_t value) { + Mutable().motionTrickSensitivity = value; + return WriteSetting("controller", "motion_trick_sensitivity", std::to_string(value)); +} + inline int32_t MuteHotkey(int32_t fallback) { return Get().muteHotkey.value_or(fallback); } diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 24ad29faa..c0c6b672d 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -2,6 +2,7 @@ #include "memory.h" #include "hle/controller_status_contract.h" #include "input_bindings.h" +#include "motion_trick.h" #include #include @@ -116,6 +117,7 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) FillTriggersHeldByButtons(statuses); InputBindings::Apply(statuses); + MotionTrick::Apply(statuses); try { for (uint32_t i = 0; i < PAD_CHANMAX; ++i) { diff --git a/runtime/src/motion_trick.cpp b/runtime/src/motion_trick.cpp new file mode 100644 index 000000000..ffdd7b3a4 --- /dev/null +++ b/runtime/src/motion_trick.cpp @@ -0,0 +1,220 @@ +#include "motion_trick.h" + +#include "input_bindings.h" +#include "runtime_config.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace MotionTrick { +namespace { + +// Long enough for the game to see the press, short enough not to hold a trick. +constexpr int kPulseFrames = 4; +// Sensors report zeros or stale data for a moment after being switched on. +constexpr int kWarmupFrames = 6; +constexpr float kIntensityDecay = 0.85f; +// Real sensors always jitter; identical readings mean something else (Steam, +// for one) switched the stream off, so ask for it again. +constexpr int kStaleFrames = 30; +constexpr uint64_t kResyncIntervalUs = 2'000'000; + +struct PortState { + SDL_JoystickID id = 0; + bool gyro = false; + bool accel = false; + int warmup = 0; + MotionSample previous; + int staleFrames = 0; + uint64_t lastResyncUs = 0; + ShakeDetector detector; + float displayIntensity = 0.0f; + Direction pulse = Direction::None; + int pulseFrames = 0; + Direction lastTrick = Direction::None; + uint64_t lastTrickUs = 0; +}; + +std::atomic g_enabled{RuntimeConfigFile::MotionTrickEnabled(false)}; +std::atomic g_sensitivity{std::clamp(RuntimeConfigFile::MotionTrickSensitivity(kDefaultSensitivity), + kMinSensitivity, kMaxSensitivity)}; + +std::mutex g_mutex; +std::array g_ports; + +SDL_Gamepad* GamepadForPort(uint32_t port) { + const s32 index = PADGetIndexForPort(port); + return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast(index)); +} + +uint16_t ButtonFor(Direction direction) { + switch (direction) { + case Direction::Up: return PAD_BUTTON_UP; + case Direction::Down: return PAD_BUTTON_DOWN; + case Direction::Left: return PAD_BUTTON_LEFT; + case Direction::Right: return PAD_BUTTON_RIGHT; + default: return 0; + } +} + +bool HeldByOtherPort(uint32_t port, SDL_JoystickID id) { + for (uint32_t other = 0; other < g_ports.size(); ++other) { + if (other != port && g_ports[other].id == id) { + return true; + } + } + return false; +} + +void SetSensors(SDL_Gamepad* gamepad, const PortState& state, bool enabled) { + if (state.gyro) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, enabled); + if (state.accel) SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, enabled); +} + +void Attach(uint32_t port, SDL_Gamepad* gamepad) { + PortState& state = g_ports[port]; + const SDL_JoystickID id = gamepad != nullptr ? SDL_GetGamepadID(gamepad) : 0; + if (id == state.id) { + return; + } + if (state.id != 0 && !HeldByOtherPort(port, state.id)) { + if (SDL_Gamepad* previous = SDL_GetGamepadFromID(state.id)) { + SetSensors(previous, state, false); + } + } + state = PortState{}; + if (gamepad == nullptr) { + return; + } + state.id = id; + state.gyro = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) && + SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_GYRO, true); + state.accel = SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL) && + SDL_SetGamepadSensorEnabled(gamepad, SDL_SENSOR_ACCEL, true); + state.warmup = kWarmupFrames; +} + +bool SameReading(const MotionSample& a, const MotionSample& b) { + return a.hasGyro == b.hasGyro && a.hasAccel == b.hasAccel && + std::memcmp(a.gyro, b.gyro, sizeof(a.gyro)) == 0 && std::memcmp(a.accel, b.accel, sizeof(a.accel)) == 0; +} + +// Returns true when the sensors were restarted. +bool ResyncIfStale(SDL_Gamepad* gamepad, PortState& state, const MotionSample& sample, uint64_t now) { + const bool stale = SameReading(sample, state.previous) && SDL_GetKeyboardFocus() != nullptr; + state.previous = sample; + state.staleFrames = stale ? state.staleFrames + 1 : 0; + if (state.staleFrames < kStaleFrames || now - state.lastResyncUs < kResyncIntervalUs) { + return false; + } + SetSensors(gamepad, state, false); + SetSensors(gamepad, state, true); + state.staleFrames = 0; + state.lastResyncUs = now; + state.warmup = kWarmupFrames; + state.detector.Reset(); + return true; +} + +MotionSample ReadSample(SDL_Gamepad* gamepad, const PortState& state) { + MotionSample sample; + sample.hasGyro = state.gyro && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, sample.gyro, 3); + sample.hasAccel = state.accel && SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, sample.accel, 3) && + (sample.accel[0] != 0.0f || sample.accel[1] != 0.0f || sample.accel[2] != 0.0f); + return sample; +} + +} // namespace + +bool Enabled() noexcept { + return g_enabled.load(std::memory_order_relaxed); +} + +void SetEnabled(bool enabled) noexcept { + g_enabled.store(enabled, std::memory_order_relaxed); + RuntimeConfigFile::SetMotionTrickEnabled(enabled); +} + +int Sensitivity() noexcept { + return g_sensitivity.load(std::memory_order_relaxed); +} + +void SetSensitivity(int level) noexcept { + level = std::clamp(level, kMinSensitivity, kMaxSensitivity); + g_sensitivity.store(level, std::memory_order_relaxed); + RuntimeConfigFile::SetMotionTrickSensitivity(level); +} + +bool PortHasMotion(uint32_t port) noexcept { + SDL_Gamepad* gamepad = GamepadForPort(port); + return gamepad != nullptr && + (SDL_GamepadHasSensor(gamepad, SDL_SENSOR_GYRO) || SDL_GamepadHasSensor(gamepad, SDL_SENSOR_ACCEL)); +} + +uint64_t NowUs() noexcept { + return SDL_GetTicksNS() / 1000; +} + +void Apply(PADStatus* statuses) noexcept { + if (statuses == nullptr) { + return; + } + const bool enabled = Enabled(); + const int sensitivity = Sensitivity(); + const bool blocked = InputBindings::InputBlocked(); + const uint64_t now = NowUs(); + + std::lock_guard lock(g_mutex); + for (uint32_t port = 0; port < PAD_CHANMAX; ++port) { + SDL_Gamepad* gamepad = + enabled && statuses[port].err == PAD_ERR_NONE ? GamepadForPort(port) : nullptr; + Attach(port, gamepad); + PortState& state = g_ports[port]; + if (gamepad == nullptr || (!state.gyro && !state.accel)) { + continue; + } + if (state.warmup > 0) { + --state.warmup; + continue; + } + + const MotionSample sample = ReadSample(gamepad, state); + if (ResyncIfStale(gamepad, state, sample, now)) { + continue; + } + state.detector.SetSensitivity(sensitivity); + const Direction fired = state.detector.Update(sample, now); + state.displayIntensity = std::max(state.detector.Intensity(), state.displayIntensity * kIntensityDecay); + if (fired != Direction::None) { + state.lastTrick = fired; + state.lastTrickUs = now; + state.pulse = fired; + state.pulseFrames = kPulseFrames; + } + if (blocked) { + state.pulseFrames = 0; + } + if (state.pulseFrames > 0) { + statuses[port].button |= ButtonFor(state.pulse); + --state.pulseFrames; + } + } +} + +PortStatus Status(uint32_t port) noexcept { + if (port >= PAD_CHANMAX) { + return {}; + } + std::lock_guard lock(g_mutex); + const PortState& state = g_ports[port]; + return {state.displayIntensity, state.lastTrick, state.lastTrickUs}; +} + +} // namespace MotionTrick diff --git a/runtime/src/motion_trick_detector.cpp b/runtime/src/motion_trick_detector.cpp new file mode 100644 index 000000000..6652b6457 --- /dev/null +++ b/runtime/src/motion_trick_detector.cpp @@ -0,0 +1,71 @@ +#include "motion_trick_detector.h" + +#include +#include +#include + +namespace MotionTrick { +namespace { + +constexpr float kGravity = 9.80665f; +constexpr float kRearmIntensity = 0.35f; +constexpr uint64_t kCooldownUs = 400'000; + +// Indexed by sensitivity level - 1. +constexpr std::array kGyroThresholds = {11.0f, 9.0f, 7.0f, 5.5f, 4.0f}; +constexpr std::array kAccelThresholds = {30.0f, 25.0f, 20.0f, 16.0f, 12.0f}; + +float Length(const float (&v)[3]) { + return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +Direction GyroDirection(const float (&gyro)[3]) { + const float pitch = gyro[0]; + const float yaw = gyro[1]; + const float roll = gyro[2]; + if (std::fabs(pitch) >= std::max(std::fabs(yaw), std::fabs(roll))) { + return pitch >= 0.0f ? Direction::Up : Direction::Down; + } + const float lateral = std::fabs(roll) >= std::fabs(yaw) ? roll : yaw; + return lateral >= 0.0f ? Direction::Left : Direction::Right; +} + +} // namespace + +void ShakeDetector::SetSensitivity(int level) { + const size_t index = static_cast(std::clamp(level, kMinSensitivity, kMaxSensitivity) - 1); + m_gyroThreshold = kGyroThresholds[index]; + m_accelThreshold = kAccelThresholds[index]; +} + +void ShakeDetector::Reset() { + m_intensity = 0.0f; + m_armed = true; + m_lastFireUs = 0; +} + +Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) { + const float gyroIntensity = sample.hasGyro ? Length(sample.gyro) / m_gyroThreshold : 0.0f; + const float accelIntensity = + sample.hasAccel ? std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold : 0.0f; + m_intensity = std::max(gyroIntensity, accelIntensity); + if (!std::isfinite(m_intensity)) { + m_intensity = 0.0f; + return Direction::None; + } + + if (!m_armed) { + if (m_intensity < kRearmIntensity && nowUs - m_lastFireUs >= kCooldownUs) { + m_armed = true; + } + return Direction::None; + } + if (m_intensity < 1.0f) { + return Direction::None; + } + m_armed = false; + m_lastFireUs = nowUs; + return gyroIntensity >= accelIntensity ? GyroDirection(sample.gyro) : Direction::Up; +} + +} // namespace MotionTrick diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 2de7ac5d7..74d1fb7aa 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -5,6 +5,7 @@ #include "controller_button_names.h" #include "controller_mapping_wizard.h" #include "input_bindings.h" +#include "motion_trick.h" #include "game_graphics_options.h" #include "music_attenuation.h" #include "runtime_config.h" @@ -745,6 +746,69 @@ void DrawRumbleSettings() { } } +const char* TrickDirectionLabel(MotionTrick::Direction direction) { + switch (direction) { + case MotionTrick::Direction::Up: return "up"; + case MotionTrick::Direction::Down: return "down"; + case MotionTrick::Direction::Left: return "left"; + case MotionTrick::Direction::Right: return "right"; + default: return ""; + } +} + +void DrawMotionTrickSettings(uint32_t port) { + const bool hasMotion = MotionTrick::PortHasMotion(port); + bool enabled = MotionTrick::Enabled(); + ImGui::BeginDisabled(!hasMotion); + if (ImGui::Checkbox("Shake to trick", &enabled)) { + MotionTrick::SetEnabled(enabled); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip(hasMotion + ? "Flick the controller to trick off ramps, or to wheelie on a bike, like shaking a Wii Remote.\n" + "The flick direction picks the trick. The D-pad keeps working. Applies to every player." + : "Steam Input can hide a controller's motion sensor; turn it off for this game to use it here."); + } + if (!hasMotion) { + ImGui::SameLine(); + ImGui::TextDisabled("(no motion sensor)"); + return; + } + if (!enabled) { + return; + } + + ImGui::Indent(); + int sensitivity = MotionTrick::Sensitivity(); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderInt("Sensitivity##motion", &sensitivity, MotionTrick::kMinSensitivity, + MotionTrick::kMaxSensitivity, "%d", ImGuiSliderFlags_AlwaysClamp)) { + MotionTrick::SetSensitivity(sensitivity); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Higher reacts to smaller flicks. Lower it if you trick or wheelie by accident while steering."); + } + + constexpr float kMeterScale = 1.5f; + const MotionTrick::PortStatus status = MotionTrick::Status(port); + const ImVec2 meterPos = ImGui::GetCursorScreenPos(); + const ImVec2 meterSize(160.0f, ImGui::GetFrameHeight() * 0.6f); + ImGui::ProgressBar(std::min(status.intensity / kMeterScale, 1.0f), meterSize, ""); + const float thresholdX = meterPos.x + meterSize.x / kMeterScale; + ImGui::GetWindowDrawList()->AddLine(ImVec2(thresholdX, meterPos.y), ImVec2(thresholdX, meterPos.y + meterSize.y), + ImGui::GetColorU32(ImGuiCol_Text), 2.0f); + ImGui::SameLine(); + const bool recent = status.lastTrick != MotionTrick::Direction::None && + MotionTrick::NowUs() - status.lastTrickUs < 700'000; + if (recent) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Trick! (%s)", TrickDirectionLabel(status.lastTrick)); + } else { + ImGui::TextDisabled("Flick to test"); + } + ImGui::Unindent(); +} + void DrawGamepadMapping(uint32_t port) { uint32_t mappingCount = 0; PADButtonMapping* mappings = PADGetButtonMappings(port, &mappingCount); @@ -985,6 +1049,7 @@ void DrawControllerSettings() { } } else { ImGui::TextUnformatted("Ready to play."); + DrawMotionTrickSettings(port); if (ImGui::CollapsingHeader("Customize buttons")) { ImGui::Indent(); DrawGamepadMapping(port); diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp new file mode 100644 index 000000000..0f283f8a6 --- /dev/null +++ b/runtime/tests/motion_trick_tests.cpp @@ -0,0 +1,209 @@ +#include "motion_trick_detector.h" + +#include +#include +#include +#include +#include + +using MotionTrick::Direction; +using MotionTrick::MotionSample; +using MotionTrick::ShakeDetector; + +namespace { + +int g_failures = 0; +constexpr uint64_t kFrameUs = 16'667; + +void Check(bool ok, const std::string& what) { + if (!ok) { + std::printf(" FAIL: %s\n", what.c_str()); + ++g_failures; + } +} + +MotionSample Gyro(float pitch, float yaw, float roll) { + MotionSample sample; + sample.hasGyro = true; + sample.gyro[0] = pitch; + sample.gyro[1] = yaw; + sample.gyro[2] = roll; + sample.hasAccel = true; + sample.accel[1] = 9.80665f; + return sample; +} + +MotionSample AtRest() { + return Gyro(0.0f, 0.0f, 0.0f); +} + +struct Run { + ShakeDetector detector; + uint64_t now = 0; + std::vector fired; + + void Feed(const MotionSample& sample, int frames = 1) { + for (int i = 0; i < frames; ++i) { + const Direction direction = detector.Update(sample, now); + if (direction != Direction::None) fired.push_back(direction); + now += kFrameUs; + } + } + + // Ramp up to the peak and back down over a few frames, like a real flick. + void Flick(const MotionSample& peak) { + for (const float scale : {0.3f, 0.7f, 1.0f, 0.6f, 0.2f}) { + MotionSample sample = peak; + for (float& value : sample.gyro) value *= scale; + Feed(sample); + } + } +}; + +void TestRestNeverFires() { + Run run; + run.Feed(AtRest(), 600); + Check(run.fired.empty(), "controller at rest fired a trick"); +} + +void TestSteeringWobbleNeverFires() { + Run run; + for (int i = 0; i < 600; ++i) { + run.Feed(Gyro(1.5f * std::sin(i * 0.2f), 2.0f * std::sin(i * 0.13f), 2.5f * std::cos(i * 0.17f))); + } + Check(run.fired.empty(), "steering wobble fired a trick"); +} + +void TestOneFlickOneTrick() { + Run run; + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + run.Feed(AtRest(), 60); + Check(run.fired.size() == 1, "one flick did not give exactly one trick"); + Check(!run.fired.empty() && run.fired[0] == Direction::Up, "upward flick was not Up"); +} + +void TestReturnStrokeIsIgnored() { + Run run; + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + run.Flick(Gyro(-9.0f, 0.0f, 0.0f)); + run.Feed(AtRest(), 60); + Check(run.fired.size() == 1, "bringing the controller back gave a second trick"); +} + +void TestSeparateFlicksBothFire() { + Run run; + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + run.Feed(AtRest(), 40); + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + run.Feed(AtRest(), 40); + Check(run.fired.size() == 2, "two separate flicks did not give two tricks"); +} + +void TestSustainedShakeFiresOnce() { + Run run; + for (int i = 0; i < 120; ++i) { + run.Feed(Gyro(i % 2 == 0 ? 12.0f : -12.0f, 0.0f, 0.0f)); + } + Check(run.fired.size() == 1, "continuous shaking fired more than once"); +} + +void TestDirections() { + const struct { + MotionSample peak; + Direction expected; + const char* name; + } cases[] = { + {Gyro(10.0f, 0.0f, 0.0f), Direction::Up, "pitch up"}, + {Gyro(-10.0f, 0.0f, 0.0f), Direction::Down, "pitch down"}, + {Gyro(1.0f, 0.0f, 10.0f), Direction::Left, "roll left"}, + {Gyro(1.0f, 0.0f, -10.0f), Direction::Right, "roll right"}, + {Gyro(0.0f, 10.0f, 2.0f), Direction::Left, "yaw left"}, + {Gyro(0.0f, -10.0f, -2.0f), Direction::Right, "yaw right"}, + }; + for (const auto& c : cases) { + Run run; + run.Flick(c.peak); + Check(run.fired.size() == 1 && run.fired[0] == c.expected, std::string("wrong direction for ") + c.name); + } +} + +void TestSensitivity() { + Run low; + low.detector.SetSensitivity(1); + low.Flick(Gyro(6.0f, 0.0f, 0.0f)); + Check(low.fired.empty(), "sensitivity 1 fired on a small flick"); + + Run high; + high.detector.SetSensitivity(5); + high.Flick(Gyro(6.0f, 0.0f, 0.0f)); + Check(high.fired.size() == 1, "sensitivity 5 missed a small flick"); + + Run clamped; + clamped.detector.SetSensitivity(99); + clamped.Flick(Gyro(6.0f, 0.0f, 0.0f)); + Check(clamped.fired.size() == 1, "out of range sensitivity was not clamped"); +} + +void TestAccelOnlyController() { + Run run; + MotionSample rest; + rest.hasAccel = true; + rest.accel[1] = 9.80665f; + run.Feed(rest, 120); + Check(run.fired.empty(), "gravity alone fired a trick"); + + MotionSample jolt = rest; + jolt.accel[1] = 35.0f; + run.Feed(jolt); + run.Feed(rest, 60); + Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "accelerometer jolt did not give Up"); +} + +void TestNoSensorsNeverFires() { + Run run; + MotionSample none; + none.gyro[0] = 50.0f; + none.accel[1] = 80.0f; + run.Feed(none, 60); + Check(run.fired.empty(), "a sample without sensors fired a trick"); +} + +void TestBadDataIgnored() { + Run run; + const float nan = std::numeric_limits::quiet_NaN(); + run.Feed(Gyro(nan, 0.0f, 0.0f), 10); + Check(run.fired.empty(), "NaN sensor data fired a trick"); + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + Check(run.fired.size() == 1, "detector did not recover after NaN data"); +} + +void TestReset() { + Run run; + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + run.detector.Reset(); + run.Flick(Gyro(10.0f, 0.0f, 0.0f)); + Check(run.fired.size() == 2, "Reset did not re-arm the detector"); +} + +} // namespace + +int main() { + TestRestNeverFires(); + TestSteeringWobbleNeverFires(); + TestOneFlickOneTrick(); + TestReturnStrokeIsIgnored(); + TestSeparateFlicksBothFire(); + TestSustainedShakeFiresOnce(); + TestDirections(); + TestSensitivity(); + TestAccelOnlyController(); + TestNoSensorsNeverFires(); + TestBadDataIgnored(); + TestReset(); + if (g_failures != 0) { + std::printf("%d failure(s)\n", g_failures); + return 1; + } + std::printf("motion trick tests passed\n"); + return 0; +} diff --git a/runtime/tests/runtime_config_tests.cpp b/runtime/tests/runtime_config_tests.cpp index 5af70a09c..644cec924 100644 --- a/runtime/tests/runtime_config_tests.cpp +++ b/runtime/tests/runtime_config_tests.cpp @@ -65,6 +65,19 @@ int main() { config.widescreen == std::optional{false} && !config.legacyForceAspect, "legacy force_16_9 overrode force_aspect")) return 1; } + { + std::istringstream input("[controller]\nmotion_trick = true\nmotion_trick_sensitivity = 5\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion.toml"); + if (!Require(config.motionTrick == std::optional{true} && + config.motionTrickSensitivity == std::optional{5}, + "shake to trick settings were not loaded")) return 1; + } + { + std::istringstream input("[controller]\nmotion_trick_sensitivity = 9\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "motion-range.toml"); + if (!Require(!config.motionTrick && !config.motionTrickSensitivity, + "out of range shake to trick sensitivity was accepted")) return 1; + } if (!SetsFps("[video]\nshow_fps = false\n", "bare key was not replaced")) return 1; if (!SetsFps("[video]\n\"show_fps\" = false\n", "basic-quoted key was not replaced")) return 1; if (!SetsFps("[video]\n'show_fps'=false\n", "literal-quoted key was not replaced")) return 1; From cc63a8c0d034c65715b2c6be86a2ba79eeb78818 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:01:36 +0100 Subject: [PATCH 2/6] address review --- runtime/include/motion_trick.h | 1 + runtime/include/motion_trick_detector.h | 2 +- runtime/src/motion_trick.cpp | 51 ++++++++++++++----------- runtime/src/motion_trick_detector.cpp | 17 ++++++--- runtime/src/settings_overlay.cpp | 6 +-- runtime/tests/motion_trick_tests.cpp | 17 +++++++++ 6 files changed, 62 insertions(+), 32 deletions(-) diff --git a/runtime/include/motion_trick.h b/runtime/include/motion_trick.h index 8b1985ef0..d74421987 100644 --- a/runtime/include/motion_trick.h +++ b/runtime/include/motion_trick.h @@ -27,6 +27,7 @@ struct PortStatus { float intensity = 0.0f; // decaying peak, 1 being the trigger threshold Direction lastTrick = Direction::None; uint64_t lastTrickUs = 0; + bool sensorsUnavailable = false; // the controller refused to turn its sensors on }; PortStatus Status(uint32_t port) noexcept; diff --git a/runtime/include/motion_trick_detector.h b/runtime/include/motion_trick_detector.h index deb97fa3b..4c1c965dc 100644 --- a/runtime/include/motion_trick_detector.h +++ b/runtime/include/motion_trick_detector.h @@ -13,7 +13,7 @@ inline constexpr int kMinSensitivity = 1; inline constexpr int kMaxSensitivity = 5; inline constexpr int kDefaultSensitivity = 3; -// SDL gamepad sensor axes: x right, y out of the face, z toward the player. +// SDL gamepad sensor axes: x right, y top, z toward the player. struct MotionSample { bool hasGyro = false; bool hasAccel = false; diff --git a/runtime/src/motion_trick.cpp b/runtime/src/motion_trick.cpp index ffdd7b3a4..7e02a60b1 100644 --- a/runtime/src/motion_trick.cpp +++ b/runtime/src/motion_trick.cpp @@ -21,10 +21,11 @@ constexpr int kPulseFrames = 4; // Sensors report zeros or stale data for a moment after being switched on. constexpr int kWarmupFrames = 6; constexpr float kIntensityDecay = 0.85f; -// Real sensors always jitter; identical readings mean something else (Steam, -// for one) switched the stream off, so ask for it again. +// Real sensors always jitter; identical readings mean the stream was switched +// off behind our back, so ask for it again a few times. constexpr int kStaleFrames = 30; constexpr uint64_t kResyncIntervalUs = 2'000'000; +constexpr int kMaxResyncs = 3; struct PortState { SDL_JoystickID id = 0; @@ -33,6 +34,7 @@ struct PortState { int warmup = 0; MotionSample previous; int staleFrames = 0; + int resyncs = 0; uint64_t lastResyncUs = 0; ShakeDetector detector; float displayIntensity = 0.0f; @@ -43,13 +45,17 @@ struct PortState { }; std::atomic g_enabled{RuntimeConfigFile::MotionTrickEnabled(false)}; -std::atomic g_sensitivity{std::clamp(RuntimeConfigFile::MotionTrickSensitivity(kDefaultSensitivity), - kMinSensitivity, kMaxSensitivity)}; +std::atomic g_sensitivity{RuntimeConfigFile::MotionTrickSensitivity(kDefaultSensitivity)}; std::mutex g_mutex; std::array g_ports; +// A keyboard player keeps their controller assigned, but aurora ignores it. SDL_Gamepad* GamepadForPort(uint32_t port) { + uint32_t keyCount = 0; + if (PADGetKeyButtonBindings(port, &keyCount) != nullptr) { + return nullptr; + } const s32 index = PADGetIndexForPort(port); return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast(index)); } @@ -111,15 +117,19 @@ bool ResyncIfStale(SDL_Gamepad* gamepad, PortState& state, const MotionSample& s const bool stale = SameReading(sample, state.previous) && SDL_GetKeyboardFocus() != nullptr; state.previous = sample; state.staleFrames = stale ? state.staleFrames + 1 : 0; - if (state.staleFrames < kStaleFrames || now - state.lastResyncUs < kResyncIntervalUs) { + if (!stale) { + state.resyncs = 0; + } + if (state.staleFrames < kStaleFrames || state.resyncs >= kMaxResyncs || + now - state.lastResyncUs < kResyncIntervalUs) { return false; } SetSensors(gamepad, state, false); SetSensors(gamepad, state, true); state.staleFrames = 0; + ++state.resyncs; state.lastResyncUs = now; state.warmup = kWarmupFrames; - state.detector.Reset(); return true; } @@ -182,21 +192,18 @@ void Apply(PADStatus* statuses) noexcept { } if (state.warmup > 0) { --state.warmup; - continue; - } - - const MotionSample sample = ReadSample(gamepad, state); - if (ResyncIfStale(gamepad, state, sample, now)) { - continue; - } - state.detector.SetSensitivity(sensitivity); - const Direction fired = state.detector.Update(sample, now); - state.displayIntensity = std::max(state.detector.Intensity(), state.displayIntensity * kIntensityDecay); - if (fired != Direction::None) { - state.lastTrick = fired; - state.lastTrickUs = now; - state.pulse = fired; - state.pulseFrames = kPulseFrames; + } else if (const MotionSample sample = ReadSample(gamepad, state); + !ResyncIfStale(gamepad, state, sample, now)) { + state.detector.SetSensitivity(sensitivity); + const Direction fired = state.detector.Update(sample, now); + state.displayIntensity = + std::max(state.detector.Intensity(), state.displayIntensity * kIntensityDecay); + if (fired != Direction::None) { + state.lastTrick = fired; + state.lastTrickUs = now; + state.pulse = fired; + state.pulseFrames = kPulseFrames; + } } if (blocked) { state.pulseFrames = 0; @@ -214,7 +221,7 @@ PortStatus Status(uint32_t port) noexcept { } std::lock_guard lock(g_mutex); const PortState& state = g_ports[port]; - return {state.displayIntensity, state.lastTrick, state.lastTrickUs}; + return {state.displayIntensity, state.lastTrick, state.lastTrickUs, state.id != 0 && !state.gyro && !state.accel}; } } // namespace MotionTrick diff --git a/runtime/src/motion_trick_detector.cpp b/runtime/src/motion_trick_detector.cpp index 6652b6457..524727421 100644 --- a/runtime/src/motion_trick_detector.cpp +++ b/runtime/src/motion_trick_detector.cpp @@ -13,7 +13,7 @@ constexpr uint64_t kCooldownUs = 400'000; // Indexed by sensitivity level - 1. constexpr std::array kGyroThresholds = {11.0f, 9.0f, 7.0f, 5.5f, 4.0f}; -constexpr std::array kAccelThresholds = {30.0f, 25.0f, 20.0f, 16.0f, 12.0f}; +constexpr std::array kAccelThresholds = {20.0f, 16.0f, 13.0f, 10.0f, 8.0f}; float Length(const float (&v)[3]) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); @@ -45,10 +45,15 @@ void ShakeDetector::Reset() { } Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) { - const float gyroIntensity = sample.hasGyro ? Length(sample.gyro) / m_gyroThreshold : 0.0f; - const float accelIntensity = - sample.hasAccel ? std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold : 0.0f; - m_intensity = std::max(gyroIntensity, accelIntensity); + // The accelerometer only stands in for a missing gyro: on a gyro pad it + // mostly picks up bumps, and it carries no direction. + if (sample.hasGyro) { + m_intensity = Length(sample.gyro) / m_gyroThreshold; + } else if (sample.hasAccel) { + m_intensity = std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold; + } else { + m_intensity = 0.0f; + } if (!std::isfinite(m_intensity)) { m_intensity = 0.0f; return Direction::None; @@ -65,7 +70,7 @@ Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) { } m_armed = false; m_lastFireUs = nowUs; - return gyroIntensity >= accelIntensity ? GyroDirection(sample.gyro) : Direction::Up; + return sample.hasGyro ? GyroDirection(sample.gyro) : Direction::Up; } } // namespace MotionTrick diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 74d1fb7aa..6c2083a8d 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -757,9 +757,10 @@ const char* TrickDirectionLabel(MotionTrick::Direction direction) { } void DrawMotionTrickSettings(uint32_t port) { - const bool hasMotion = MotionTrick::PortHasMotion(port); + const MotionTrick::PortStatus status = MotionTrick::Status(port); + const bool hasMotion = MotionTrick::PortHasMotion(port) && !status.sensorsUnavailable; bool enabled = MotionTrick::Enabled(); - ImGui::BeginDisabled(!hasMotion); + ImGui::BeginDisabled(!hasMotion && !enabled); if (ImGui::Checkbox("Shake to trick", &enabled)) { MotionTrick::SetEnabled(enabled); } @@ -791,7 +792,6 @@ void DrawMotionTrickSettings(uint32_t port) { } constexpr float kMeterScale = 1.5f; - const MotionTrick::PortStatus status = MotionTrick::Status(port); const ImVec2 meterPos = ImGui::GetCursorScreenPos(); const ImVec2 meterSize(160.0f, ImGui::GetFrameHeight() * 0.6f); ImGui::ProgressBar(std::min(status.intensity / kMeterScale, 1.0f), meterSize, ""); diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp index 0f283f8a6..4e2ee4023 100644 --- a/runtime/tests/motion_trick_tests.cpp +++ b/runtime/tests/motion_trick_tests.cpp @@ -159,6 +159,22 @@ void TestAccelOnlyController() { Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "accelerometer jolt did not give Up"); } +void TestGyroPadIgnoresAccel() { + Run bump; + MotionSample jolt = AtRest(); + jolt.accel[1] = 40.0f; + bump.Feed(jolt, 3); + bump.Feed(AtRest(), 60); + Check(bump.fired.empty(), "an accelerometer bump on a gyro pad fired a trick"); + + Run flick; + MotionSample sideways = Gyro(0.0f, 10.0f, 0.0f); + sideways.accel[1] = 40.0f; + flick.Feed(sideways); + Check(flick.fired.size() == 1 && flick.fired[0] == Direction::Left, + "a jolt changed the direction of a sideways flick"); +} + void TestNoSensorsNeverFires() { Run run; MotionSample none; @@ -197,6 +213,7 @@ int main() { TestDirections(); TestSensitivity(); TestAccelOnlyController(); + TestGyroPadIgnoresAccel(); TestNoSensorsNeverFires(); TestBadDataIgnored(); TestReset(); From 4ac4c902d8f5a591a90519fbb8de994d96f743ac Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:08:44 +0100 Subject: [PATCH 3/6] add motion trick tests to ci --- .github/workflows/build.yml | 1 + runtime/include/motion_trick_detector.h | 1 - runtime/src/motion_trick_detector.cpp | 6 ------ runtime/tests/motion_trick_tests.cpp | 19 ++++++++++--------- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b204551ef..01cb8de53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,6 +102,7 @@ jobs: mkw_nand_settings_tests \ mkw_sc_serial_tests \ mkw_input_expr_tests \ + mkw_motion_trick_tests \ mkw_bluetooth_controller_tests \ mkw_macos_native_compile \ mkw_macos_context_abi_tests \ diff --git a/runtime/include/motion_trick_detector.h b/runtime/include/motion_trick_detector.h index 4c1c965dc..285ff6874 100644 --- a/runtime/include/motion_trick_detector.h +++ b/runtime/include/motion_trick_detector.h @@ -28,7 +28,6 @@ class ShakeDetector { void SetSensitivity(int level); Direction Update(const MotionSample& sample, uint64_t nowUs); - void Reset(); // Strength of the last sample, 1 being the trigger threshold. float Intensity() const { return m_intensity; } diff --git a/runtime/src/motion_trick_detector.cpp b/runtime/src/motion_trick_detector.cpp index 524727421..378c5977b 100644 --- a/runtime/src/motion_trick_detector.cpp +++ b/runtime/src/motion_trick_detector.cpp @@ -38,12 +38,6 @@ void ShakeDetector::SetSensitivity(int level) { m_accelThreshold = kAccelThresholds[index]; } -void ShakeDetector::Reset() { - m_intensity = 0.0f; - m_armed = true; - m_lastFireUs = 0; -} - Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) { // The accelerometer only stands in for a missing gyro: on a gyro pad it // mostly picks up bumps, and it carries no direction. diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp index 4e2ee4023..e4adaee33 100644 --- a/runtime/tests/motion_trick_tests.cpp +++ b/runtime/tests/motion_trick_tests.cpp @@ -159,6 +159,15 @@ void TestAccelOnlyController() { Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "accelerometer jolt did not give Up"); } +void TestGyroOnlyController() { + Run run; + MotionSample flick = Gyro(10.0f, 0.0f, 0.0f); + flick.hasAccel = false; + run.Flick(flick); + run.Feed(AtRest(), 60); + Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "gyro-only flick did not give Up"); +} + void TestGyroPadIgnoresAccel() { Run bump; MotionSample jolt = AtRest(); @@ -193,14 +202,6 @@ void TestBadDataIgnored() { Check(run.fired.size() == 1, "detector did not recover after NaN data"); } -void TestReset() { - Run run; - run.Flick(Gyro(10.0f, 0.0f, 0.0f)); - run.detector.Reset(); - run.Flick(Gyro(10.0f, 0.0f, 0.0f)); - Check(run.fired.size() == 2, "Reset did not re-arm the detector"); -} - } // namespace int main() { @@ -213,10 +214,10 @@ int main() { TestDirections(); TestSensitivity(); TestAccelOnlyController(); + TestGyroOnlyController(); TestGyroPadIgnoresAccel(); TestNoSensorsNeverFires(); TestBadDataIgnored(); - TestReset(); if (g_failures != 0) { std::printf("%d failure(s)\n", g_failures); return 1; From 02e643c6322b0586ac6389534c12cb835d86734a Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:14:33 +0100 Subject: [PATCH 4/6] reject bad sensor data under fast math --- runtime/cmake/PlatformTests.cmake | 2 ++ runtime/src/motion_trick_detector.cpp | 26 ++++++++++++++++++-------- runtime/tests/motion_trick_tests.cpp | 15 ++++++++++++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/runtime/cmake/PlatformTests.cmake b/runtime/cmake/PlatformTests.cmake index 26dd3d2fa..1a31b8232 100644 --- a/runtime/cmake/PlatformTests.cmake +++ b/runtime/cmake/PlatformTests.cmake @@ -68,6 +68,8 @@ add_executable(mkw_motion_trick_tests "${MKW_TEST_RUNTIME_DIR}/src/motion_trick_detector.cpp") target_include_directories(mkw_motion_trick_tests PRIVATE "${MKW_TEST_RUNTIME_DIR}/include") target_compile_features(mkw_motion_trick_tests PRIVATE cxx_std_17) +# Match the runtime's float model so the bad-data guard is tested as shipped. +target_compile_options(mkw_motion_trick_tests PRIVATE $<$>:-ffast-math>) add_test(NAME mkw_motion_trick_tests COMMAND mkw_motion_trick_tests) # The emulated Bluetooth controller is pure host code. diff --git a/runtime/src/motion_trick_detector.cpp b/runtime/src/motion_trick_detector.cpp index 378c5977b..36fd06571 100644 --- a/runtime/src/motion_trick_detector.cpp +++ b/runtime/src/motion_trick_detector.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace MotionTrick { namespace { @@ -15,6 +17,18 @@ constexpr uint64_t kCooldownUs = 400'000; constexpr std::array kGyroThresholds = {11.0f, 9.0f, 7.0f, 5.5f, 4.0f}; constexpr std::array kAccelThresholds = {20.0f, 16.0f, 13.0f, 10.0f, 8.0f}; +// The runtime builds with -ffast-math, which may fold std::isfinite away. +bool Finite(const float (&v)[3]) { + for (const float value : v) { + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + if ((bits & 0x7f800000u) == 0x7f800000u) { + return false; + } + } + return true; +} + float Length(const float (&v)[3]) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } @@ -41,17 +55,13 @@ void ShakeDetector::SetSensitivity(int level) { Direction ShakeDetector::Update(const MotionSample& sample, uint64_t nowUs) { // The accelerometer only stands in for a missing gyro: on a gyro pad it // mostly picks up bumps, and it carries no direction. - if (sample.hasGyro) { - m_intensity = Length(sample.gyro) / m_gyroThreshold; - } else if (sample.hasAccel) { - m_intensity = std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold; - } else { - m_intensity = 0.0f; - } - if (!std::isfinite(m_intensity)) { + const float(&values)[3] = sample.hasGyro ? sample.gyro : sample.accel; + if ((!sample.hasGyro && !sample.hasAccel) || !Finite(values)) { m_intensity = 0.0f; return Direction::None; } + m_intensity = sample.hasGyro ? Length(sample.gyro) / m_gyroThreshold + : std::fabs(Length(sample.accel) - kGravity) / m_accelThreshold; if (!m_armed) { if (m_intensity < kRearmIntensity && nowUs - m_lastFireUs >= kCooldownUs) { diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp index e4adaee33..2888e7c29 100644 --- a/runtime/tests/motion_trick_tests.cpp +++ b/runtime/tests/motion_trick_tests.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include @@ -22,6 +22,13 @@ void Check(bool ok, const std::string& what) { } } +// Built from bits so -ffast-math cannot assume them away. +float FromBits(uint32_t bits) { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + MotionSample Gyro(float pitch, float yaw, float roll) { MotionSample sample; sample.hasGyro = true; @@ -195,9 +202,11 @@ void TestNoSensorsNeverFires() { void TestBadDataIgnored() { Run run; - const float nan = std::numeric_limits::quiet_NaN(); + const float nan = FromBits(0x7fc00000u); + const float inf = FromBits(0x7f800000u); run.Feed(Gyro(nan, 0.0f, 0.0f), 10); - Check(run.fired.empty(), "NaN sensor data fired a trick"); + run.Feed(Gyro(inf, 0.0f, 0.0f), 10); + Check(run.fired.empty(), "NaN or infinite sensor data fired a trick"); run.Flick(Gyro(10.0f, 0.0f, 0.0f)); Check(run.fired.size() == 1, "detector did not recover after NaN data"); } From 2f651382bdfef995738674ff7ac772336ce454a4 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:17:04 +0100 Subject: [PATCH 5/6] harden bad data test --- runtime/tests/motion_trick_tests.cpp | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp index 2888e7c29..a3ddcd75f 100644 --- a/runtime/tests/motion_trick_tests.cpp +++ b/runtime/tests/motion_trick_tests.cpp @@ -22,11 +22,9 @@ void Check(bool ok, const std::string& what) { } } -// Built from bits so -ffast-math cannot assume them away. -float FromBits(uint32_t bits) { - float value = 0.0f; - std::memcpy(&value, &bits, sizeof(value)); - return value; +// Written as raw bits so no float operation under -ffast-math can drop them. +void SetBits(float& target, uint32_t bits) { + std::memcpy(&target, &bits, sizeof(target)); } MotionSample Gyro(float pitch, float yaw, float roll) { @@ -202,10 +200,16 @@ void TestNoSensorsNeverFires() { void TestBadDataIgnored() { Run run; - const float nan = FromBits(0x7fc00000u); - const float inf = FromBits(0x7f800000u); - run.Feed(Gyro(nan, 0.0f, 0.0f), 10); - run.Feed(Gyro(inf, 0.0f, 0.0f), 10); + for (const uint32_t bits : {0x7fc00000u, 0x7f800000u}) { + MotionSample gyro = AtRest(); + SetBits(gyro.gyro[0], bits); + run.Feed(gyro, 10); + + MotionSample accel; + accel.hasAccel = true; + SetBits(accel.accel[1], bits); + run.Feed(accel, 10); + } Check(run.fired.empty(), "NaN or infinite sensor data fired a trick"); run.Flick(Gyro(10.0f, 0.0f, 0.0f)); Check(run.fired.size() == 1, "detector did not recover after NaN data"); From 99e93df70386a05947c4e73253faecd74405da46 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:28:29 +0100 Subject: [PATCH 6/6] widen motion trick test coverage --- runtime/tests/motion_trick_tests.cpp | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/runtime/tests/motion_trick_tests.cpp b/runtime/tests/motion_trick_tests.cpp index a3ddcd75f..81a64878c 100644 --- a/runtime/tests/motion_trick_tests.cpp +++ b/runtime/tests/motion_trick_tests.cpp @@ -168,9 +168,14 @@ void TestGyroOnlyController() { Run run; MotionSample flick = Gyro(10.0f, 0.0f, 0.0f); flick.hasAccel = false; + MotionSample rest = AtRest(); + rest.hasAccel = false; run.Flick(flick); - run.Feed(AtRest(), 60); - Check(run.fired.size() == 1 && run.fired[0] == Direction::Up, "gyro-only flick did not give Up"); + run.Feed(rest, 60); + run.Flick(flick); + run.Feed(rest, 60); + Check(run.fired.size() == 2 && run.fired[0] == Direction::Up && run.fired[1] == Direction::Up, + "repeated gyro-only flicks did not give two Up tricks"); } void TestGyroPadIgnoresAccel() { @@ -201,14 +206,16 @@ void TestNoSensorsNeverFires() { void TestBadDataIgnored() { Run run; for (const uint32_t bits : {0x7fc00000u, 0x7f800000u}) { - MotionSample gyro = AtRest(); - SetBits(gyro.gyro[0], bits); - run.Feed(gyro, 10); - - MotionSample accel; - accel.hasAccel = true; - SetBits(accel.accel[1], bits); - run.Feed(accel, 10); + for (int axis = 0; axis < 3; ++axis) { + MotionSample gyro = AtRest(); + SetBits(gyro.gyro[axis], bits); + run.Feed(gyro, 10); + + MotionSample accel; + accel.hasAccel = true; + SetBits(accel.accel[axis], bits); + run.Feed(accel, 10); + } } Check(run.fired.empty(), "NaN or infinite sensor data fired a trick"); run.Flick(Gyro(10.0f, 0.0f, 0.0f));