From cfec816dace59a121a614fbcea7afe5cfbb050c8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 2 Sep 2026 17:35:13 -0600 Subject: [PATCH] Apply persistent controller profiles --- CMakeLists.txt | 2 + controller_profile.cpp | 46 ++- controller_profile.h | 12 +- controller_profile_runtime.cpp | 131 ++++++ controller_profile_runtime.h | 34 ++ controller_profile_transform.cpp | 344 ++++++++++++++++ controller_profile_transform.h | 31 ++ profile_service.cpp | 83 +++- profile_service.h | 14 +- profile_storage.cpp | 6 +- src/switch_pico_bridge/config_manager.py | 78 +++- switch-pico.cpp | 45 +- switch_pro_driver.cpp | 16 +- switch_pro_driver.h | 7 +- tests/controller_profile_legacy_fixtures.h | 63 +++ tests/controller_profile_runtime_test.cpp | 273 +++++++++++++ tests/controller_profile_test.cpp | 138 ++++++- tests/controller_profile_transform_test.cpp | 385 ++++++++++++++++++ tests/profile_service_test.cpp | 37 ++ tests/profile_storage_test.cpp | 258 ++++++++++++ tests/switch_pro_driver_context_test.cpp | 61 ++- tests/test_config_manager.py | 141 ++++++- .../test_controller_profile_runtime_native.py | 32 ++ ...est_controller_profile_transform_native.py | 31 ++ tests/usb_configuration_management_test.cpp | 7 +- 25 files changed, 2179 insertions(+), 96 deletions(-) create mode 100644 controller_profile_runtime.cpp create mode 100644 controller_profile_runtime.h create mode 100644 controller_profile_transform.cpp create mode 100644 controller_profile_transform.h create mode 100644 tests/controller_profile_legacy_fixtures.h create mode 100644 tests/controller_profile_runtime_test.cpp create mode 100644 tests/controller_profile_transform_test.cpp create mode 100644 tests/test_controller_profile_runtime_native.py create mode 100644 tests/test_controller_profile_transform_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 167d8b4..b35736d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,8 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") bluepad32_input_backend.cpp controller_identity.cpp controller_profile.cpp + controller_profile_transform.cpp + controller_profile_runtime.cpp profile_storage.cpp profile_service.cpp pico_profile_storage.cpp diff --git a/controller_profile.cpp b/controller_profile.cpp index 9b3c47f..7209944 100644 --- a/controller_profile.cpp +++ b/controller_profile.cpp @@ -17,6 +17,7 @@ constexpr uint8_t kMacroOverrideMask = kControllerProfileOverrideRightStick | kControllerProfileOverrideLeftTrigger | kControllerProfileOverrideRightTrigger; +constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000; uint16_t profile_read_u16(const uint8_t* input) { return static_cast(input[0]) | @@ -144,7 +145,8 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity, trigger.lower_deadzone = 0; trigger.upper_saturation = UINT16_MAX; trigger.curve_q8_8 = 256; - trigger.digital_threshold = 0x8000; + trigger.digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; } profile.weak_rumble_scale = UINT8_MAX; profile.strong_rumble_scale = UINT8_MAX; @@ -176,9 +178,7 @@ bool controller_profile_validate(const ControllerProfile& profile) { for (const ControllerProfileTriggerConfiguration& trigger : profile.triggers) { if (trigger.lower_deadzone >= trigger.upper_saturation || - trigger.curve_q8_8 == 0 || - trigger.digital_threshold < trigger.lower_deadzone || - trigger.digital_threshold > trigger.upper_saturation) { + trigger.curve_q8_8 == 0) { return false; } } @@ -272,13 +272,19 @@ bool controller_profile_encode(const ControllerProfile& profile, bool controller_profile_decode(const uint8_t* input, size_t input_size, ControllerProfile* output) { if (input == nullptr || output == nullptr || - input_size != CONTROLLER_PROFILE_ENCODED_SIZE || - profile_read_u16(&input[0]) != CONTROLLER_PROFILE_SCHEMA_VERSION || + input_size != CONTROLLER_PROFILE_ENCODED_SIZE) { + return false; + } + const uint16_t schema_version = profile_read_u16(&input[0]); + if ((schema_version != CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && + schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) || profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE || - !profile_bytes_are_zero(&input[31], 5) || !profile_bytes_are_zero(&input[47], 5) || - !profile_bytes_are_zero(&input[60], 2) || !profile_bytes_are_zero(&input[70], 2) || - input[75] != 0 || input[81] != 0 || - !profile_bytes_are_zero(&input[98], 2) || !profile_bytes_are_zero(&input[252], 4)) { + !profile_bytes_are_zero(&input[31], 5) || + !profile_bytes_are_zero(&input[47], 5) || + !profile_bytes_are_zero(&input[60], 2) || + !profile_bytes_are_zero(&input[70], 2) || input[75] != 0 || + input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) || + !profile_bytes_are_zero(&input[252], 4)) { return false; } @@ -307,6 +313,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, trigger.upper_saturation = profile_read_u16(&encoded[2]); trigger.curve_q8_8 = profile_read_u16(&encoded[4]); trigger.digital_threshold = profile_read_u16(&encoded[6]); + if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && + trigger.digital_threshold == kLegacyDefaultDigitalThreshold) { + trigger.digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + } } profile.weak_rumble_scale = input[72]; profile.strong_rumble_scale = input[73]; @@ -489,11 +500,16 @@ bool controller_profile_database_decode( return false; } uint8_t header[CONTROLLER_PROFILE_DATABASE_HEADER_SIZE]{}; - if (!read(context, 0, header, sizeof(header)) || - memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 || - profile_read_u16(&header[4]) != - CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION || - profile_read_u16(&header[6]) != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + if (!read(context, 0, header, sizeof(header))) { + return false; + } + const uint16_t schema_version = profile_read_u16(&header[4]); + if (memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 || + (schema_version != + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + schema_version != CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || + profile_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || header[9] != CONTROLLER_PROFILE_COUNT || header[10] >= CONTROLLER_PROFILE_COUNT || diff --git a/controller_profile.h b/controller_profile.h index 14b30db..d7e7618 100644 --- a/controller_profile.h +++ b/controller_profile.h @@ -5,7 +5,8 @@ #include "controller_identity.h" -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 2; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; @@ -13,9 +14,12 @@ constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8; constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000; constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff; constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff; +// Exact 16-bit counterpart of the existing 358-of-1023 Switch boundary. +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934; constexpr uint8_t CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY = 16; -constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 2; constexpr size_t CONTROLLER_PROFILE_DATABASE_HEADER_SIZE = 32; constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE = 16; constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE = @@ -92,7 +96,9 @@ struct ControllerProfileTriggerConfiguration { uint16_t lower_deadzone = 0; uint16_t upper_saturation = UINT16_MAX; uint16_t curve_q8_8 = 256; - uint16_t digital_threshold = 0x8000; + // Compared against the transformed uint16 trigger output. + uint16_t digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; }; struct ControllerProfileMacroStep { diff --git a/controller_profile_runtime.cpp b/controller_profile_runtime.cpp new file mode 100644 index 0000000..681c42c --- /dev/null +++ b/controller_profile_runtime.cpp @@ -0,0 +1,131 @@ +#include "controller_profile_runtime.h" + +#include "controller_identity.h" +#include "profile_service.h" + +namespace { + +struct ControllerProfileRuntimeContext { + bool active = false; + uint32_t connection_generation = 0; + ControllerIdentity identity{}; + uint32_t database_generation = 0; + uint8_t active_profile_index = 0; + ControllerProfile profile{}; +}; + +ControllerProfileRuntimeContext + g_contexts[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT]{}; +ControllerProfile g_default_profile{}; +ControllerProfileTransformResult g_neutral_output{}; +bool g_initialized = false; + +void initialize_defaults() { + if (g_initialized) { + return; + } + g_default_profile = + controller_profile_default(controller_identity_global(), 0); + g_neutral_output = controller_profile_transform( + controller_neutral_state(), g_default_profile); + g_initialized = true; +} + +void clear_context(ControllerProfileRuntimeContext* context) { + if (context == nullptr) { + return; + } + *context = {}; +} + +void refresh_profile(ControllerProfileRuntimeContext* context, + const ControllerIdentity& identity, + uint32_t connection_generation, + uint32_t observed_database_generation) { + ProfileServiceActiveProfileSnapshot snapshot{}; + profile_service_active_profile_snapshot(identity, &snapshot); + + context->active = true; + context->connection_generation = connection_generation; + context->identity = identity; + context->database_generation = snapshot.valid + ? snapshot.metadata.generation + : observed_database_generation; + context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0; + context->profile = snapshot.valid ? snapshot.profile : g_default_profile; +} + +ControllerProfileRuntimeContext* update_context( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot) { + if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) { + return nullptr; + } + + ControllerProfileRuntimeContext& context = g_contexts[slot]; + if (!snapshot.active) { + if (context.active) { + clear_context(&context); + } + return nullptr; + } + + const uint32_t database_generation = + profile_service_database_generation(); + if (!context.active || + context.connection_generation != snapshot.connection_generation || + !controller_identity_equal(context.identity, snapshot.identity) || + context.database_generation != database_generation) { + refresh_profile(&context, snapshot.identity, + snapshot.connection_generation, + database_generation); + } + return &context; +} + + +} // namespace + +void controller_profile_runtime_reset() { + g_initialized = false; + initialize_defaults(); + for (ControllerProfileRuntimeContext& context : g_contexts) { + clear_context(&context); + } +} + +ControllerProfileTransformResult controller_profile_runtime_transform( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot) { + initialize_defaults(); + ControllerProfileRuntimeContext* context = + update_context(slot, snapshot); + if (context == nullptr) { + return g_neutral_output; + } + return controller_profile_transform(snapshot.state, context->profile); +} + +ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble) { + initialize_defaults(); + ControllerProfileRuntimeContext* context = + update_context(slot, snapshot); + const ControllerProfile& profile = + context == nullptr ? g_default_profile : context->profile; + return controller_profile_scale_host_rumble(rumble, profile); +} + +ControllerProfileRuntimeLocalConfirmation +controller_profile_runtime_local_confirmation( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble) { + initialize_defaults(); + ControllerProfileRuntimeContext* context = + update_context(slot, snapshot); + const ControllerProfile& profile = + context == nullptr ? g_default_profile : context->profile; + return { + rumble, + controller_profile_confirmation_policy(profile), + }; +} diff --git a/controller_profile_runtime.h b/controller_profile_runtime.h new file mode 100644 index 0000000..4eb246c --- /dev/null +++ b/controller_profile_runtime.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "bluepad32_input_backend.h" +#include "controller_profile_transform.h" + +constexpr uint8_t CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT = 4; + +struct ControllerProfileRuntimeLocalConfirmation { + ControllerRumbleOutput rumble{}; + ControllerProfileConfirmationPolicy policy = + ControllerProfileConfirmationPolicy::kRumbleAndLed; +}; + +// Reset all four fixed slot caches to the default profile. +void controller_profile_runtime_reset(); + +// Refresh a slot only when its connection key or database generation changes, +// then transform the raw snapshot. Inactive snapshots return neutral output and +// invalidate the slot immediately. +ControllerProfileTransformResult controller_profile_runtime_transform( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot); + +// Refresh from the current slot snapshot and scale host-originated rumble. +ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble); + +// Preserve local confirmation rumble exactly while exposing profile policy. +ControllerProfileRuntimeLocalConfirmation +controller_profile_runtime_local_confirmation( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble); diff --git a/controller_profile_transform.cpp b/controller_profile_transform.cpp new file mode 100644 index 0000000..9039035 --- /dev/null +++ b/controller_profile_transform.cpp @@ -0,0 +1,344 @@ +#include "controller_profile_transform.h" + +#include + +namespace { + +constexpr uint32_t kQ16One = 1u << 16; + +constexpr uint16_t button_bit(ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + +int32_t clamp_centered_axis(int16_t value, int16_t center) { + const int32_t adjusted = + static_cast(value) - static_cast(center); + if (adjusted < INT16_MIN) { + return INT16_MIN; + } + if (adjusted > INT16_MAX) { + return INT16_MAX; + } + return adjusted; +} + +uint32_t absolute_axis(int32_t value) { + return value < 0 ? static_cast(-value) + : static_cast(value); +} + +// This endpoint-preserving rational curve is x / (c + (1 - c) * x), +// evaluated with x in Q16 and c in Q8.8. Values above 1.0 reduce the +// response below the linear curve; values below 1.0 increase it. +uint32_t apply_curve_q16(uint32_t input_q16, uint16_t curve_q8_8) { + if (input_q16 == 0 || input_q16 == kQ16One || curve_q8_8 == 256) { + return input_q16; + } + + const uint64_t denominator = + static_cast(curve_q8_8) * + (kQ16One - input_q16) + + static_cast(256) * input_q16; + const uint64_t numerator = + static_cast(input_q16) * 256u * kQ16One; + const uint64_t curved = (numerator + denominator / 2u) / denominator; + return curved > kQ16One ? kQ16One + : static_cast(curved); +} + +bool is_default_stick_configuration( + const ControllerProfileStickConfiguration& configuration) { + return configuration.center_x == 0 && configuration.center_y == 0 && + configuration.inner_deadzone == 0 && + configuration.outer_saturation == 32767 && + configuration.curve_q8_8 == 256 && !configuration.invert_x && + !configuration.invert_y; +} + +bool is_default_trigger_configuration( + const ControllerProfileTriggerConfiguration& configuration) { + return configuration.lower_deadzone == 0 && + configuration.upper_saturation == UINT16_MAX && + configuration.curve_q8_8 == 256; +} + +int16_t scale_stick_axis(int32_t adjusted_axis, uint32_t magnitude, + uint32_t response_q16, bool invert) { + if (adjusted_axis == 0 || magnitude == 0 || response_q16 == 0) { + return 0; + } + + bool negative = adjusted_axis < 0; + if (invert) { + negative = !negative; + } + const uint32_t axis_magnitude = absolute_axis(adjusted_axis); + const uint32_t output_limit = + negative ? static_cast(-static_cast(INT16_MIN)) + : static_cast(INT16_MAX); + const uint64_t numerator = + static_cast(axis_magnitude) * output_limit * + response_q16; + const uint64_t denominator = + static_cast(magnitude) * kQ16One; + uint32_t output_magnitude = static_cast( + (numerator + denominator / 2u) / denominator); + if (output_magnitude > output_limit) { + output_magnitude = output_limit; + } + + if (!negative) { + return static_cast(output_magnitude); + } + if (output_magnitude == + static_cast(-static_cast(INT16_MIN))) { + return INT16_MIN; + } + return static_cast(-static_cast(output_magnitude)); +} + +void transform_stick(const ControllerProfileStickConfiguration& configuration, + int16_t input_x, int16_t input_y, int16_t* output_x, + int16_t* output_y) { + if (is_default_stick_configuration(configuration)) { + *output_x = input_x; + *output_y = input_y; + return; + } + + const int32_t adjusted_x = + clamp_centered_axis(input_x, configuration.center_x); + const int32_t adjusted_y = + clamp_centered_axis(input_y, configuration.center_y); + const uint32_t magnitude_x = absolute_axis(adjusted_x); + const uint32_t magnitude_y = absolute_axis(adjusted_y); + const uint32_t magnitude = + magnitude_x > magnitude_y ? magnitude_x : magnitude_y; + + uint32_t response_q16 = 0; + if (magnitude <= configuration.inner_deadzone) { + response_q16 = 0; + } else if (magnitude >= configuration.outer_saturation) { + response_q16 = kQ16One; + } else { + const uint32_t input_range = + static_cast(configuration.outer_saturation) - + configuration.inner_deadzone; + const uint32_t input_offset = + magnitude - configuration.inner_deadzone; + const uint32_t normalized_q16 = static_cast( + (static_cast(input_offset) * kQ16One + + input_range / 2u) / + input_range); + response_q16 = + apply_curve_q16(normalized_q16, configuration.curve_q8_8); + } + + *output_x = scale_stick_axis(adjusted_x, magnitude, response_q16, + configuration.invert_x); + *output_y = scale_stick_axis(adjusted_y, magnitude, response_q16, + configuration.invert_y); +} + +uint16_t transform_trigger( + uint16_t input, + const ControllerProfileTriggerConfiguration& configuration) { + if (is_default_trigger_configuration(configuration)) { + return input; + } + if (input <= configuration.lower_deadzone) { + return 0; + } + if (input >= configuration.upper_saturation) { + return UINT16_MAX; + } + + const uint32_t input_range = + static_cast(configuration.upper_saturation) - + configuration.lower_deadzone; + const uint32_t input_offset = + static_cast(input) - configuration.lower_deadzone; + if (configuration.curve_q8_8 == 256) { + return static_cast( + (static_cast(input_offset) * UINT16_MAX + + input_range / 2u) / + input_range); + } + + const uint32_t normalized_q16 = static_cast( + (static_cast(input_offset) * kQ16One + + input_range / 2u) / + input_range); + const uint32_t curved_q16 = + apply_curve_q16(normalized_q16, configuration.curve_q8_8); + return static_cast( + (static_cast(curved_q16) * UINT16_MAX + + kQ16One / 2u) / + kQ16One); +} + +} // namespace + +uint16_t controller_profile_extract_button_mask(const ControllerState& state) { + uint16_t mask = 0; + mask |= state.button_south + ? button_bit(ControllerProfileLogicalButton::kSouth) + : 0; + mask |= state.button_east + ? button_bit(ControllerProfileLogicalButton::kEast) + : 0; + mask |= state.button_west + ? button_bit(ControllerProfileLogicalButton::kWest) + : 0; + mask |= state.button_north + ? button_bit(ControllerProfileLogicalButton::kNorth) + : 0; + mask |= state.button_left_shoulder + ? button_bit(ControllerProfileLogicalButton::kLeftShoulder) + : 0; + mask |= state.button_right_shoulder + ? button_bit(ControllerProfileLogicalButton::kRightShoulder) + : 0; + mask |= state.button_select + ? button_bit(ControllerProfileLogicalButton::kSelect) + : 0; + mask |= state.button_start + ? button_bit(ControllerProfileLogicalButton::kStart) + : 0; + mask |= state.button_system + ? button_bit(ControllerProfileLogicalButton::kSystem) + : 0; + mask |= state.button_capture + ? button_bit(ControllerProfileLogicalButton::kCapture) + : 0; + mask |= state.button_left_stick + ? button_bit(ControllerProfileLogicalButton::kLeftStick) + : 0; + mask |= state.button_right_stick + ? button_bit(ControllerProfileLogicalButton::kRightStick) + : 0; + mask |= state.dpad_up + ? button_bit(ControllerProfileLogicalButton::kDpadUp) + : 0; + mask |= state.dpad_down + ? button_bit(ControllerProfileLogicalButton::kDpadDown) + : 0; + mask |= state.dpad_left + ? button_bit(ControllerProfileLogicalButton::kDpadLeft) + : 0; + mask |= state.dpad_right + ? button_bit(ControllerProfileLogicalButton::kDpadRight) + : 0; + return mask; +} + +void controller_profile_apply_button_mask(uint16_t button_mask, + ControllerState* state) { + if (state == nullptr) { + return; + } + state->button_south = + (button_mask & button_bit(ControllerProfileLogicalButton::kSouth)) != 0; + state->button_east = + (button_mask & button_bit(ControllerProfileLogicalButton::kEast)) != 0; + state->button_west = + (button_mask & button_bit(ControllerProfileLogicalButton::kWest)) != 0; + state->button_north = + (button_mask & button_bit(ControllerProfileLogicalButton::kNorth)) != 0; + state->button_left_shoulder = + (button_mask & + button_bit(ControllerProfileLogicalButton::kLeftShoulder)) != 0; + state->button_right_shoulder = + (button_mask & + button_bit(ControllerProfileLogicalButton::kRightShoulder)) != 0; + state->button_select = + (button_mask & button_bit(ControllerProfileLogicalButton::kSelect)) != 0; + state->button_start = + (button_mask & button_bit(ControllerProfileLogicalButton::kStart)) != 0; + state->button_system = + (button_mask & button_bit(ControllerProfileLogicalButton::kSystem)) != 0; + state->button_capture = + (button_mask & button_bit(ControllerProfileLogicalButton::kCapture)) != 0; + state->button_left_stick = + (button_mask & + button_bit(ControllerProfileLogicalButton::kLeftStick)) != 0; + state->button_right_stick = + (button_mask & + button_bit(ControllerProfileLogicalButton::kRightStick)) != 0; + state->dpad_up = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadUp)) != 0; + state->dpad_down = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadDown)) != 0; + state->dpad_left = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadLeft)) != 0; + state->dpad_right = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadRight)) != 0; +} + +uint16_t controller_profile_map_button_mask( + uint16_t input_button_mask, const ControllerProfile& profile) { + uint16_t output_button_mask = 0; + for (uint8_t input = 0; + input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) { + if ((input_button_mask & static_cast(1u << input)) == 0) { + continue; + } + const uint8_t output = profile.button_map[input]; + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + output_button_mask |= static_cast(1u << output); + } + } + return output_button_mask; +} + +ControllerProfileTransformResult controller_profile_transform( + const ControllerState& input, const ControllerProfile& profile) { + ControllerProfileTransformResult result{}; + result.state = input; + const uint16_t input_button_mask = + controller_profile_extract_button_mask(input); + controller_profile_apply_button_mask( + controller_profile_map_button_mask(input_button_mask, profile), + &result.state); + + transform_stick(profile.sticks[0], input.left_stick_x, + input.left_stick_y, &result.state.left_stick_x, + &result.state.left_stick_y); + transform_stick(profile.sticks[1], input.right_stick_x, + input.right_stick_y, &result.state.right_stick_x, + &result.state.right_stick_y); + result.state.left_trigger = transform_trigger(input.left_trigger, + profile.triggers[0]); + result.state.right_trigger = transform_trigger(input.right_trigger, + profile.triggers[1]); + result.left_trigger_digital_threshold = + profile.triggers[0].digital_threshold; + result.right_trigger_digital_threshold = + profile.triggers[1].digital_threshold; + return result; +} + +uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, + uint8_t scale) { + const uint32_t scaled = + (static_cast(magnitude) * scale + UINT8_MAX / 2u) / + UINT8_MAX; + return scaled > UINT8_MAX ? UINT8_MAX + : static_cast(scaled); +} + +ControllerRumbleOutput controller_profile_scale_host_rumble( + const ControllerRumbleOutput& input, const ControllerProfile& profile) { + return { + controller_profile_scale_rumble_magnitude( + input.low_frequency_magnitude, profile.strong_rumble_scale), + controller_profile_scale_rumble_magnitude( + input.high_frequency_magnitude, profile.weak_rumble_scale), + }; +} + +ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( + const ControllerProfile& profile) { + return profile.confirmation_policy; +} diff --git a/controller_profile_transform.h b/controller_profile_transform.h new file mode 100644 index 0000000..54c4904 --- /dev/null +++ b/controller_profile_transform.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "controller_profile.h" +#include "controller_state.h" +#include "switch_haptics.h" + +struct ControllerProfileTransformResult { + ControllerState state{}; + uint16_t left_trigger_digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + uint16_t right_trigger_digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; +}; + +uint16_t controller_profile_extract_button_mask(const ControllerState& state); +void controller_profile_apply_button_mask(uint16_t button_mask, + ControllerState* state); +uint16_t controller_profile_map_button_mask( + uint16_t input_button_mask, const ControllerProfile& profile); + +ControllerProfileTransformResult controller_profile_transform( + const ControllerState& input, const ControllerProfile& profile); + +uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, + uint8_t scale); +ControllerRumbleOutput controller_profile_scale_host_rumble( + const ControllerRumbleOutput& input, const ControllerProfile& profile); +ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( + const ControllerProfile& profile); diff --git a/profile_service.cpp b/profile_service.cpp index f646b38..12ec36c 100644 --- a/profile_service.cpp +++ b/profile_service.cpp @@ -29,13 +29,23 @@ struct ProfileTransaction { uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; }; +struct PublishedActiveProfile { + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ControllerProfile profile{}; +}; + critical_section_t g_lock; bool g_prepared = false; ProfileStorage g_storage; ControllerProfileDatabase g_database; ProfileServiceMetadata g_metadata; +uint32_t g_published_generation = 0; ProfileServiceListSnapshot g_list; ProfileServiceSelectedSnapshot g_selected; +PublishedActiveProfile + g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{}; +uint8_t g_active_profile_count = 0; ProfileTransaction g_transaction; PendingCommand g_command; bool g_identity_dirty = false; @@ -65,6 +75,26 @@ void refresh_list_locked() { } } +void refresh_active_profiles_locked() { + g_active_profile_count = 1; + g_active_profiles[0].identity = controller_identity_global(); + g_active_profiles[0].profile_index = + g_database.fallback_active_profile; + g_active_profiles[0].profile = + g_database.fallback_profiles[g_database.fallback_active_profile]; + for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { + if (!entry.used || + g_active_profile_count >= PROFILE_SERVICE_LIST_CAPACITY) { + continue; + } + PublishedActiveProfile& active = + g_active_profiles[g_active_profile_count++]; + active.identity = entry.identity; + active.profile_index = entry.active_profile; + active.profile = entry.profiles[entry.active_profile]; + } +} + void refresh_selected_locked() { g_selected.metadata = g_metadata; const ControllerProfile* profile = controller_profile_database_get( @@ -84,8 +114,11 @@ void refresh_metadata_locked(ProfileServiceState state) { g_metadata.state = state; g_metadata.generation = stored.valid ? stored.generation : 0; g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; + refresh_active_profiles_locked(); refresh_list_locked(); refresh_selected_locked(); + __atomic_store_n(&g_published_generation, g_metadata.generation, + __ATOMIC_RELEASE); } ConfigurationTransactionStatus database_result_status( @@ -134,8 +167,11 @@ void profile_service_prepare() { } critical_section_init(&g_lock); g_metadata = {}; + __atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED); g_list = {}; g_selected = {}; + g_active_profiles[0] = {}; + g_active_profile_count = 0; g_selected.identity = controller_identity_global(); g_selected.profile_index = 0; g_transaction = {}; @@ -522,27 +558,36 @@ void profile_service_transaction_snapshot( critical_section_exit(&g_lock); } -bool profile_service_active_profile(const ControllerIdentity& identity, - ControllerProfile* output, - uint8_t* profile_index) { - if (output == nullptr || profile_index == nullptr || - !valid_identity(identity)) { - return false; +uint32_t profile_service_database_generation() { + return __atomic_load_n(&g_published_generation, __ATOMIC_ACQUIRE); +} + +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output) { + if (output == nullptr) { + return; } + *output = {}; + if (!valid_identity(identity)) { + return; + } + critical_section_enter_blocking(&g_lock); - if (g_metadata.state != ProfileServiceState::kReady) { - critical_section_exit(&g_lock); - return false; - } - const ControllerProfileDatabaseEntry* entry = - controller_profile_database_find(g_database, identity); - if (entry != nullptr) { - *profile_index = entry->active_profile; - *output = entry->profiles[entry->active_profile]; - } else { - *profile_index = g_database.fallback_active_profile; - *output = g_database.fallback_profiles[*profile_index]; + output->metadata = g_metadata; + if (g_metadata.state == ProfileServiceState::kReady && + g_active_profile_count != 0) { + const PublishedActiveProfile* active = &g_active_profiles[0]; + for (uint8_t index = 1; index < g_active_profile_count; ++index) { + if (controller_identity_equal( + g_active_profiles[index].identity, identity)) { + active = &g_active_profiles[index]; + break; + } + } + output->profile_index = active->profile_index; + output->profile = active->profile; + output->valid = true; } critical_section_exit(&g_lock); - return true; } diff --git a/profile_service.h b/profile_service.h index b96b04a..7c0e803 100644 --- a/profile_service.h +++ b/profile_service.h @@ -42,6 +42,13 @@ struct ProfileServiceSelectedSnapshot { ControllerProfile profile{}; }; +struct ProfileServiceActiveProfileSnapshot { + ProfileServiceMetadata metadata{}; + bool valid = false; + uint8_t profile_index = 0; + ControllerProfile profile{}; +}; + struct ProfileServiceTransactionSnapshot { ProfileServiceMetadata metadata{}; ControllerIdentity identity{}; @@ -78,6 +85,7 @@ void profile_service_selected_snapshot( ProfileServiceSelectedSnapshot* output); void profile_service_transaction_snapshot( ProfileServiceTransactionSnapshot* output); -bool profile_service_active_profile(const ControllerIdentity& identity, - ControllerProfile* output, - uint8_t* profile_index); +uint32_t profile_service_database_generation(); +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output); diff --git a/profile_storage.cpp b/profile_storage.cpp index 14af5ab..50559a3 100644 --- a/profile_storage.cpp +++ b/profile_storage.cpp @@ -236,8 +236,10 @@ bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const { !io_.read(io_.context, bank, 0, header, sizeof(header)) || memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || storage_read_u16(&header[4]) != kRecordFormatVersion || - storage_read_u16(&header[6]) != - CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION || + (storage_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + storage_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || storage_read_u32(&header[12]) != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || profile_storage_crc32(header, kHeaderCrcOffset) != diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 8d5fcc3..d88beea 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -69,7 +69,8 @@ PAIRING_RECORD_CAPACITY = 16 TRANSPORT_UNKNOWN = 0 TRANSPORT_CLASSIC = 1 TRANSPORT_BLE = 2 -PROFILE_SCHEMA_VERSION = 1 +PROFILE_LEGACY_SCHEMA_VERSION = 1 +PROFILE_SCHEMA_VERSION = 2 PROFILE_SIZE = 256 PROFILE_CAPACITY = 4 PROFILE_IDENTITY_CAPACITY = 16 @@ -80,6 +81,8 @@ PROFILE_NONE_BUTTON = 0xFF PROFILE_MACRO_STEP_CAPACITY = 8 PROFILE_MACRO_STEP_SIZE = 19 PROFILE_MAXIMUM_WAIT_MS = 10000 +PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 +PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934 LOGICAL_BUTTONS = ( "south", @@ -513,10 +516,7 @@ class TriggerConfig: ) _require_int(self.curve_q8_8, "trigger curve_q8_8", 1, 0xFFFF) _require_int( - self.digital_threshold, - "trigger digital_threshold", - self.lower_deadzone, - self.upper_saturation, + self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF ) @classmethod @@ -575,6 +575,19 @@ class TriggerConfig: ), ) +def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: + if ( + trigger.digital_threshold + != PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD + ): + return trigger + return TriggerConfig( + trigger.lower_deadzone, + trigger.upper_saturation, + trigger.curve_q8_8, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + ) + @dataclass(frozen=True) class MacroStep: @@ -866,7 +879,9 @@ class ControllerProfile: @classmethod def default(cls) -> ControllerProfile: stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False) - trigger = TriggerConfig(0, 0xFFFF, 256, 0x8000) + trigger = TriggerConfig( + 0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD + ) return cls( button_map=tuple(range(len(LOGICAL_BUTTONS))), left_stick=stick, @@ -889,7 +904,11 @@ class ControllerProfile: if len(payload) != PROFILE_SIZE: raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from(" TransactionStatus: def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: _raise_status(envelope) - if envelope.schema_version != PROFILE_SCHEMA_VERSION: + if envelope.schema_version not in ( + PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_SCHEMA_VERSION, + ): raise ConfigManagerError("unsupported profile-list schema") if not envelope.payload: raise ConfigManagerError("short profile-list payload") @@ -1407,7 +1440,10 @@ def select_profile( def read_selected_profile(device: UsbDevice) -> ControllerProfile: envelope = _control_in(device, OP_PROFILE_READ) _raise_status(envelope) - if envelope.schema_version != PROFILE_SCHEMA_VERSION: + if envelope.schema_version not in ( + PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_SCHEMA_VERSION, + ): raise ConfigManagerError("unsupported profile schema") return ControllerProfile.from_bytes(envelope.payload) diff --git a/switch-pico.cpp b/switch-pico.cpp index 90aac7b..bf889d9 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -7,6 +7,7 @@ #include "hardware/uart.h" #else #include "bluepad32_input_backend.h" +#include "controller_profile_runtime.h" #include "bootsel_pairing_button.h" #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY #include "adapter_host_probe.h" @@ -33,6 +34,10 @@ #ifdef SWITCH_PICO_BLUEPAD32 static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == BLUEPAD32_INPUT_BACKEND_SLOT_COUNT); +static_assert(CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT == + BLUEPAD32_INPUT_BACKEND_SLOT_COUNT); +static_assert(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; static ControllerState g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; @@ -80,7 +85,11 @@ static void on_rumble_from_switch(uint8_t instance, if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { return; } - bluepad32_input_backend_queue_rumble(instance, rumble); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(instance, &snapshot); + bluepad32_input_backend_queue_rumble( + instance, controller_profile_runtime_scale_host_rumble( + instance, snapshot, rumble)); #else if (instance != SWITCH_HID_INSTANCE) { return; @@ -217,6 +226,7 @@ int main() { #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_init(); + controller_profile_runtime_reset(); #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY adapter_host_probe_init(); #endif @@ -241,13 +251,19 @@ int main() { switch_pro_set_rumble_callback(instance, on_rumble_from_switch); g_user_states[instance] = neutral_input(); - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); } #else switch_pro_init(instance); switch_pro_set_rumble_callback(instance, on_rumble_from_switch); g_user_states[instance] = neutral_input(); - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); #endif } #else @@ -255,7 +271,9 @@ int main() { switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE, on_rumble_from_switch); g_user_state = neutral_input(); - switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state); + switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); #endif #ifdef SWITCH_PICO_BLUEPAD32 @@ -292,8 +310,9 @@ int main() { instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { Bluepad32SlotSnapshot snapshot{}; bluepad32_input_backend_snapshot(instance, &snapshot); - g_user_states[instance] = - snapshot.active ? snapshot.state : controller_neutral_state(); + const ControllerProfileTransformResult transformed = + controller_profile_runtime_transform(instance, snapshot); + g_user_states[instance] = transformed.state; #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY bool sent = false; if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { @@ -301,14 +320,20 @@ int main() { g_user_states[instance]); sent = xinput_feasibility_task(instance); } else { - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + transformed.left_trigger_digital_threshold, + transformed.right_trigger_digital_threshold); sent = switch_pro_task(instance); } if (sent) { bluepad32_input_backend_report_sent(instance); } #else - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + transformed.left_trigger_digital_threshold, + transformed.right_trigger_digital_threshold); if (switch_pro_task(instance)) { bluepad32_input_backend_report_sent(instance); } @@ -318,7 +343,9 @@ int main() { bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; ControllerState state = g_user_state; - switch_pro_set_input(SWITCH_HID_INSTANCE, state); + switch_pro_set_input(SWITCH_HID_INSTANCE, state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); (void)switch_pro_task(SWITCH_HID_INSTANCE); #endif log_usb_state(); diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index 631ad2b..fe8a1f2 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -43,6 +43,10 @@ struct MotionQuaternion { struct SwitchProContext { ControllerState input_state{}; + uint16_t left_trigger_threshold = + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + uint16_t right_trigger_threshold = + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; uint8_t report_buffer[SWITCH_PRO_ENDPOINT_SIZE]{}; SwitchProReport switch_report{}; uint8_t last_report_counter = 0; @@ -420,6 +424,8 @@ static ControllerState make_neutral_state() { static void reset_context_runtime(SwitchProContext& context, uint32_t now, bool ready_before_mount) { context.input_state = make_neutral_state(); + context.left_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + context.right_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; memset(context.report_buffer, 0x00, sizeof(context.report_buffer)); context.switch_report = {}; context.switch_report.reportID = 0x30; @@ -747,7 +753,7 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonRightSL = 0; inputs.buttonR = state.button_right_shoulder; inputs.buttonZR = - state.right_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + state.right_trigger >= context.right_trigger_threshold; inputs.buttonMinus = state.button_select; inputs.buttonPlus = state.button_start; inputs.buttonThumbR = state.button_right_stick; @@ -758,7 +764,7 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonLeftSL = 0; inputs.buttonL = state.button_left_shoulder; inputs.buttonZL = - state.left_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + state.left_trigger >= context.left_trigger_threshold; uint16_t left_x = scale16To12(controller_axis_to_unsigned(state.left_stick_x)); @@ -813,10 +819,14 @@ void switch_pro_init(uint8_t instance) { to_ms_since_boot(get_absolute_time()), true); } -void switch_pro_set_input(uint8_t instance, const ControllerState& state) { +void switch_pro_set_input(uint8_t instance, const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold) { SwitchProContext* context = context_for(instance); if (context != nullptr) { context->input_state = state; + context->left_trigger_threshold = left_trigger_threshold; + context->right_trigger_threshold = right_trigger_threshold; } } diff --git a/switch_pro_driver.h b/switch_pro_driver.h index bb1d9ba..5cc51f5 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -27,8 +27,11 @@ constexpr uint16_t SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD = // Initialize one HID instance before entering the main loop. void switch_pro_init(uint8_t instance); -// Update the desired controller state for one HID instance. -void switch_pro_set_input(uint8_t instance, const ControllerState& state); +// Update the desired controller state and digital trigger thresholds for one +// HID instance. +void switch_pro_set_input(uint8_t instance, const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold); // Drive one Switch Pro USB state machine; returns true only when a regular // 0x30 input report was successfully queued. diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h new file mode 100644 index 0000000..eb29b40 --- /dev/null +++ b/tests/controller_profile_legacy_fixtures.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include "controller_profile.h" + +constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +constexpr uint8_t + kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x75, 0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x30, 0x75, + 0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x34, 0x12, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x01, 0xcd, 0xab, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp new file mode 100644 index 0000000..92eb8d7 --- /dev/null +++ b/tests/controller_profile_runtime_test.cpp @@ -0,0 +1,273 @@ +#include "controller_profile_runtime.h" + +#include "controller_identity.h" +#include "controller_profile.h" +#include "profile_service.h" + +#include +#include +#include +#include + +namespace { + +struct FakeProfileRow { + ControllerIdentity identity{}; + uint8_t active_profile = 0; + ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{}; +}; + +std::array rows{}; +uint32_t database_generation = 7; +unsigned active_snapshot_count = 0; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerIdentity make_identity(uint8_t value) { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kClassic; + identity.address[0] = value; + identity.address[5] = static_cast(value + 0x40u); + identity.vendor_id = static_cast(0x1000u + value); + identity.product_id = static_cast(0x2000u + value); + return identity; +} + +void prepare_profiles() { + database_generation = 7; + active_snapshot_count = 0; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + FakeProfileRow& row = rows[slot]; + row = {}; + row.identity = make_identity(static_cast(slot + 1u)); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + row.profiles[profile_index] = + controller_profile_default(row.identity, profile_index); + } + row.profiles[0].triggers[0].digital_threshold = + static_cast(1000u + slot); + row.profiles[0].triggers[1].digital_threshold = + static_cast(5000u + slot); + row.profiles[0].confirmation_policy = + slot == 0 ? ControllerProfileConfirmationPolicy::kLed + : ControllerProfileConfirmationPolicy::kRumble; + } + rows[0].profiles[0].strong_rumble_scale = 0; + rows[0].profiles[0].weak_rumble_scale = UINT8_MAX; + rows[1].profiles[0].strong_rumble_scale = UINT8_MAX; + rows[1].profiles[0].weak_rumble_scale = 0; + controller_profile_runtime_reset(); +} + +Bluepad32SlotSnapshot make_snapshot(uint8_t slot, + uint32_t connection_generation = 1) { + Bluepad32SlotSnapshot snapshot{}; + snapshot.active = true; + snapshot.connection_generation = connection_generation; + snapshot.identity = rows[slot].identity; + snapshot.state = controller_neutral_state(); + return snapshot; +} + +bool motion_equal(const ControllerState& first, + const ControllerState& second) { + return first.motion_sample_count == second.motion_sample_count && + std::memcmp(first.motion_samples, second.motion_samples, + sizeof(first.motion_samples)) == 0; +} + +void test_four_slot_cache_and_unchanged_generation() { + prepare_profiles(); + std::array + snapshots{}; + std::array + transformed{}; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + snapshots[slot] = make_snapshot(slot); + transformed[slot] = + controller_profile_runtime_transform(slot, snapshots[slot]); + require(transformed[slot].left_trigger_digital_threshold == + static_cast(1000u + slot) && + transformed[slot].right_trigger_digital_threshold == + static_cast(5000u + slot), + "a slot did not receive its own cached profile"); + } + require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT, + "initial slot loads did not fetch exactly one profile each"); + + rows[0].profiles[0].triggers[0].digital_threshold = 65000; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + transformed[slot] = + controller_profile_runtime_transform(slot, snapshots[slot]); + } + require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT, + "unchanged generations copied profiles on the report path"); + require(transformed[0].left_trigger_digital_threshold == 1000, + "an unchanged generation bypassed the slot cache"); + + snapshots[2].connection_generation = 2; + transformed[2] = + controller_profile_runtime_transform(2, snapshots[2]); + require(active_snapshot_count == + CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 1 && + transformed[2].left_trigger_digital_threshold == 1002, + "one slot connection generation did not refresh in isolation"); + + snapshots[3].identity = rows[1].identity; + transformed[3] = + controller_profile_runtime_transform(3, snapshots[3]); + require(active_snapshot_count == + CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 2 && + transformed[3].left_trigger_digital_threshold == 1001, + "an exact identity change did not refresh only its slot"); +} + +void test_activation_disconnect_and_default_preservation() { + prepare_profiles(); + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + snapshot.state.button_south = true; + ControllerProfileTransformResult transformed = + controller_profile_runtime_transform(0, snapshot); + require(transformed.state.button_south && + transformed.left_trigger_digital_threshold == 1000, + "initial active profile was not applied"); + + rows[0].active_profile = 1; + rows[0].profiles[1].button_map[ + static_cast(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kNorth); + rows[0].profiles[1].triggers[0].digital_threshold = 12345; + ++database_generation; + transformed = controller_profile_runtime_transform(0, snapshot); + require(!transformed.state.button_south && transformed.state.button_north && + transformed.left_trigger_digital_threshold == 12345, + "profile activation generation did not refresh the slot cache"); + + const unsigned reads_before_disconnect = active_snapshot_count; + snapshot.active = false; + transformed = controller_profile_runtime_transform(0, snapshot); + require(!transformed.state.button_south && !transformed.state.button_north && + transformed.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + active_snapshot_count == reads_before_disconnect, + "disconnect did not neutralize output without fetching a profile"); + + transformed = controller_profile_runtime_transform(0, snapshot); + require(active_snapshot_count == reads_before_disconnect, + "an unchanged inactive slot repeatedly cleared or fetched state"); + snapshot.active = true; + transformed = controller_profile_runtime_transform(0, snapshot); + require(active_snapshot_count == reads_before_disconnect + 1 && + transformed.state.button_north, + "reconnection did not reload a cleared slot cache"); + + Bluepad32SlotSnapshot default_snapshot = make_snapshot(2); + rows[2].profiles[0] = + controller_profile_default(rows[2].identity, 0); + ++database_generation; + default_snapshot.state.button_east = true; + default_snapshot.state.dpad_left = true; + default_snapshot.state.left_trigger = 32123; + default_snapshot.state.right_trigger = 54321; + default_snapshot.state.left_stick_x = -12345; + default_snapshot.state.right_stick_y = 23456; + default_snapshot.state.motion_sample_count = 2; + default_snapshot.state.motion_samples[0] = {1, 2, 3, 4, 5, 6}; + default_snapshot.state.motion_samples[1] = {-1, -2, -3, -4, -5, -6}; + transformed = controller_profile_runtime_transform(2, default_snapshot); + require(transformed.state.button_east && transformed.state.dpad_left && + transformed.state.left_trigger == 32123 && + transformed.state.right_trigger == 54321 && + transformed.state.left_stick_x == -12345 && + transformed.state.right_stick_y == 23456 && + motion_equal(transformed.state, default_snapshot.state) && + transformed.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + transformed.right_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "default runtime profile changed serializer input or motion"); +} + +void test_analog_thresholds_rumble_and_local_confirmation() { + prepare_profiles(); + Bluepad32SlotSnapshot first = make_snapshot(0); + Bluepad32SlotSnapshot second = make_snapshot(1); + first.state.left_trigger = second.state.left_trigger = 23456; + first.state.right_trigger = second.state.right_trigger = 45678; + const ControllerProfileTransformResult first_output = + controller_profile_runtime_transform(0, first); + const ControllerProfileTransformResult second_output = + controller_profile_runtime_transform(1, second); + require(first_output.state.left_trigger == + second_output.state.left_trigger && + first_output.state.right_trigger == + second_output.state.right_trigger && + first_output.left_trigger_digital_threshold != + second_output.left_trigger_digital_threshold, + "digital thresholds changed XInput-visible analog trigger state"); + + const ControllerRumbleOutput host{91, 73}; + const ControllerRumbleOutput first_host = + controller_profile_runtime_scale_host_rumble(0, first, host); + const ControllerRumbleOutput second_host = + controller_profile_runtime_scale_host_rumble(1, second, host); + require(first_host.low_frequency_magnitude == 0 && + first_host.high_frequency_magnitude == 73 && + second_host.low_frequency_magnitude == 91 && + second_host.high_frequency_magnitude == 0, + "host rumble was not scaled through each slot profile"); + + const ControllerProfileRuntimeLocalConfirmation confirmation = + controller_profile_runtime_local_confirmation(0, first, host); + require(confirmation.rumble.low_frequency_magnitude == 91 && + confirmation.rumble.high_frequency_magnitude == 73 && + confirmation.policy == + ControllerProfileConfirmationPolicy::kLed, + "local confirmation was scaled or lost its profile policy"); +} + +} // namespace + +uint32_t profile_service_database_generation() { + return database_generation; +} + +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output) { + if (output == nullptr) { + return; + } + ++active_snapshot_count; + *output = {}; + output->metadata.state = ProfileServiceState::kReady; + output->metadata.generation = database_generation; + for (const FakeProfileRow& row : rows) { + if (!controller_identity_equal(row.identity, identity)) { + continue; + } + output->valid = true; + output->profile_index = row.active_profile; + output->profile = row.profiles[row.active_profile]; + return; + } +} + +int main() { + test_four_slot_cache_and_unchanged_generation(); + test_activation_disconnect_and_default_preservation(); + test_analog_thresholds_rumble_and_local_confirmation(); + return 0; +} diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index a235a26..91d1bac 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -1,5 +1,6 @@ #include "controller_identity.h" #include "controller_profile.h" +#include "tests/controller_profile_legacy_fixtures.h" #include #include @@ -44,9 +45,9 @@ void test_profile_wire_schema() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(profile, encoded, sizeof(encoded)), "default profile did not encode"); - require(encoded[0] == 1 && encoded[1] == 0 && + require(encoded[0] == 2 && encoded[1] == 0 && encoded[2] == 0 && encoded[3] == 1, - "profile header is not little-endian v1/256"); + "profile header is not little-endian v2/256"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { require(encoded[4 + index] == index, @@ -56,7 +57,12 @@ void test_profile_wire_schema() { encoded[30] == 0, "default stick encoding changed"); require(encoded[54] == 0xff && encoded[55] == 0xff && - encoded[58] == 0x00 && encoded[59] == 0x80, + encoded[58] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[59] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), "default trigger encoding changed"); require(encoded[72] == 0xff && encoded[73] == 0xff && encoded[74] == 3 && encoded[78] == 0xff && @@ -84,11 +90,35 @@ void test_profile_wire_schema() { invalid.sticks[0].outer_saturation; require(!controller_profile_validate(invalid), "empty stick range was accepted"); + ControllerProfile boundary = profile; + boundary.triggers[0].lower_deadzone = 30000; + boundary.triggers[0].upper_saturation = 40000; + boundary.triggers[0].digital_threshold = 0; + uint8_t boundary_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(boundary, boundary_encoded, + sizeof(boundary_encoded)) && + controller_profile_decode(boundary_encoded, + sizeof(boundary_encoded), + &decoded) && + decoded.triggers[0].digital_threshold == 0, + "current profile rejected zero transformed trigger threshold"); + boundary.triggers[0].digital_threshold = UINT16_MAX; + require(controller_profile_encode(boundary, boundary_encoded, + sizeof(boundary_encoded)) && + controller_profile_decode(boundary_encoded, + sizeof(boundary_encoded), + &decoded) && + decoded.triggers[0].digital_threshold == UINT16_MAX, + "current profile rejected maximum transformed trigger threshold"); invalid = profile; - invalid.triggers[0].digital_threshold = 0; - invalid.triggers[0].lower_deadzone = 1; + invalid.triggers[0].lower_deadzone = + invalid.triggers[0].upper_saturation; require(!controller_profile_validate(invalid), - "trigger threshold outside its range was accepted"); + "empty raw trigger range was accepted"); + invalid.triggers[0].lower_deadzone = UINT16_MAX; + invalid.triggers[0].upper_saturation = UINT16_MAX - 1; + require(!controller_profile_validate(invalid), + "reversed raw trigger range was accepted"); invalid = profile; invalid.turbo_modes[0] = static_cast(3); @@ -110,6 +140,97 @@ void test_profile_wire_schema() { "macro without a final end was accepted"); } +void test_legacy_profile_migration() { + ControllerProfile migrated{}; + require(controller_profile_decode( + kLegacyDefaultProfile, sizeof(kLegacyDefaultProfile), + &migrated), + "legacy default profile did not decode"); + require(migrated.triggers[0].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + migrated.triggers[1].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "legacy inherited thresholds were not migrated"); + + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(migrated, encoded, sizeof(encoded)), + "migrated default profile did not encode"); + for (size_t index = 0; index < sizeof(encoded); ++index) { + const bool schema_byte = index == 0; + const bool threshold_byte = + (index >= 58 && index < 60) || + (index >= 68 && index < 70); + if (!schema_byte && !threshold_byte) { + require(encoded[index] == kLegacyDefaultProfile[index], + "legacy default profile changed an unrelated byte"); + } + } + require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION && + encoded[58] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[59] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && + encoded[68] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[69] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), + "migrated default profile did not encode as v2"); + + require(controller_profile_decode( + kLegacyNarrowRawRangeProfile, + sizeof(kLegacyNarrowRawRangeProfile), &migrated), + "legacy narrow-raw-range profile did not decode"); + require(migrated.triggers[0].lower_deadzone == 30000 && + migrated.triggers[0].upper_saturation == 40000 && + migrated.triggers[0].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + migrated.triggers[1].lower_deadzone == 30000 && + migrated.triggers[1].upper_saturation == 40000 && + migrated.triggers[1].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "legacy narrow raw range or inherited threshold was not migrated"); + require(controller_profile_encode(migrated, encoded, sizeof(encoded)), + "migrated narrow-raw-range profile did not encode"); + for (size_t index = 0; index < sizeof(encoded); ++index) { + const bool schema_byte = index == 0; + const bool threshold_byte = + (index >= 58 && index < 60) || + (index >= 68 && index < 70); + if (!schema_byte && !threshold_byte) { + require( + encoded[index] == kLegacyNarrowRawRangeProfile[index], + "narrow-raw-range migration changed unrelated profile data"); + } + } + + require(controller_profile_decode( + kLegacyCustomThresholdProfile, + sizeof(kLegacyCustomThresholdProfile), &migrated), + "legacy custom-threshold profile did not decode"); + require(migrated.triggers[0].digital_threshold == 0x1234 && + migrated.triggers[1].digital_threshold == 0xabcd, + "legacy custom thresholds were not preserved"); + require(controller_profile_encode(migrated, encoded, sizeof(encoded)), + "legacy custom-threshold profile did not re-encode"); + for (size_t index = 1; index < sizeof(encoded); ++index) { + require(encoded[index] == kLegacyCustomThresholdProfile[index], + "legacy custom-threshold profile changed data"); + } + + ControllerProfile current = + controller_profile_default(controller_identity_global(), 0); + current.triggers[0].digital_threshold = 0x8000; + require(controller_profile_encode(current, encoded, sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), + &migrated) && + migrated.triggers[0].digital_threshold == 0x8000, + "v2 custom threshold matching the legacy default was migrated"); +} + void test_database_round_trip_and_capacity() { controller_profile_database_default(&database); for (uint8_t index = 0; @@ -141,6 +262,10 @@ void test_database_round_trip_and_capacity() { database, offset, &encoded_database[offset], size), "database range did not encode"); } + require(encoded_database[4] == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && + encoded_database[5] == 0, + "database encoder did not emit v2"); require(controller_profile_database_decode( read_encoded_database, nullptr, &decoded_database), "database did not decode"); @@ -157,6 +282,7 @@ void test_database_round_trip_and_capacity() { } // namespace int main() { test_profile_wire_schema(); + test_legacy_profile_migration(); test_database_round_trip_and_capacity(); return 0; } diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp new file mode 100644 index 0000000..e398638 --- /dev/null +++ b/tests/controller_profile_transform_test.cpp @@ -0,0 +1,385 @@ +#include "controller_identity.h" +#include "controller_profile.h" +#include "controller_profile_transform.h" + +#include +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerProfile default_profile() { + return controller_profile_default(controller_identity_global(), 0); +} + +bool states_equal(const ControllerState& left, const ControllerState& right) { + return left.dpad_up == right.dpad_up && + left.dpad_down == right.dpad_down && + left.dpad_left == right.dpad_left && + left.dpad_right == right.dpad_right && + left.button_south == right.button_south && + left.button_east == right.button_east && + left.button_west == right.button_west && + left.button_north == right.button_north && + left.button_left_shoulder == right.button_left_shoulder && + left.button_right_shoulder == right.button_right_shoulder && + left.button_select == right.button_select && + left.button_start == right.button_start && + left.button_system == right.button_system && + left.button_capture == right.button_capture && + left.button_left_stick == right.button_left_stick && + left.button_right_stick == right.button_right_stick && + left.left_trigger == right.left_trigger && + left.right_trigger == right.right_trigger && + left.left_stick_x == right.left_stick_x && + left.left_stick_y == right.left_stick_y && + left.right_stick_x == right.right_stick_x && + left.right_stick_y == right.right_stick_y && + left.motion_sample_count == right.motion_sample_count && + std::memcmp(left.motion_samples, right.motion_samples, + sizeof(left.motion_samples)) == 0; +} + +ControllerProfileTransformResult transform_left_stick( + const ControllerProfileStickConfiguration& configuration, int16_t x, + int16_t y) { + ControllerProfile profile = default_profile(); + profile.sticks[0] = configuration; + ControllerState state{}; + state.left_stick_x = x; + state.left_stick_y = y; + return controller_profile_transform(state, profile); +} + +uint16_t transform_left_trigger( + const ControllerProfileTriggerConfiguration& configuration, + uint16_t value) { + ControllerProfile profile = default_profile(); + profile.triggers[0] = configuration; + ControllerState state{}; + state.left_trigger = value; + return controller_profile_transform(state, profile).state.left_trigger; +} + +void test_button_masks_and_direct_mapping() { + ControllerState state{}; + controller_profile_apply_button_mask(UINT16_MAX, &state); + require(controller_profile_extract_button_mask(state) == UINT16_MAX, + "button mask application omitted a logical button"); + controller_profile_apply_button_mask(0, &state); + require(controller_profile_extract_button_mask(state) == 0, + "zero button mask did not clear every logical button"); + controller_profile_apply_button_mask(UINT16_MAX, nullptr); + + for (uint8_t input = 0; + input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) { + ControllerProfile profile = default_profile(); + const uint8_t output = static_cast( + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT - 1u - input); + profile.button_map[input] = output; + state = {}; + controller_profile_apply_button_mask( + static_cast(1u << input), &state); + const ControllerProfileTransformResult transformed = + controller_profile_transform(state, profile); + require(controller_profile_extract_button_mask(transformed.state) == + static_cast(1u << output), + "a logical button did not map directly to its output"); + } + + ControllerProfile profile = default_profile(); + profile.button_map[0] = 1; + profile.button_map[1] = 2; + require(controller_profile_map_button_mask(1u, profile) == 2u, + "button mapping recursively remapped an output"); + profile.button_map[1] = 1; + require(controller_profile_map_button_mask(3u, profile) == 2u, + "duplicate mapped outputs were not combined"); + profile.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON; + require(controller_profile_map_button_mask(1u, profile) == 0, + "disabled button mapping still produced output"); + + ControllerProfile invalid = default_profile(); + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + require(!controller_profile_validate(invalid), + "logical output 16 was accepted"); + invalid.button_map[0] = 0xfe; + require(!controller_profile_validate(invalid), + "logical output 0xfe was accepted"); + invalid.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON; + require(controller_profile_validate(invalid), + "disabled logical output 0xff was rejected"); +} + +void test_stick_center_boundaries_and_inversion() { + ControllerProfileStickConfiguration configuration{}; + configuration.center_x = 1234; + configuration.center_y = -2345; + configuration.inner_deadzone = 1000; + configuration.outer_saturation = 20000; + configuration.curve_q8_8 = 256; + ControllerProfileTransformResult transformed = transform_left_stick( + configuration, configuration.center_x, configuration.center_y); + require(transformed.state.left_stick_x == 0 && + transformed.state.left_stick_y == 0, + "center calibration did not precede stick shaping"); + + transformed = transform_left_stick( + configuration, + static_cast(configuration.center_x + + configuration.inner_deadzone), + configuration.center_y); + require(transformed.state.left_stick_x == 0 && + transformed.state.left_stick_y == 0, + "inner deadzone boundary was not neutral"); + transformed = transform_left_stick( + configuration, + static_cast(configuration.center_x + + configuration.inner_deadzone + 1), + configuration.center_y); + require(transformed.state.left_stick_x > 0, + "first value outside inner deadzone stayed neutral"); + + configuration.center_x = 0; + configuration.center_y = 0; + configuration.inner_deadzone = 0; + configuration.outer_saturation = 20000; + transformed = transform_left_stick(configuration, 19999, 0); + require(transformed.state.left_stick_x > 0 && + transformed.state.left_stick_x < INT16_MAX, + "value below outer saturation reached an endpoint"); + transformed = transform_left_stick(configuration, 20000, 0); + require(transformed.state.left_stick_x == INT16_MAX, + "positive outer saturation boundary missed endpoint"); + transformed = transform_left_stick(configuration, -20000, 0); + require(transformed.state.left_stick_x == INT16_MIN, + "negative outer saturation boundary missed endpoint"); + + configuration.outer_saturation = 32767; + configuration.invert_x = true; + configuration.invert_y = true; + transformed = transform_left_stick(configuration, INT16_MIN, 0); + require(transformed.state.left_stick_x == INT16_MAX, + "negative stick endpoint did not invert to positive endpoint"); + transformed = transform_left_stick(configuration, 0, INT16_MAX); + require(transformed.state.left_stick_y == INT16_MIN, + "positive stick endpoint did not invert to negative endpoint"); + + configuration.invert_x = false; + configuration.invert_y = false; + configuration.outer_saturation = 20000; + transformed = transform_left_stick(configuration, 15000, 15000); + require(transformed.state.left_stick_x < INT16_MAX && + transformed.state.left_stick_y < INT16_MAX, + "stick magnitude was not Chebyshev magnitude"); +} + +void test_stick_curves_and_monotonicity() { + ControllerProfileStickConfiguration linear{}; + linear.inner_deadzone = 0; + linear.outer_saturation = 32767; + linear.curve_q8_8 = 256; + ControllerProfileStickConfiguration slow = linear; + slow.curve_q8_8 = 512; + ControllerProfileStickConfiguration fast = linear; + fast.curve_q8_8 = 128; + + const int16_t linear_mid = + transform_left_stick(linear, 16384, 0).state.left_stick_x; + const int16_t slow_mid = + transform_left_stick(slow, 16384, 0).state.left_stick_x; + const int16_t fast_mid = + transform_left_stick(fast, 16384, 0).state.left_stick_x; + require(slow_mid < linear_mid && linear_mid < fast_mid, + "stick curve directions are reversed or ineffective"); + require(transform_left_stick(slow, 0, 0).state.left_stick_x == 0 && + transform_left_stick(fast, 32767, 0) + .state.left_stick_x == INT16_MAX, + "stick response curve did not preserve endpoints"); + + const uint16_t curves[] = {1, 128, 256, 512, UINT16_MAX}; + for (uint16_t curve : curves) { + ControllerProfileStickConfiguration configuration = linear; + configuration.curve_q8_8 = curve; + int16_t previous = 0; + for (int32_t input = 0; input <= INT16_MAX; ++input) { + const int16_t output = transform_left_stick( + configuration, + static_cast(input), 0) + .state.left_stick_x; + require(output >= previous, + "stick response was not monotonic"); + previous = output; + } + require(previous == INT16_MAX, + "monotonic stick response missed positive endpoint"); + } +} + +void test_trigger_boundaries_curves_and_thresholds() { + ControllerProfileTriggerConfiguration configuration{}; + configuration.lower_deadzone = 1000; + configuration.upper_saturation = 60000; + configuration.curve_q8_8 = 256; + configuration.digital_threshold = 32000; + require(transform_left_trigger(configuration, 999) == 0 && + transform_left_trigger(configuration, 1000) == 0, + "trigger lower deadzone boundary was not zero"); + require(transform_left_trigger(configuration, 1001) > 0, + "first trigger value above lower deadzone stayed zero"); + require(transform_left_trigger(configuration, 59999) < UINT16_MAX && + transform_left_trigger(configuration, 60000) == UINT16_MAX && + transform_left_trigger(configuration, UINT16_MAX) == + UINT16_MAX, + "trigger upper saturation boundary missed full scale"); + + ControllerProfileTriggerConfiguration slow = configuration; + slow.curve_q8_8 = 512; + ControllerProfileTriggerConfiguration fast = configuration; + fast.curve_q8_8 = 128; + const uint16_t midpoint = static_cast( + (static_cast(configuration.lower_deadzone) + + configuration.upper_saturation) / + 2u); + const uint16_t linear_mid = + transform_left_trigger(configuration, midpoint); + const uint16_t slow_mid = transform_left_trigger(slow, midpoint); + const uint16_t fast_mid = transform_left_trigger(fast, midpoint); + require(slow_mid < linear_mid && linear_mid < fast_mid, + "trigger curve directions are reversed or ineffective"); + require(transform_left_trigger(slow, configuration.lower_deadzone) == 0 && + transform_left_trigger(fast, + configuration.upper_saturation) == + UINT16_MAX, + "trigger response curve did not preserve endpoints"); + + uint16_t previous = 0; + for (uint32_t input = 0; input <= UINT16_MAX; ++input) { + const uint16_t output = transform_left_trigger( + slow, static_cast(input)); + require(output >= previous, + "trigger response was not monotonic"); + previous = output; + } + + ControllerProfile profile = default_profile(); + profile.triggers[0] = slow; + profile.triggers[0].digital_threshold = 12345; + profile.triggers[1].digital_threshold = 54321; + ControllerState state{}; + state.left_trigger = midpoint; + const ControllerProfileTransformResult transformed = + controller_profile_transform(state, profile); + require(transformed.left_trigger_digital_threshold == 12345 && + transformed.right_trigger_digital_threshold == 54321, + "profile-owned digital thresholds were not returned"); +} + +void test_default_whole_state_equivalence() { + ControllerState input{}; + input.dpad_up = true; + input.dpad_right = true; + input.button_south = true; + input.button_west = true; + input.button_left_shoulder = true; + input.button_select = true; + input.button_system = true; + input.button_capture = true; + input.button_right_stick = true; + input.left_trigger = 0; + input.right_trigger = UINT16_MAX; + input.left_stick_x = INT16_MIN; + input.left_stick_y = INT16_MAX; + input.right_stick_x = INT16_MAX; + input.right_stick_y = INT16_MIN; + input.motion_sample_count = CONTROLLER_MOTION_SAMPLE_CAPACITY; + input.motion_samples[0] = + {INT16_MIN, -30000, -1, 0, 1, INT16_MAX}; + input.motion_samples[1] = {1, 2, 3, 4, 5, 6}; + input.motion_samples[2] = + {INT16_MAX, 30000, 1, 0, -1, INT16_MIN}; + + const ControllerProfile profile = default_profile(); + const ControllerProfileTransformResult transformed = + controller_profile_transform(input, profile); + require(states_equal(input, transformed.state), + "default profile changed whole controller state or motion"); + require(transformed.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + transformed.right_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD == 22934, + "default digital threshold changed"); + + for (uint32_t trigger = 0; trigger <= UINT16_MAX; ++trigger) { + ControllerState trigger_state{}; + trigger_state.left_trigger = static_cast(trigger); + trigger_state.right_trigger = static_cast(trigger); + const ControllerState output = + controller_profile_transform(trigger_state, profile).state; + require(output.left_trigger == trigger_state.left_trigger && + output.right_trigger == trigger_state.right_trigger, + "default profile lost full trigger analog precision"); + } +} + +void test_rumble_scaling_and_confirmation_policy() { + ControllerProfile profile = default_profile(); + for (uint16_t magnitude = 0; magnitude <= UINT8_MAX; ++magnitude) { + const ControllerRumbleOutput input{ + static_cast(magnitude), + static_cast(UINT8_MAX - magnitude), + }; + const ControllerRumbleOutput output = + controller_profile_scale_host_rumble(input, profile); + require(output.low_frequency_magnitude == + input.low_frequency_magnitude && + output.high_frequency_magnitude == + input.high_frequency_magnitude, + "default rumble scaling was not bit-exact identity"); + } + + profile.strong_rumble_scale = 0; + profile.weak_rumble_scale = 0; + ControllerRumbleOutput output = controller_profile_scale_host_rumble( + {UINT8_MAX, UINT8_MAX}, profile); + require(output.low_frequency_magnitude == 0 && + output.high_frequency_magnitude == 0, + "zero rumble scales did not mute both bands"); + + profile.strong_rumble_scale = 128; + profile.weak_rumble_scale = 64; + output = controller_profile_scale_host_rumble({200, 201}, profile); + require(output.low_frequency_magnitude == 100 && + output.high_frequency_magnitude == 50, + "strong/weak mid-scale rumble mapping was incorrect"); + require(controller_profile_scale_rumble_magnitude(UINT8_MAX, + UINT8_MAX) == + UINT8_MAX, + "full rumble scaling did not saturate at uint8 maximum"); + + profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed; + require(controller_profile_confirmation_policy(profile) == + ControllerProfileConfirmationPolicy::kLed, + "confirmation policy was not exposed unchanged"); +} + +} // namespace + +int main() { + test_button_masks_and_direct_mapping(); + test_stick_center_boundaries_and_inversion(); + test_stick_curves_and_monotonicity(); + test_trigger_boundaries_curves_and_thresholds(); + test_default_whole_state_equivalence(); + test_rumble_scaling_and_confirmation_policy(); + return 0; +} diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 0239a39..646fd9b 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -82,6 +82,13 @@ ProfileServiceTransactionSnapshot transaction_snapshot() { return snapshot; } +ProfileServiceActiveProfileSnapshot active_profile_snapshot( + const ControllerIdentity& identity) { + ProfileServiceActiveProfileSnapshot snapshot{}; + profile_service_active_profile_snapshot(identity, &snapshot); + return snapshot; +} + ControllerProfileDatabase reload_database( const ProfileServiceTransactionSnapshot& transaction, uint32_t expected_generation) { @@ -102,6 +109,14 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { memset(flash.bytes, 0xff, sizeof(flash.bytes)); profile_service_prepare(); profile_service_initialize_on_storage_core(); + ProfileServiceActiveProfileSnapshot active = + active_profile_snapshot(controller_identity_global()); + require(active.valid && + active.metadata.state == ProfileServiceState::kReady && + active.metadata.generation == 0 && + profile_service_database_generation() == 0 && + active.profile_index == 0, + "initial active profile snapshot was not coherent"); const ControllerIdentity identity = controller_identity_global(); constexpr uint8_t kProfileIndex = 2; @@ -128,6 +143,11 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { require(transaction_snapshot().transaction.status == ConfigurationTransactionStatus::kCommitted, "profile write baseline did not commit"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 1 && + profile_service_database_generation() == 1 && + active.profile_index == 0, + "profile write did not publish one coherent generation"); constexpr uint32_t kResetTransactionId = 0xa5a55a5a; require(profile_service_reset(kResetTransactionId, identity, @@ -150,6 +170,11 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale == UINT8_MAX, "terminal reset status was published before reset persisted"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 2 && + profile_service_database_generation() == 2 && + active.profile_index == 0, + "profile reset did not refresh the active snapshot generation"); constexpr uint32_t kActivateTransactionId = 0x50607080; constexpr uint8_t kActivatedProfile = 3; @@ -162,6 +187,10 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { activate.transaction.status == ConfigurationTransactionStatus::kPending, "pending activation lost its transaction identity"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 2 && + active.profile_index == 0, + "pending activation leaked an uncommitted active profile"); profile_service_task_on_storage_core(2000); activate = transaction_snapshot(); @@ -172,6 +201,14 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { recovered = reload_database(activate, 3); require(recovered.fallback_active_profile == kActivatedProfile, "terminal activation status was published before activation persisted"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 3 && + profile_service_database_generation() == 3 && + active.profile_index == kActivatedProfile && + active.profile.strong_rumble_scale == + recovered.fallback_profiles[kActivatedProfile] + .strong_rumble_scale, + "activation did not publish profile, index, and generation together"); } } // namespace diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index 6301bb0..957a8cb 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -1,6 +1,7 @@ #include "controller_identity.h" #include "controller_profile.h" #include "profile_storage.h" +#include "tests/controller_profile_legacy_fixtures.h" #include #include @@ -15,6 +16,7 @@ struct FakeFlash { bool corrupt_next_program = false; bool fail_reads_after_header_program = false; bool header_programmed = false; + int erase_count = 0; }; FakeFlash flash{}; @@ -35,6 +37,7 @@ void erase_all() { flash.corrupt_next_program = false; flash.fail_reads_after_header_program = false; flash.header_programmed = false; + flash.erase_count = 0; } bool fake_read(void* context, uint8_t bank, size_t offset, @@ -62,6 +65,7 @@ bool fake_erase_sector(void* context, uint8_t bank, size_t offset) { PROFILE_STORAGE_BANK_SIZE - offset) { return false; } + ++storage->erase_count; memset(&storage->bytes[bank][offset], 0xff, PROFILE_STORAGE_SECTOR_SIZE); return true; @@ -107,6 +111,91 @@ ProfileStorageIo fake_io() { }; } +uint16_t fixture_read_u16(const uint8_t* input) { + return static_cast(input[0]) | + static_cast(input[1] << 8); +} + +void fixture_write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void fixture_write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +void install_legacy_database_bank_fixture() { + erase_all(); + constexpr uint8_t kBank = 1; + constexpr uint32_t kGeneration = 41; + constexpr size_t kFallbackOffset = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; + constexpr size_t kEntryOffset = + kFallbackOffset + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = { + 1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1, + }; + + uint8_t* const record = flash.bytes[kBank]; + uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE; + memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE); + memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + + memcpy(payload, "SPDB", 4); + fixture_write_u16(&payload[4], + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); + fixture_write_u16( + &payload[6], + static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); + payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + payload[9] = CONTROLLER_PROFILE_COUNT; + payload[10] = 2; + payload[11] = 1; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint8_t* fixture = + profile_index == 0 + ? kLegacyNarrowRawRangeProfile + : profile_index == 1 ? kLegacyCustomThresholdProfile + : kLegacyDefaultProfile; + memcpy(&payload[kFallbackOffset + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], + fixture, CONTROLLER_PROFILE_ENCODED_SIZE); + } + + memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader)); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint8_t* fixture = + profile_index == 0 + ? kLegacyNarrowRawRangeProfile + : profile_index == 3 ? kLegacyCustomThresholdProfile + : kLegacyDefaultProfile; + memcpy(&payload[kEntryOffset + + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], + fixture, CONTROLLER_PROFILE_ENCODED_SIZE); + } + + const uint32_t payload_crc = profile_storage_crc32( + payload, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + memcpy(record, "SPPF", 4); + fixture_write_u16(&record[4], 1); + fixture_write_u16(&record[6], + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); + fixture_write_u32(&record[8], kGeneration); + fixture_write_u32( + &record[12], + static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); + fixture_write_u32(&record[16], payload_crc); + fixture_write_u32(&record[20], profile_storage_crc32(record, 20)); +} + void test_two_bank_recovery() { erase_all(); controller_profile_database_default(&database); @@ -225,11 +314,180 @@ void test_payload_corruption_prevents_header_publication() { "headerless corrupt payload was recovered"); } +void test_legacy_database_bank_migration() { + install_legacy_database_bank_fixture(); + ProfileStorage storage; + require(storage.initialize(fake_io(), &recovered_database) && + storage.snapshot().valid && + storage.snapshot().active_bank == 1 && + storage.snapshot().generation == 41, + "legacy v1 database bank was not selected"); + require(flash.erase_count == 0, + "legacy bank admission erased flash"); + require(recovered_database.fallback_active_profile == 2, + "legacy fallback active profile was not preserved"); + + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint16_t expected_left = + profile_index == 1 + ? 0x1234 + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + const uint16_t expected_right = + profile_index == 1 + ? 0xabcd + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + require(recovered_database.fallback_profiles[profile_index] + .triggers[0] + .digital_threshold == expected_left && + recovered_database.fallback_profiles[profile_index] + .triggers[1] + .digital_threshold == expected_right, + "legacy fallback thresholds were not selectively migrated"); + } + require(recovered_database.fallback_profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + recovered_database.fallback_profiles[0] + .triggers[0] + .upper_saturation == 40000 && + recovered_database.fallback_profiles[0] + .triggers[1] + .lower_deadzone == 30000 && + recovered_database.fallback_profiles[0] + .triggers[1] + .upper_saturation == 40000, + "legacy fallback raw trigger ranges were not preserved"); + + const ControllerProfileDatabaseEntry& entry = + recovered_database.entries[0]; + require(entry.used && entry.active_profile == 3 && + entry.identity.stable && + entry.identity.transport == ControllerTransport::kClassic && + entry.identity.address_type == 7 && + entry.identity.address[0] == 1 && + entry.identity.address[5] == 6 && + entry.identity.vendor_id == 0x057e && + entry.identity.product_id == 0x2009, + "legacy entry identity or active profile was not preserved"); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint16_t expected_left = + profile_index == 3 + ? 0x1234 + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + const uint16_t expected_right = + profile_index == 3 + ? 0xabcd + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + require(entry.profiles[profile_index] + .triggers[0] + .digital_threshold == expected_left && + entry.profiles[profile_index] + .triggers[1] + .digital_threshold == expected_right, + "legacy entry thresholds were not selectively migrated"); + } + require(entry.profiles[0].triggers[0].lower_deadzone == 30000 && + entry.profiles[0].triggers[0].upper_saturation == 40000 && + entry.profiles[0].triggers[1].lower_deadzone == 30000 && + entry.profiles[0].triggers[1].upper_saturation == 40000, + "legacy entry raw trigger ranges were not preserved"); + + recovered_database.fallback_profiles[2].weak_rumble_scale = 17; + require(storage.commit(recovered_database) == + ProfileStorageResult::kOk && + storage.snapshot().active_bank == 0 && + storage.snapshot().generation == 42, + "mutation after legacy admission did not commit"); + const uint8_t* const current_record = flash.bytes[0]; + const uint8_t* const current_payload = + current_record + PROFILE_STORAGE_RECORD_HEADER_SIZE; + require(fixture_read_u16(¤t_record[6]) == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && + fixture_read_u16(¤t_payload[4]) == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION, + "post-migration commit did not emit v2 storage schemas"); + constexpr size_t kFallbackOffset = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; + constexpr size_t kEntryOffset = + kFallbackOffset + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + require(fixture_read_u16( + ¤t_payload[kFallbackOffset + + profile_index * + CONTROLLER_PROFILE_ENCODED_SIZE]) == + CONTROLLER_PROFILE_SCHEMA_VERSION && + fixture_read_u16( + ¤t_payload[ + kEntryOffset + + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * + CONTROLLER_PROFILE_ENCODED_SIZE]) == + CONTROLLER_PROFILE_SCHEMA_VERSION, + "post-migration commit retained a v1 profile"); + } + require(fixture_read_u16(&flash.bytes[1][6]) == + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + fixture_read_u16( + &flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) == + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION, + "post-migration commit erased or rewrote the admitted legacy bank"); + + ProfileStorage reloaded; + require(reloaded.initialize(fake_io(), &database) && + reloaded.snapshot().generation == 42 && + database.fallback_profiles[2].weak_rumble_scale == 17 && + database.fallback_profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + database.fallback_profiles[0] + .triggers[0] + .upper_saturation == 40000 && + database.fallback_profiles[0] + .triggers[0] + .digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + database.fallback_profiles[1] + .triggers[0] + .digital_threshold == 0x1234 && + database.fallback_profiles[1] + .triggers[1] + .digital_threshold == 0xabcd && + database.entries[0].used && + database.entries[0].active_profile == 3 && + database.entries[0] + .profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + database.entries[0] + .profiles[0] + .triggers[0] + .upper_saturation == 40000 && + database.entries[0] + .profiles[0] + .triggers[0] + .digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + database.entries[0] + .profiles[3] + .triggers[0] + .digital_threshold == 0x1234 && + database.entries[0] + .profiles[3] + .triggers[1] + .digital_threshold == 0xabcd, + "v2 migration commit did not reload without data loss"); +} + } // namespace int main() { test_two_bank_recovery(); test_interrupted_commit_retains_previous_bank(); test_successful_header_program_is_commit_point(); test_payload_corruption_prevents_header_publication(); + test_legacy_database_bank_migration(); return 0; } diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index 3130f38..ce7dfac 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -289,7 +289,9 @@ void test_input_reports_and_timers_are_isolated() { states[3].button_west = true; for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { - switch_pro_set_input(instance, states[instance]); + switch_pro_set_input(instance, states[instance], + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); } now_ms = 15; @@ -328,7 +330,9 @@ void test_input_reports_and_timers_are_isolated() { ControllerState changed_zero = states[0]; changed_zero.button_east = false; changed_zero.button_system = true; - switch_pro_set_input(0, changed_zero); + switch_pro_set_input(0, changed_zero, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 30; expect(switch_pro_task(0), "instance 0 did not apply its changed input state"); @@ -341,7 +345,9 @@ void test_input_reports_and_timers_are_isolated() { ControllerState changed_three = states[3]; changed_three.button_west = false; changed_three.button_capture = true; - switch_pro_set_input(3, changed_three); + switch_pro_set_input(3, changed_three, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 45; expect(switch_pro_task(3), "instance 3 did not apply its changed input state"); @@ -370,8 +376,10 @@ void test_callback_send_and_imu_modes_are_isolated() { ControllerState one = zero; one.button_north = true; one.motion_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006}; - switch_pro_set_input(0, zero); - switch_pro_set_input(1, one); + switch_pro_set_input(0, zero, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + switch_pro_set_input(1, one, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 21; expect(switch_pro_task(0), "raw-IMU instance did not send input"); expect(switch_pro_task(1), "off-IMU instance timer did not send input"); @@ -400,8 +408,11 @@ void test_callback_send_and_imu_modes_are_isolated() { stationary.right_stick_x = stationary.right_stick_y = 0; stationary.motion_sample_count = 1; stationary.motion_samples[0] = {1000, 2000, 3000, 0, 0, 0}; - switch_pro_set_input(0, moving); - switch_pro_set_input(1, stationary); + switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + switch_pro_set_input(1, stationary, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 21; expect(switch_pro_task(0), "moving quaternion instance did not report"); expect(switch_pro_task(1), "stationary quaternion timer crossed instances"); @@ -578,7 +589,9 @@ void test_lifecycle_and_invalid_instances() { ControllerState ignored{}; ignored.button_system = true; switch_pro_init(kInvalidInstance); - switch_pro_set_input(kInvalidInstance, ignored); + switch_pro_set_input(kInvalidInstance, ignored, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback); expect(!switch_pro_task(kInvalidInstance), "invalid instance ran a driver task"); @@ -598,7 +611,8 @@ void test_protocol_neutral_trigger_threshold() { state.left_trigger = static_cast(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD - 1u); state.right_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; - switch_pro_set_input(0, state); + switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 15; expect(switch_pro_task(0), "trigger threshold report was not sent"); SwitchProReport report = copy_switch_report(latest_regular_report(0)); @@ -607,7 +621,8 @@ void test_protocol_neutral_trigger_threshold() { state.left_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; state.right_trigger = CONTROLLER_TRIGGER_MIN; - switch_pro_set_input(0, state); + switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 30; expect(switch_pro_task(0), "second trigger threshold report was not sent"); report = copy_switch_report(latest_regular_report(0)); @@ -615,13 +630,36 @@ void test_protocol_neutral_trigger_threshold() { "Switch trigger threshold changed at the upper boundary"); } +void test_custom_trigger_thresholds_are_isolated() { + initialize_contexts(); + ControllerState state{}; + state.left_trigger = 300; + state.right_trigger = 300; + switch_pro_set_input(0, state, 300, 301); + switch_pro_set_input(1, state, 301, 300); + + now_ms = 15; + expect(switch_pro_task(0) && switch_pro_task(1), + "custom trigger threshold reports were not sent"); + const SwitchProReport first = + copy_switch_report(latest_regular_report(0)); + const SwitchProReport second = + copy_switch_report(latest_regular_report(1)); + expect(first.inputs.buttonZL && !first.inputs.buttonZR, + "instance 0 did not use its exact left/right trigger thresholds"); + expect(!second.inputs.buttonZL && second.inputs.buttonZR, + "instance 1 trigger thresholds crossed HID contexts"); +} + void test_uart_parser_is_pure() { initialize_contexts(); ControllerState driver_state{}; driver_state.left_stick_x = driver_state.left_stick_y = driver_state.right_stick_x = driver_state.right_stick_y = 0; driver_state.button_north = true; - switch_pro_set_input(0, driver_state); + switch_pro_set_input(0, driver_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 15; switch_pro_task(0); @@ -724,6 +762,7 @@ int main() { test_grip_colors_are_isolated(); test_lifecycle_and_invalid_instances(); test_protocol_neutral_trigger_threshold(); + test_custom_trigger_thresholds_are_isolated(); test_uart_parser_is_pure(); if (failures != 0) { std::cerr << failures << " driver context test(s) failed\n"; diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 5819074..0cd83d5 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -500,6 +500,13 @@ def test_identity_and_profile_binary_json_round_trip() -> None: with pytest.raises(config_manager.ConfigManagerError): config_manager.ControllerIdentity.from_bytes(malformed_identity) + default_profile = config_manager.ControllerProfile.default() + assert default_profile.left_trigger.digital_threshold == 22934 + assert default_profile.right_trigger.digital_threshold == 22934 + default_wire = default_profile.to_bytes() + assert struct.unpack_from(" None: assert config_manager.ControllerProfile.from_bytes(encoded) == profile serialized = profile.to_json() - assert serialized.startswith('{\n "schema_version": 1,\n "size": 256,') + assert serialized.startswith('{\n "schema_version": 2,\n "size": 256,') decoded = config_manager.ControllerProfile.from_json(serialized) assert decoded == profile assert decoded.to_json() == serialized + legacy_default_wire = bytearray(default_wire) + struct.pack_into( + " None: + for threshold in (0, 0xFFFF): + trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold) + assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger + assert ( + config_manager.TriggerConfig.from_json_object( + trigger.to_json_object(), "trigger" + ) + == trigger + ) + + current_wire = bytearray( + config_manager.ControllerProfile.default().to_bytes() + ) + struct.pack_into( + " None: device = FakeDevice() diff --git a/tests/test_controller_profile_runtime_native.py b/tests/test_controller_profile_runtime_native.py new file mode 100644 index 0000000..92ebce1 --- /dev/null +++ b/tests/test_controller_profile_runtime_native.py @@ -0,0 +1,32 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_profile_runtime_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "controller_profile_runtime_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_profile_runtime_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "controller_profile_transform.cpp"), + str(root / "controller_profile_runtime.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_controller_profile_transform_native.py b/tests/test_controller_profile_transform_native.py new file mode 100644 index 0000000..474cdb6 --- /dev/null +++ b/tests/test_controller_profile_transform_native.py @@ -0,0 +1,31 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_profile_transform_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "controller_profile_transform_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_profile_transform_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "controller_profile_transform.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 093c8b3..27d9e2c 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -279,7 +279,12 @@ void test_profile_vendor_requests() { control_payload.size() == kResponseHeaderSize + CONTROLLER_PROFILE_ENCODED_SIZE && - control_payload[kResponseHeaderSize] == 1 && + control_payload[10] == + CONTROLLER_PROFILE_SCHEMA_VERSION && + control_payload[kResponseHeaderSize] == + static_cast( + CONTROLLER_PROFILE_SCHEMA_VERSION) && + control_payload[kResponseHeaderSize + 1] == 0 && control_payload[kResponseHeaderSize + 2] == 0 && control_payload[kResponseHeaderSize + 3] == 1, "selected profile response was not encoded");