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/cmake/PlatformTests.cmake b/runtime/cmake/PlatformTests.cmake index a1a073ffa..1a31b8232 100644 --- a/runtime/cmake/PlatformTests.cmake +++ b/runtime/cmake/PlatformTests.cmake @@ -63,6 +63,15 @@ 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) +# 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. 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..d74421987 --- /dev/null +++ b/runtime/include/motion_trick.h @@ -0,0 +1,36 @@ +#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; + bool sensorsUnavailable = false; // the controller refused to turn its sensors on +}; +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..285ff6874 --- /dev/null +++ b/runtime/include/motion_trick_detector.h @@ -0,0 +1,43 @@ +#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 top, 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); + + // 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..7e02a60b1 --- /dev/null +++ b/runtime/src/motion_trick.cpp @@ -0,0 +1,227 @@ +#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 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; + bool gyro = false; + bool accel = false; + int warmup = 0; + MotionSample previous; + int staleFrames = 0; + int resyncs = 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{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)); +} + +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 (!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; + 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; + } 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; + } + 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, 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 new file mode 100644 index 000000000..36fd06571 --- /dev/null +++ b/runtime/src/motion_trick_detector.cpp @@ -0,0 +1,80 @@ +#include "motion_trick_detector.h" + +#include +#include +#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 = {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]); +} + +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]; +} + +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. + 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) { + m_armed = true; + } + return Direction::None; + } + if (m_intensity < 1.0f) { + return Direction::None; + } + m_armed = false; + m_lastFireUs = nowUs; + 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 2de7ac5d7..6c2083a8d 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 MotionTrick::PortStatus status = MotionTrick::Status(port); + const bool hasMotion = MotionTrick::PortHasMotion(port) && !status.sensorsUnavailable; + bool enabled = MotionTrick::Enabled(); + ImGui::BeginDisabled(!hasMotion && !enabled); + 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 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..81a64878c --- /dev/null +++ b/runtime/tests/motion_trick_tests.cpp @@ -0,0 +1,247 @@ +#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; + } +} + +// 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) { + 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 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(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() { + 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; + 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; + for (const uint32_t bits : {0x7fc00000u, 0x7f800000u}) { + 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)); + Check(run.fired.size() == 1, "detector did not recover after NaN data"); +} + +} // namespace + +int main() { + TestRestNeverFires(); + TestSteeringWobbleNeverFires(); + TestOneFlickOneTrick(); + TestReturnStrokeIsIgnored(); + TestSeparateFlicksBothFire(); + TestSustainedShakeFiresOnce(); + TestDirections(); + TestSensitivity(); + TestAccelOnlyController(); + TestGyroOnlyController(); + TestGyroPadIgnoresAccel(); + TestNoSensorsNeverFires(); + TestBadDataIgnored(); + 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;