diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index b78ed88..6ea621b 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -24,11 +24,9 @@ constexpr int32_t kAxisMinimum = -512; constexpr int32_t kAxisMaximum = 511; constexpr int32_t kTriggerMaximum = 1023; constexpr uint16_t kSwitchHostRumbleDurationMs = 50; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY // XInput vibration is stateful and remains active until XInputSetState sends // a new magnitude. constexpr uint16_t kXInputHostRumbleDurationMs = UINT16_MAX; -#endif constexpr uint32_t kRumblePollIntervalMs = 5; constexpr uint32_t kConfigurationPollIntervalMs = 50; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; @@ -42,16 +40,15 @@ constexpr uint8_t kAllBlePairingMethods = SM_STK_GENERATION_METHOD_OOB | SM_STK_GENERATION_METHOD_PASSKEY | SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON; -constexpr uint32_t kAbxyHotkeyButtonMask = - SWITCH_ABXY_HOTKEY_BUTTON_MASK; -constexpr uint32_t kAbxyHotkeyMiscMask = SWITCH_ABXY_HOTKEY_MISC_MASK; -constexpr bool kDefaultSwapAbxy = SWITCH_ABXY_DEFAULT_SWAPPED != 0; -constexpr uint32_t kAbxyFeedbackDurationMs = - SWITCH_ABXY_FEEDBACK_DURATION_MS; -constexpr uint8_t kAbxyFeedbackWeakMagnitude = - SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE; -constexpr uint8_t kAbxyFeedbackStrongMagnitude = - SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE; +constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75; +constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX; +constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX; +constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = { + {0x00, 0x55, 0xff}, + {0x00, 0xcc, 0x66}, + {0xff, 0xaa, 0x00}, + {0xcc, 0x33, 0xff}, +}; constexpr uint32_t kMotionHotkeyDpadMask = SWITCH_MOTION_HOTKEY_DPAD_MASK; constexpr uint32_t kMotionHotkeyButtonMask = @@ -73,9 +70,8 @@ constexpr uint8_t kMotionEnabledFeedbackWeakMagnitude = constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; -static_assert(kAbxyHotkeyButtonMask != 0); -static_assert(kAbxyHotkeyMiscMask != 0); -static_assert(kAbxyFeedbackDurationMs > 0); +static_assert(kProfileFeedbackPhaseDurationMs == 75); +static_assert(CONTROLLER_PROFILE_COUNT == 4); static_assert(kMotionHotkeyDpadMask != 0); static_assert(kMotionHotkeyButtonMask != 0); static_assert(kMotionHotkeyMiscMask != 0); @@ -104,6 +100,7 @@ struct RumbleEnvelope { uint8_t slot; uint32_t connection_generation; ControllerRumbleOutput rumble; + uint16_t duration_ms; }; struct FeedbackEnvelope { uint32_t connection_generation; @@ -111,6 +108,23 @@ struct FeedbackEnvelope { uint8_t weak_magnitude; uint8_t strong_magnitude; }; +struct ProfileFeedbackEnvelope { + uint32_t connection_generation; + uint8_t active_profile_number; + ControllerProfileConfirmationPolicy policy; +}; + +struct ProfileFeedbackSequence { + uint32_t connection_generation; + uint32_t phase_deadline_ms; + uint8_t pulse_count; + uint8_t pulses_started; + bool active; + bool on; + bool rumble_enabled; + bool led_enabled; +}; + // Security Manager identity events arrive before Bluepad32 publishes a ready // device. Retain only the four live handle/address associations so a BLE RPA @@ -126,6 +140,7 @@ struct BleIdentityMapping { struct BackendSlot { ControllerState state; + uint16_t pre_hotkey_button_mask; ControllerIdentity identity; // Non-null with active=false is a connected device still becoming ready. uni_hid_device_t* device; @@ -133,14 +148,17 @@ struct BackendSlot { uint32_t connection_generation; bool active; bool rumble_pending; - bool swap_abxy; - bool abxy_hotkey_latched; bool motion_enabled; bool motion_hotkey_latched; bool feedback_pending; uint32_t feedback_until_ms; + bool profile_feedback_pending; RumbleEnvelope pending_rumble; + bool retained_host_rumble_valid; + RumbleEnvelope retained_host_rumble; FeedbackEnvelope pending_feedback; + ProfileFeedbackEnvelope pending_profile_feedback; + ProfileFeedbackSequence profile_feedback; }; critical_section_t g_state_lock; @@ -409,6 +427,31 @@ void apply_slot_lighting(uint8_t slot_index, uni_hid_device_t* device) { device, static_cast(1u << slot_index)); } } +bool valid_confirmation_policy( + ControllerProfileConfirmationPolicy policy) { + return static_cast(policy) <= + static_cast( + ControllerProfileConfirmationPolicy::kRumbleAndLed); +} + +void apply_profile_lighting( + uint8_t active_profile_number, uni_hid_device_t* device) { + if (device == nullptr || active_profile_number == 0 || + active_profile_number > CONTROLLER_PROFILE_COUNT) { + return; + } + if (device->report_parser.set_lightbar_color != nullptr) { + const SwitchRgbColor color = + kProfileLightbarPalette[active_profile_number - 1u]; + device->report_parser.set_lightbar_color( + device, color.red, color.green, color.blue); + } else if (device->report_parser.set_player_leds != nullptr) { + device->report_parser.set_player_leds( + device, static_cast( + (1u << active_profile_number) - 1u)); + } +} + ConnectionStatus compute_connection_status() { @@ -430,11 +473,13 @@ ConnectionStatus compute_connection_status() { } void publish_device_state(uint8_t slot, uni_hid_device_t* device, + uint16_t pre_hotkey_button_mask, const ControllerState& state) { critical_section_enter_blocking(&g_state_lock); BackendSlot& target = g_slots[slot]; if (target.active && target.device == device) { target.state = state; + target.pre_hotkey_button_mask = pre_hotkey_button_mask; ++target.state_generation; } critical_section_exit(&g_state_lock); @@ -444,10 +489,18 @@ void publish_all_neutral() { critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { slot.state = make_neutral_state(); + slot.pre_hotkey_button_mask = 0; slot.identity = controller_identity_global(); slot.device = nullptr; slot.active = false; slot.rumble_pending = false; + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; + slot.feedback_pending = false; + slot.feedback_until_ms = 0; + slot.profile_feedback_pending = false; + slot.pending_profile_feedback = {}; + slot.profile_feedback = {}; ++slot.state_generation; ++slot.connection_generation; } @@ -541,32 +594,129 @@ bool has_motion(const uni_gamepad_t& gamepad) { } return false; } +constexpr uint16_t logical_button_bit( + ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + +constexpr uint16_t logical_button_mask( + uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) { + return static_cast( + ((buttons & BUTTON_A) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSouth) + : 0u) | + ((buttons & BUTTON_B) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kEast) + : 0u) | + ((buttons & BUTTON_X) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kWest) + : 0u) | + ((buttons & BUTTON_Y) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kNorth) + : 0u) | + ((buttons & BUTTON_SHOULDER_L) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder) + : 0u) | + ((buttons & BUTTON_SHOULDER_R) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder) + : 0u) | + ((misc_buttons & MISC_BUTTON_SELECT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSelect) + : 0u) | + ((misc_buttons & MISC_BUTTON_START) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kStart) + : 0u) | + ((misc_buttons & MISC_BUTTON_SYSTEM) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSystem) + : 0u) | + ((misc_buttons & MISC_BUTTON_CAPTURE) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kCapture) + : 0u) | + ((buttons & BUTTON_THUMB_L) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kLeftStick) + : 0u) | + ((buttons & BUTTON_THUMB_R) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kRightStick) + : 0u) | + ((dpad & DPAD_UP) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadUp) + : 0u) | + ((dpad & DPAD_DOWN) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadDown) + : 0u) | + ((dpad & DPAD_LEFT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadLeft) + : 0u) | + ((dpad & DPAD_RIGHT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadRight) + : 0u)); +} + +constexpr uint16_t kMotionHotkeyLogicalButtonMask = + logical_button_mask( + kMotionHotkeyDpadMask, kMotionHotkeyButtonMask, + kMotionHotkeyMiscMask); + +uint16_t logical_button_mask(const uni_gamepad_t& gamepad) { + return logical_button_mask( + gamepad.dpad, gamepad.buttons, gamepad.misc_buttons); +} ControllerState map_gamepad(const uni_gamepad_t& gamepad, - bool swap_abxy, - bool motion_enabled) { + bool motion_enabled, + uint16_t button_mask) { ControllerState state = make_neutral_state(); - state.dpad_up = (gamepad.dpad & DPAD_UP) != 0; - state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0; - state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0; - state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0; + state.dpad_up = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadUp)) != 0; + state.dpad_down = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadDown)) != 0; + state.dpad_left = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadLeft)) != 0; + state.dpad_right = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadRight)) != 0; - // Bluepad32's A/B/X/Y are positional: south/east/west/north. - state.button_south = (gamepad.buttons & BUTTON_A) != 0; - state.button_east = (gamepad.buttons & BUTTON_B) != 0; - state.button_west = (gamepad.buttons & BUTTON_X) != 0; - state.button_north = (gamepad.buttons & BUTTON_Y) != 0; - if (swap_abxy) { - bool temporary = state.button_east; - state.button_east = state.button_south; - state.button_south = temporary; - temporary = state.button_north; - state.button_north = state.button_west; - state.button_west = temporary; - } - state.button_left_shoulder = (gamepad.buttons & BUTTON_SHOULDER_L) != 0; - state.button_right_shoulder = (gamepad.buttons & BUTTON_SHOULDER_R) != 0; + // Bluepad32's A/B/X/Y are positional: south/east/west/north. Persistent + // profile mappings are the only button remapping layer. + state.button_south = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSouth)) != 0; + state.button_east = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kEast)) != 0; + state.button_west = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kWest)) != 0; + state.button_north = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kNorth)) != 0; + state.button_left_shoulder = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder)) != 0; + state.button_right_shoulder = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder)) != 0; state.left_trigger = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 ? UINT16_MAX @@ -575,13 +725,25 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, (gamepad.buttons & BUTTON_TRIGGER_R) != 0 ? UINT16_MAX : scale_trigger(gamepad.throttle); - state.button_left_stick = (gamepad.buttons & BUTTON_THUMB_L) != 0; - state.button_right_stick = (gamepad.buttons & BUTTON_THUMB_R) != 0; + state.button_left_stick = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kLeftStick)) != 0; + state.button_right_stick = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kRightStick)) != 0; - state.button_select = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0; - state.button_start = (gamepad.misc_buttons & MISC_BUTTON_START) != 0; - state.button_system = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0; - state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0; + state.button_select = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSelect)) != 0; + state.button_start = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kStart)) != 0; + state.button_system = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSystem)) != 0; + state.button_capture = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kCapture)) != 0; state.left_stick_x = scale_axis(gamepad.axis_x); state.left_stick_y = scale_axis(gamepad.axis_y); @@ -606,11 +768,11 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, return state; } struct HotkeyDecision { - bool swap_abxy; bool motion_enabled; uint32_t suppress_dpad; uint32_t suppress_buttons; uint32_t suppress_misc_buttons; + uint16_t suppress_logical_buttons; }; void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, @@ -622,24 +784,23 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, strong_magnitude}; } void reset_slot_hotkeys(BackendSlot& slot) { - slot.swap_abxy = kDefaultSwapAbxy; - slot.abxy_hotkey_latched = false; slot.motion_enabled = kDefaultMotionEnabled; slot.motion_hotkey_latched = false; + slot.pre_hotkey_button_mask = 0; slot.feedback_pending = false; slot.feedback_until_ms = 0; slot.pending_feedback = {}; + slot.profile_feedback_pending = false; + slot.pending_profile_feedback = {}; + slot.profile_feedback = {}; + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; } HotkeyDecision update_controller_hotkeys( uint8_t slot_index, uni_hid_device_t* device, const uni_gamepad_t& gamepad) { - const bool abxy_pressed = - (gamepad.buttons & kAbxyHotkeyButtonMask) == - kAbxyHotkeyButtonMask && - (gamepad.misc_buttons & kAbxyHotkeyMiscMask) == - kAbxyHotkeyMiscMask; const bool motion_pressed = (gamepad.dpad & kMotionHotkeyDpadMask) == kMotionHotkeyDpadMask && @@ -648,18 +809,12 @@ HotkeyDecision update_controller_hotkeys( (gamepad.misc_buttons & kMotionHotkeyMiscMask) == kMotionHotkeyMiscMask; HotkeyDecision decision{ - kDefaultSwapAbxy, kDefaultMotionEnabled, 0, 0, 0}; + kDefaultMotionEnabled, 0, 0, 0, 0}; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device == device) { - if (abxy_pressed && !slot.abxy_hotkey_latched) { - slot.swap_abxy = !slot.swap_abxy; - queue_local_feedback( - slot, static_cast(kAbxyFeedbackDurationMs), - kAbxyFeedbackWeakMagnitude, - kAbxyFeedbackStrongMagnitude); - } else if (motion_pressed && !slot.motion_hotkey_latched) { + if (motion_pressed && !slot.motion_hotkey_latched) { slot.motion_enabled = !slot.motion_enabled; if (slot.motion_enabled) { queue_local_feedback( @@ -673,18 +828,14 @@ HotkeyDecision update_controller_hotkeys( kMotionDisabledFeedbackStrongMagnitude); } } - slot.abxy_hotkey_latched = abxy_pressed; slot.motion_hotkey_latched = motion_pressed; - decision.swap_abxy = slot.swap_abxy; decision.motion_enabled = slot.motion_enabled; - if (abxy_pressed) { - decision.suppress_buttons |= kAbxyHotkeyButtonMask; - decision.suppress_misc_buttons |= kAbxyHotkeyMiscMask; - } if (motion_pressed) { decision.suppress_dpad |= kMotionHotkeyDpadMask; decision.suppress_buttons |= kMotionHotkeyButtonMask; decision.suppress_misc_buttons |= kMotionHotkeyMiscMask; + decision.suppress_logical_buttons |= + kMotionHotkeyLogicalButtonMask; } } critical_section_exit(&g_state_lock); @@ -995,13 +1146,56 @@ void apply_connection_policy() { } } +bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) { + return static_cast(now_ms - deadline_ms) >= 0; +} + +bool advance_profile_feedback(ProfileFeedbackSequence* sequence, + uint32_t now_ms) { + bool rumble_dispatch = false; + for (uint8_t transition = 0; + transition < CONTROLLER_PROFILE_COUNT * 2u && + sequence->active && + deadline_reached(now_ms, sequence->phase_deadline_ms); + ++transition) { + sequence->phase_deadline_ms += + kProfileFeedbackPhaseDurationMs; + if (sequence->on) { + sequence->on = false; + rumble_dispatch = false; + } else if (sequence->pulses_started >= + sequence->pulse_count) { + sequence->active = false; + } else { + sequence->on = true; + ++sequence->pulses_started; + rumble_dispatch = sequence->rumble_enabled; + } + } + return rumble_dispatch; +} + void update_status_led() { ++g_status_led_tick; const uint32_t now_ms = btstack_run_loop_get_time_ms(); - bool led_on = false; + bool profile_led_override = false; + bool profile_led_on = false; + critical_section_enter_blocking(&g_state_lock); + for (const BackendSlot& slot : g_slots) { + if (slot.profile_feedback.active && + slot.profile_feedback.led_enabled) { + profile_led_override = true; + profile_led_on = + profile_led_on || slot.profile_feedback.on; + } + } + critical_section_exit(&g_state_lock); - if (static_cast( - now_ms - g_pairing_reset_feedback_deadline_ms) < 0) { + bool led_on = false; + if (profile_led_override) { + led_on = profile_led_on; + } else if (static_cast( + now_ms - g_pairing_reset_feedback_deadline_ms) < 0) { led_on = (g_status_led_tick % 20) < 10; } else if (pairing_window_active_at(now_ms)) { const uint16_t phase = g_status_led_tick % 200; @@ -1037,17 +1231,95 @@ void process_rumble_timer(btstack_timer_source_t* timer) { if (update_pairing_window(now_ms)) { apply_connection_policy(); } + const bool xinput_host_mode = + host_rumble_duration_ms() == kXInputHostRumbleDurationMs; for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { RumbleEnvelope envelope{}; FeedbackEnvelope feedback{}; + ProfileFeedbackEnvelope profile_feedback{}; uni_hid_device_t* device = nullptr; + bool profile_lighting_dispatch = false; + bool profile_rumble_dispatch = false; bool feedback_dispatch = false; bool host_dispatch = false; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - if (slot.feedback_pending) { + if (slot.retained_host_rumble_valid && + (!xinput_host_mode || + slot.retained_host_rumble.duration_ms != + kXInputHostRumbleDurationMs || + slot.retained_host_rumble.slot != slot_index || + slot.retained_host_rumble.connection_generation != + slot.connection_generation || + !slot.active || slot.device == nullptr)) { + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; + } + if (slot.profile_feedback.active && + slot.profile_feedback.connection_generation != + slot.connection_generation) { + slot.profile_feedback = {}; + } + const bool profile_feedback_was_active = + slot.profile_feedback.active; + const bool completed_feedback_had_rumble = + slot.profile_feedback.rumble_enabled; + profile_rumble_dispatch = + advance_profile_feedback(&slot.profile_feedback, now_ms); + if (profile_feedback_was_active && + !slot.profile_feedback.active && + completed_feedback_had_rumble && + slot.retained_host_rumble_valid) { + slot.pending_rumble = slot.retained_host_rumble; + slot.rumble_pending = true; + } + if (profile_rumble_dispatch) { + device = slot.device; + } + + const bool feedback_active = + static_cast(now_ms - slot.feedback_until_ms) < 0; + if (!slot.profile_feedback.active && !feedback_active && + slot.profile_feedback_pending) { + profile_feedback = slot.pending_profile_feedback; + slot.profile_feedback_pending = false; + const uint8_t policy = + static_cast(profile_feedback.policy); + if (slot.active && slot.device != nullptr && + profile_feedback.connection_generation == + slot.connection_generation && + profile_feedback.active_profile_number != 0 && + profile_feedback.active_profile_number <= + CONTROLLER_PROFILE_COUNT && + valid_confirmation_policy(profile_feedback.policy) && + profile_feedback.policy != + ControllerProfileConfirmationPolicy::kNone) { + slot.profile_feedback = { + slot.connection_generation, + now_ms + kProfileFeedbackPhaseDurationMs, + profile_feedback.active_profile_number, + 1, + true, + true, + (policy & static_cast( + ControllerProfileConfirmationPolicy:: + kRumble)) != 0, + (policy & static_cast( + ControllerProfileConfirmationPolicy:: + kLed)) != 0, + }; + device = slot.device; + profile_lighting_dispatch = + slot.profile_feedback.led_enabled; + profile_rumble_dispatch = + slot.profile_feedback.rumble_enabled; + } + } + + if (!slot.profile_feedback.active && + slot.feedback_pending) { feedback = slot.pending_feedback; feedback_dispatch = slot.active && slot.device != nullptr && @@ -1062,10 +1334,12 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } } - const bool feedback_active = - static_cast(now_ms - slot.feedback_until_ms) < 0; - if (!feedback_dispatch && !feedback_active && - slot.rumble_pending) { + const bool local_feedback_active = + slot.profile_feedback.active || + static_cast( + now_ms - slot.feedback_until_ms) < 0; + if (!profile_rumble_dispatch && !feedback_dispatch && + !local_feedback_active && slot.rumble_pending) { envelope = slot.pending_rumble; slot.rumble_pending = false; host_dispatch = @@ -1079,7 +1353,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } critical_section_exit(&g_state_lock); - if (feedback_dispatch) { + if (profile_lighting_dispatch) { + apply_profile_lighting( + profile_feedback.active_profile_number, device); + } + if (profile_rumble_dispatch && device != nullptr && + device->report_parser.play_dual_rumble != nullptr) { + device->report_parser.play_dual_rumble( + device, 0, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, + kProfileFeedbackStrongMagnitude); + } else if (feedback_dispatch) { device->report_parser.play_dual_rumble( device, 0, feedback.duration_ms, feedback.weak_magnitude, feedback.strong_magnitude); @@ -1089,7 +1373,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { envelope.rumble.low_frequency_magnitude == 0 && envelope.rumble.high_frequency_magnitude == 0; device->report_parser.play_dual_rumble( - device, 0, stop ? 0 : host_rumble_duration_ms(), + device, 0, stop ? 0 : envelope.duration_ms, envelope.rumble.high_frequency_magnitude, envelope.rumble.low_frequency_magnitude); } @@ -1273,15 +1557,20 @@ void platform_on_controller_data(uni_hid_device_t* device, } uni_gamepad_t gamepad = controller->gamepad; + const uint16_t pre_hotkey_button_mask = + logical_button_mask(gamepad); const HotkeyDecision hotkeys = update_controller_hotkeys( static_cast(slot_index), device, gamepad); gamepad.dpad &= ~hotkeys.suppress_dpad; gamepad.buttons &= ~hotkeys.suppress_buttons; gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons; + const uint16_t output_button_mask = static_cast( + pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons); publish_device_state( static_cast(slot_index), device, - map_gamepad(gamepad, hotkeys.swap_abxy, - hotkeys.motion_enabled)); + pre_hotkey_button_mask, + map_gamepad( + gamepad, hotkeys.motion_enabled, output_button_mask)); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -1462,6 +1751,8 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index, out->active = slot.active; out->connection_generation = slot.connection_generation; out->identity = slot.identity; + out->pre_hotkey_button_mask = + slot.pre_hotkey_button_mask; out->state = slot.state; const uint32_t state_generation = slot.state_generation; critical_section_exit(&g_state_lock); @@ -1485,11 +1776,45 @@ void bluepad32_input_backend_queue_rumble( return; } + const uint16_t duration_ms = host_rumble_duration_ms(); critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device != nullptr) { - slot.pending_rumble = {slot_index, slot.connection_generation, rumble}; + const RumbleEnvelope envelope{ + slot_index, slot.connection_generation, rumble, + duration_ms}; + slot.pending_rumble = envelope; slot.rumble_pending = true; + if (duration_ms == kXInputHostRumbleDurationMs) { + slot.retained_host_rumble = envelope; + slot.retained_host_rumble_valid = true; + } else { + slot.retained_host_rumble = {}; + slot.retained_host_rumble_valid = false; + } + } + critical_section_exit(&g_state_lock); +} + +void bluepad32_input_backend_queue_profile_feedback( + uint8_t slot_index, uint32_t connection_generation, + uint8_t active_profile_number, + ControllerProfileConfirmationPolicy policy) { + if (!g_initialized || !valid_slot(slot_index) || + active_profile_number == 0 || + active_profile_number > CONTROLLER_PROFILE_COUNT || + !valid_confirmation_policy(policy) || + policy == ControllerProfileConfirmationPolicy::kNone) { + return; + } + + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && slot.device != nullptr && + slot.connection_generation == connection_generation) { + slot.pending_profile_feedback = { + connection_generation, active_profile_number, policy}; + slot.profile_feedback_pending = true; } critical_section_exit(&g_state_lock); } diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 4d359b8..0600d13 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -4,6 +4,7 @@ #include "controller_color.h" #include "controller_identity.h" +#include "controller_profile.h" #include "controller_state.h" #include "switch_haptics.h" @@ -37,6 +38,9 @@ struct Bluepad32SlotSnapshot { bool active; uint32_t connection_generation; ControllerIdentity identity; + // Physical logical-button state before backend hotkey consumption. + // Valid only for this snapshot's connection generation. + uint16_t pre_hotkey_button_mask; ControllerState state; }; @@ -54,3 +58,9 @@ void bluepad32_input_backend_pairing_snapshot( void bluepad32_input_backend_report_sent(uint8_t slot); void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); +// Schedule bounded local profile confirmation only for the matching live +// connection generation. Profile numbers are one-based (1..4). +void bluepad32_input_backend_queue_profile_feedback( + uint8_t slot, uint32_t connection_generation, + uint8_t active_profile_number, + ControllerProfileConfirmationPolicy policy); diff --git a/controller_hotkey_config.h b/controller_hotkey_config.h index ebd2cc0..818515f 100644 --- a/controller_hotkey_config.h +++ b/controller_hotkey_config.h @@ -2,26 +2,12 @@ #pragma once -// Bluepad32 button masks. Default chord: L + R + SELECT + START. -#define SWITCH_ABXY_HOTKEY_BUTTON_MASK \ - (BUTTON_SHOULDER_L | BUTTON_SHOULDER_R) -#define SWITCH_ABXY_HOTKEY_MISC_MASK \ - (MISC_BUTTON_SELECT | MISC_BUTTON_START) - // Motion toggle chord: D-pad Up + R + START / Options. #define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP #define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R #define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START #define SWITCH_MOTION_DEFAULT_ENABLED 1 -// 0 starts each new connection in Nintendo positional layout; 1 starts swapped. -#define SWITCH_ABXY_DEFAULT_SWAPPED 0 - -// Local confirmation pulse sent only to the controller that toggled. -#define SWITCH_ABXY_FEEDBACK_DURATION_MS 120 -#define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0xFF -#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0xFF - // A longer pulse confirms disabled; a shorter pulse confirms enabled. #define SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS 180 #define SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE 0xA0 diff --git a/controller_profile_runtime.cpp b/controller_profile_runtime.cpp index 68da390..0c5d6ff 100644 --- a/controller_profile_runtime.cpp +++ b/controller_profile_runtime.cpp @@ -13,11 +13,20 @@ struct ControllerProfileRuntimeContext { ControllerIdentity identity{}; uint32_t database_generation = 0; uint8_t active_profile_index = 0; + bool profile_snapshot_valid = false; ControllerProfile profile{}; ControllerSyntheticInputContext synthetic{}; bool runtime_generations_initialized = false; AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; uint32_t configuration_reset_generation = 0; + bool switching_chord_held = false; + bool switching_chord_armed = true; + bool switching_activation_requested = false; + uint16_t held_switching_chord = 0; + uint8_t switching_target_profile_index = 0; + uint32_t switching_transaction_id = 0; + bool profile_change_pending = false; + ControllerProfileRuntimeProfileChangeEvent pending_profile_change{}; }; ControllerProfileRuntimeContext @@ -25,6 +34,23 @@ ControllerProfileRuntimeContext ControllerProfile g_default_profile{}; ControllerProfileTransformResult g_neutral_output{}; bool g_initialized = false; +uint32_t g_next_activation_sequence = 1; + +uint32_t next_activation_transaction_id() { + const uint32_t transaction_id = + 0x80000000u | g_next_activation_sequence; + g_next_activation_sequence = + g_next_activation_sequence == 0x7fffffffu + ? 1u + : g_next_activation_sequence + 1u; + return transaction_id; +} + +uint16_t effective_switching_chord(const ControllerProfile& profile) { + return profile.switching_chord == 0 + ? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD + : profile.switching_chord; +} void initialize_defaults() { if (g_initialized) { @@ -52,6 +78,30 @@ void refresh_profile(ControllerProfileRuntimeContext* context, ProfileServiceActiveProfileSnapshot snapshot{}; profile_service_active_profile_snapshot(identity, &snapshot); + const bool same_connection = + context->active && + context->connection_generation == connection_generation; + const bool same_identity = + same_connection && + controller_identity_equal(context->identity, identity); + const uint8_t previous_profile_index = + context->active_profile_index; + const bool previous_profile_valid = + context->profile_snapshot_valid; + if (!same_connection) { + context->runtime_generations_initialized = false; + context->switching_chord_held = false; + context->switching_chord_armed = true; + context->switching_activation_requested = false; + context->held_switching_chord = 0; + context->switching_target_profile_index = 0; + context->switching_transaction_id = 0; + context->profile_change_pending = false; + context->pending_profile_change = {}; + } + + controller_synthetic_input_cancel(&context->synthetic, + current_input_button_mask); context->active = true; context->connection_generation = connection_generation; context->identity = identity; @@ -59,9 +109,25 @@ void refresh_profile(ControllerProfileRuntimeContext* context, ? snapshot.metadata.generation : observed_database_generation; context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0; + context->profile_snapshot_valid = snapshot.valid; context->profile = snapshot.valid ? snapshot.profile : g_default_profile; - controller_synthetic_input_cancel(&context->synthetic, - current_input_button_mask); + if (context->switching_chord_held && + !context->switching_activation_requested) { + context->switching_target_profile_index = + static_cast( + (context->active_profile_index + 1u) % + CONTROLLER_PROFILE_COUNT); + } + if (same_identity && previous_profile_valid && snapshot.valid && + previous_profile_index != context->active_profile_index) { + context->pending_profile_change = { + connection_generation, + context->database_generation, + static_cast(context->active_profile_index + 1u), + controller_profile_confirmation_policy(context->profile), + }; + context->profile_change_pending = true; + } } ControllerProfileRuntimeContext* update_context( @@ -91,7 +157,75 @@ ControllerProfileRuntimeContext* update_context( } return &context; } +void process_profile_switching( + ControllerProfileRuntimeContext* context, + const ControllerIdentity& identity, + uint16_t switching_input_button_mask, + uint16_t current_input_button_mask, + ControllerState* consumed_input) { + if (context == nullptr || consumed_input == nullptr) { + return; + } + if (context->switching_chord_held) { + if ((switching_input_button_mask & + context->held_switching_chord) == + context->held_switching_chord) { + controller_profile_apply_button_mask( + static_cast( + current_input_button_mask & + ~context->held_switching_chord), + consumed_input); + if (!context->switching_activation_requested) { + const ConfigurationTransactionStatus status = + profile_service_activate_internal( + context->switching_transaction_id, identity, + context->switching_target_profile_index); + if (status != ConfigurationTransactionStatus::kBusy) { + context->switching_activation_requested = true; + } + } + return; + } + context->switching_chord_held = false; + context->switching_activation_requested = false; + context->held_switching_chord = 0; + context->switching_transaction_id = 0; + } + + const uint16_t chord = + effective_switching_chord(context->profile); + const bool chord_fully_held = + (switching_input_button_mask & chord) == chord; + if (!chord_fully_held) { + context->switching_chord_armed = true; + return; + } + if (!context->switching_chord_armed) { + return; + } + + context->switching_chord_armed = false; + context->switching_chord_held = true; + context->held_switching_chord = chord; + context->switching_target_profile_index = + static_cast( + (context->active_profile_index + 1u) % + CONTROLLER_PROFILE_COUNT); + context->switching_transaction_id = + next_activation_transaction_id(); + controller_profile_apply_button_mask( + static_cast( + current_input_button_mask & ~context->held_switching_chord), + consumed_input); + const ConfigurationTransactionStatus status = + profile_service_activate_internal( + context->switching_transaction_id, identity, + context->switching_target_profile_index); + if (status != ConfigurationTransactionStatus::kBusy) { + context->switching_activation_requested = true; + } +} } // namespace @@ -101,6 +235,7 @@ void controller_profile_runtime_reset() { for (ControllerProfileRuntimeContext& context : g_contexts) { clear_context(&context); } + g_next_activation_sequence = 1; } ControllerProfileTransformResult controller_profile_runtime_transform( @@ -112,6 +247,12 @@ ControllerProfileTransformResult controller_profile_runtime_transform( if (context == nullptr) { return g_neutral_output; } + ControllerState consumed_input = snapshot.state; + const uint16_t current_input_button_mask = + controller_profile_extract_button_mask(snapshot.state); + process_profile_switching( + context, snapshot.identity, snapshot.pre_hotkey_button_mask, + current_input_button_mask, &consumed_input); const uint32_t reset_generation = configuration_service_reset_generation(); @@ -124,12 +265,32 @@ ControllerProfileTransformResult controller_profile_runtime_transform( reset_generation) { controller_synthetic_input_cancel( &context->synthetic, - controller_profile_extract_button_mask(snapshot.state)); + controller_profile_extract_button_mask(consumed_input)); context->output_mode = output_mode; context->configuration_reset_generation = reset_generation; } return controller_synthetic_input_apply( - &context->synthetic, snapshot.state, context->profile, now_ms); + &context->synthetic, consumed_input, context->profile, now_ms); +} + +bool controller_profile_runtime_take_profile_change( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) { + if (output == nullptr) { + return false; + } + *output = {}; + if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) { + return false; + } + + ControllerProfileRuntimeContext& context = g_contexts[slot]; + if (!context.profile_change_pending) { + return false; + } + *output = context.pending_profile_change; + context.profile_change_pending = false; + context.pending_profile_change = {}; + return true; } ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( diff --git a/controller_profile_runtime.h b/controller_profile_runtime.h index 85e6509..2843643 100644 --- a/controller_profile_runtime.h +++ b/controller_profile_runtime.h @@ -8,6 +8,24 @@ #include "controller_profile_transform.h" constexpr uint8_t CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT = 4; +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD = + static_cast( + (1u << static_cast( + ControllerProfileLogicalButton::kLeftShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kRightShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kSelect)) | + (1u << static_cast( + ControllerProfileLogicalButton::kStart))); + +struct ControllerProfileRuntimeProfileChangeEvent { + uint32_t connection_generation = 0; + uint32_t database_generation = 0; + uint8_t active_profile_number = 0; + ControllerProfileConfirmationPolicy policy = + ControllerProfileConfirmationPolicy::kNone; +}; struct ControllerProfileRuntimeLocalConfirmation { ControllerRumbleOutput rumble{}; @@ -18,14 +36,21 @@ struct ControllerProfileRuntimeLocalConfirmation { // 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, -// cancel synthetic state on every runtime invalidation, then apply the shared -// transform and synthetic pipeline. Inactive snapshots return neutral output -// and invalidate the slot immediately. +// Refresh a slot when its identity, connection generation, or database +// generation changes; identity-only promotion preserves a held switching +// transaction. Consume pre-hotkey switching chords while transforming the +// backend-suppressed state through the shared synthetic pipeline. Inactive +// snapshots return neutral output and invalidate the slot. ControllerProfileTransformResult controller_profile_runtime_transform( uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms, AdapterUsbMode output_mode); +// Take the single committed profile-index change observed by the slot. Initial +// profile loads, identity promotions, and connection replacements do not +// publish an event. +bool controller_profile_runtime_take_profile_change( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output); + // Refresh from the current slot snapshot and scale host-originated rumble. ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( uint8_t slot, const Bluepad32SlotSnapshot& snapshot, diff --git a/profile_service.cpp b/profile_service.cpp index 12ec36c..1a33b44 100644 --- a/profile_service.cpp +++ b/profile_service.cpp @@ -18,6 +18,7 @@ enum class PendingCommandType : uint8_t { struct PendingCommand { PendingCommandType type = PendingCommandType::kNone; + uint32_t transaction_id = 0; ControllerIdentity identity{}; uint8_t profile_index = 0; }; @@ -48,6 +49,7 @@ PublishedActiveProfile uint8_t g_active_profile_count = 0; ProfileTransaction g_transaction; PendingCommand g_command; +PendingCommand g_internal_activation; bool g_identity_dirty = false; bool g_has_committed = false; uint32_t g_last_commit_ms = 0; @@ -159,6 +161,17 @@ void finish_mutation(ConfigurationTransactionStatus status, critical_section_exit(&g_lock); } +void finish_internal_activation( + ConfigurationTransactionStatus status) { + critical_section_enter_blocking(&g_lock); + g_internal_activation = {}; + refresh_metadata_locked( + status == ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); +} + } // namespace void profile_service_prepare() { @@ -176,6 +189,7 @@ void profile_service_prepare() { g_selected.profile_index = 0; g_transaction = {}; g_command = {}; + g_internal_activation = {}; g_identity_dirty = false; g_has_committed = false; g_last_commit_ms = 0; @@ -226,6 +240,7 @@ bool profile_service_observe_identity_on_storage_core( void profile_service_task_on_storage_core(uint32_t now_ms) { PendingCommand command{}; bool process_write = false; + bool process_internal_activation = false; bool process_identity = false; ControllerIdentity write_identity{}; uint8_t write_profile_index = 0; @@ -242,6 +257,10 @@ void profile_service_task_on_storage_core(uint32_t now_ms) { write_profile_index = g_transaction.profile_index; memcpy(write_payload, g_transaction.payload, sizeof(write_payload)); + } else if (g_internal_activation.type != + PendingCommandType::kNone) { + command = g_internal_activation; + process_internal_activation = true; } else if (g_identity_dirty) { process_identity = true; } @@ -249,6 +268,7 @@ void profile_service_task_on_storage_core(uint32_t now_ms) { critical_section_exit(&g_lock); if (!process_write && !process_identity && + !process_internal_activation && command.type == PendingCommandType::kNone) { return; } @@ -279,8 +299,13 @@ void profile_service_task_on_storage_core(uint32_t now_ms) { } if (database_result != ControllerProfileDatabaseResult::kOk) { - finish_mutation(database_result_status(database_result), - !process_write); + const ConfigurationTransactionStatus error_status = + database_result_status(database_result); + if (process_internal_activation) { + finish_internal_activation(error_status); + } else { + finish_mutation(error_status, !process_write); + } return; } @@ -315,7 +340,11 @@ void profile_service_task_on_storage_core(uint32_t now_ms) { critical_section_enter_blocking(&g_lock); g_identity_dirty = false; critical_section_exit(&g_lock); - finish_mutation(status, !process_write); + if (process_internal_activation) { + finish_internal_activation(status); + } else { + finish_mutation(status, !process_write); + } } ConfigurationTransactionStatus profile_service_select( @@ -362,6 +391,7 @@ ConfigurationTransactionStatus profile_service_begin( } critical_section_enter_blocking(&g_lock); if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || g_transaction.snapshot.status == ConfigurationTransactionStatus::kReceiving || g_transaction.snapshot.status == @@ -465,6 +495,7 @@ ConfigurationTransactionStatus profile_service_reset( } critical_section_enter_blocking(&g_lock); if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || g_transaction.snapshot.status == ConfigurationTransactionStatus::kReceiving || g_transaction.snapshot.status == @@ -485,6 +516,7 @@ ConfigurationTransactionStatus profile_service_reset( return ConfigurationTransactionStatus::kMalformed; } g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; g_command.type = PendingCommandType::kReset; g_command.identity = identity; g_command.profile_index = profile_index; @@ -500,6 +532,7 @@ ConfigurationTransactionStatus profile_service_activate( } critical_section_enter_blocking(&g_lock); if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || g_transaction.snapshot.status == ConfigurationTransactionStatus::kReceiving || g_transaction.snapshot.status == @@ -519,6 +552,7 @@ ConfigurationTransactionStatus profile_service_activate( return ConfigurationTransactionStatus::kMalformed; } g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; g_command.type = PendingCommandType::kActivate; g_command.identity = identity; g_command.profile_index = profile_index; @@ -526,6 +560,36 @@ ConfigurationTransactionStatus profile_service_activate( return ConfigurationTransactionStatus::kPending; } +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + if ((transaction_id & 0x80000000u) == 0 || + !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_internal_activation.type = PendingCommandType::kActivate; + g_internal_activation.transaction_id = transaction_id; + g_internal_activation.identity = identity; + g_internal_activation.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + void profile_service_list_snapshot(ProfileServiceListSnapshot* output) { if (output == nullptr) { return; diff --git a/profile_service.h b/profile_service.h index 7c0e803..c922795 100644 --- a/profile_service.h +++ b/profile_service.h @@ -79,6 +79,11 @@ ConfigurationTransactionStatus profile_service_reset( ConfigurationTransactionStatus profile_service_activate( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t profile_index); +// Queue a controller-originated activation without replacing the host-visible +// transaction snapshot. transaction_id must be nonzero with its high bit set. +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index); void profile_service_list_snapshot(ProfileServiceListSnapshot* output); void profile_service_selected_snapshot( diff --git a/switch-pico.cpp b/switch-pico.cpp index b87c493..97254ca 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -320,6 +320,14 @@ int main() { const ControllerProfileTransformResult transformed = controller_profile_runtime_transform( instance, snapshot, now_ms, output_mode); + ControllerProfileRuntimeProfileChangeEvent profile_change{}; + if (controller_profile_runtime_take_profile_change( + instance, &profile_change)) { + bluepad32_input_backend_queue_profile_feedback( + instance, profile_change.connection_generation, + profile_change.active_profile_number, + profile_change.policy); + } g_user_states[instance] = transformed.state; #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY bool sent = false; diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index d498f7c..bbb8f85 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -1039,7 +1039,8 @@ void test_independent_lifecycle() { "unstable replacement must not be enrolled for profiles"); g_slots[3].pending_rumble = { - 3, disconnected_generation, ControllerRumbleOutput{77, 88}}; + 3, disconnected_generation, ControllerRumbleOutput{77, 88}, + kXInputHostRumbleDurationMs}; g_slots[3].rumble_pending = true; process_rumble_timer(&g_rumble_timer); require(slot_three_replacement.rumble_calls == 0, @@ -1273,102 +1274,437 @@ void test_slot_lighting() { "controller without RGB support did not receive its slot LED"); } -void require_south_button_mapping(const ControllerState& state, - bool swapped, - const char* message) { - require(state.button_east == swapped && state.button_south == !swapped && - !state.button_north && !state.button_west, - message); -} - -void test_abxy_hotkey() { +void test_profile_chord_remains_raw() { start_pairing_backend(); - uni_hid_device_t slot_zero = device(0); - uni_hid_device_t slot_one = device(1); - require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS && - platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS, - "ABXY test controllers did not become ready"); + uni_hid_device_t controller = device(0); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "raw profile chord controller did not become ready"); uni_controller_t input{}; input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - input.gamepad.buttons = BUTTON_A; - platform_on_controller_data(&slot_zero, &input); - ControllerState snapshot{}; - require(read_controller_state(0, &snapshot), - "slot 0 ABXY state was not published"); - require_south_button_mapping( - snapshot, kDefaultSwapAbxy, - "slot 0 did not start in the configured ABXY layout"); - input.gamepad.buttons = - kAbxyHotkeyButtonMask | BUTTON_A; - input.gamepad.misc_buttons = kAbxyHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - require(read_controller_state(0, &snapshot), - "toggled slot 0 state was not published"); - require_south_button_mapping( - snapshot, !kDefaultSwapAbxy, - "hotkey did not toggle slot 0 ABXY mapping"); - require(!snapshot.button_left_shoulder && !snapshot.button_right_shoulder && - !snapshot.button_select && !snapshot.button_start, - "hotkey chord leaked into the Switch report"); - - bluepad32_input_backend_queue_rumble( - 0, ControllerRumbleOutput{0x11, 0x22}); + BUTTON_A | BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + input.gamepad.misc_buttons = + MISC_BUTTON_SELECT | MISC_BUTTON_START; + platform_on_controller_data(&controller, &input); + ControllerState snapshot{}; + require(read_controller_state(0, &snapshot) && + snapshot.button_south && !snapshot.button_east && + snapshot.button_left_shoulder && + snapshot.button_right_shoulder && + snapshot.button_select && snapshot.button_start, + "Core 1 suppressed the profile chord or mutated ABXY mapping"); process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 1 && - slot_zero.last_high == kAbxyFeedbackWeakMagnitude && - slot_zero.last_low == kAbxyFeedbackStrongMagnitude && - slot_zero.last_high == UINT8_MAX && - slot_zero.last_low == UINT8_MAX && - g_slots[0].rumble_pending, - "ABXY confirmation was not full-strength or did not take priority"); - - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].swap_abxy == !kDefaultSwapAbxy && - slot_zero.rumble_calls == 1, - "held hotkey toggled or rumbled more than once"); - - input.gamepad = {}; - platform_on_controller_data(&slot_zero, &input); - input.gamepad.buttons = kAbxyHotkeyButtonMask | BUTTON_A; - input.gamepad.misc_buttons = kAbxyHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].swap_abxy == kDefaultSwapAbxy && - slot_zero.rumble_calls == 2, - "released hotkey did not re-arm for a second toggle"); - - now_ms = kAbxyFeedbackDurationMs - 1; - process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 2 && g_slots[0].rumble_pending, - "host rumble interrupted ABXY confirmation"); - now_ms = kAbxyFeedbackDurationMs; - process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 3 && - slot_zero.last_high == 0x22 && - slot_zero.last_low == 0x11 && - !g_slots[0].rumble_pending, - "deferred host rumble did not resume after confirmation"); - - uni_controller_t peer_input{}; - peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - peer_input.gamepad.buttons = BUTTON_A; - platform_on_controller_data(&slot_one, &peer_input); - require(read_controller_state(1, &snapshot), - "slot 1 ABXY state was not published"); - require_south_button_mapping( - snapshot, kDefaultSwapAbxy, - "slot 0 hotkey changed slot 1 layout"); - - platform_on_device_disconnected(&slot_zero); - uni_hid_device_t replacement = device(0); - require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && - g_slots[0].swap_abxy == kDefaultSwapAbxy && - !g_slots[0].abxy_hotkey_latched && + require(controller.rumble_calls == 0 && !g_slots[0].feedback_pending, - "disconnect did not reset slot 0 hotkey state"); + "legacy ABXY chord still produced local feedback"); +} + +void test_profile_feedback_scheduler() { + start_pairing_backend(); + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + devices[0].report_parser.set_lightbar_color = set_lightbar; + devices[1].report_parser.set_player_leds = set_player_leds; + devices[2].report_parser.set_lightbar_color = set_lightbar; + devices[3].report_parser.set_player_leds = set_player_leds; + + uint32_t generations[kSlotCount]{}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(platform_on_device_ready(&devices[slot]) == + UNI_ERROR_SUCCESS, + "profile feedback controller did not become ready"); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(slot, &snapshot); + generations[slot] = snapshot.connection_generation; + require(devices[slot].rumble_calls == 0 && + !g_slots[slot].profile_feedback.active && + !g_slots[slot].profile_feedback_pending, + "initial controller/profile load scheduled confirmation feedback"); + bluepad32_input_backend_queue_profile_feedback( + slot, generations[slot], static_cast(slot + 1u), + ControllerProfileConfirmationPolicy::kRumble); + bluepad32_input_backend_queue_rumble( + slot, ControllerRumbleOutput{ + static_cast(0x10u + slot), + static_cast(0x20u + slot)}); + } + + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 1 && + devices[slot].last_rumble_duration_ms == + kProfileFeedbackPhaseDurationMs && + devices[slot].last_high == UINT8_MAX && + devices[slot].last_low == UINT8_MAX && + g_slots[slot].rumble_pending && + g_slots[slot].profile_feedback.active, + "profile pulse sequence did not start at full strength"); + } + + now_ms = 74; + process_rumble_timer(&g_rumble_timer); + now_ms = 75; + process_rumble_timer(&g_rumble_timer); + for (const uni_hid_device_t& controller : devices) { + require(controller.rumble_calls == 1, + "profile pulse did not retain a 75 ms on phase"); + } + + now_ms = 149; + process_rumble_timer(&g_rumble_timer); + now_ms = 150; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 2 && + devices[0].last_high == 0x20 && + devices[0].last_low == 0x10 && + !g_slots[0].rumble_pending, + "one-pulse confirmation did not defer host rumble through its off phase"); + for (uint8_t slot = 1; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 2 && + devices[slot].last_high == UINT8_MAX && + g_slots[slot].rumble_pending, + "second profile pulse did not start after 75 ms off"); + } + + now_ms = 225; + process_rumble_timer(&g_rumble_timer); + now_ms = 300; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == 3 && + devices[1].last_high == 0x21 && + !g_slots[1].rumble_pending && + devices[2].rumble_calls == 3 && + devices[3].rumble_calls == 3, + "two/three/four-pulse sequences diverged at 300 ms"); + + now_ms = 375; + process_rumble_timer(&g_rumble_timer); + now_ms = 450; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == 4 && + devices[2].last_high == 0x22 && + !g_slots[2].rumble_pending && + devices[3].rumble_calls == 4 && + devices[3].last_high == UINT8_MAX, + "three/four-pulse sequences diverged at 450 ms"); + + now_ms = 525; + process_rumble_timer(&g_rumble_timer); + now_ms = 600; + process_rumble_timer(&g_rumble_timer); + require(devices[3].rumble_calls == 5 && + devices[3].last_high == 0x23 && + !g_slots[3].rumble_pending && + devices[0].lightbar_calls == 1 && + devices[1].player_led_calls == 1 && + devices[2].lightbar_calls == 1 && + devices[3].player_led_calls == 1, + "four-pulse confirmation did not release host rumble at 600 ms"); + + const int slot_zero_lightbar_calls = devices[0].lightbar_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kNone); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 700; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 3 && + devices[0].last_high == 0x41 && + devices[0].lightbar_calls == + slot_zero_lightbar_calls && + !g_slots[0].profile_feedback.active, + "none policy scheduled profile rumble or lighting"); + + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kLed); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x32, 0x42}); + now_ms = 800; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 3 && + devices[0].lightbar_calls == + slot_zero_lightbar_calls + 1 && + devices[0].lightbar_red == + kProfileLightbarPalette[1].red && + devices[0].lightbar_green == + kProfileLightbarPalette[1].green && + devices[0].lightbar_blue == + kProfileLightbarPalette[1].blue && + observed_status_led_on && + g_slots[0].rumble_pending, + "LED policy did not set persistent profile color and first onboard blink"); + now_ms = 875; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + devices[0].rumble_calls == 3, + "onboard profile blink did not enter its 75 ms off phase"); + now_ms = 950; + process_rumble_timer(&g_rumble_timer); + require(observed_status_led_on, + "second onboard profile blink did not start"); + now_ms = 1025; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + g_slots[0].rumble_pending, + "host rumble interrupted the final onboard off phase"); + now_ms = 1100; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 4 && + devices[0].last_high == 0x42 && + !g_slots[0].rumble_pending, + "LED-only sequence did not release deferred host rumble"); + + const int slot_one_player_led_calls = + devices[1].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 1, generations[1], 3, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + now_ms = 1200; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == 4 && + devices[1].last_high == UINT8_MAX && + devices[1].player_led_calls == + slot_one_player_led_calls + 1 && + devices[1].player_leds == 0x07 && + observed_status_led_on, + "combined policy did not drive rumble, onboard LED, and player LEDs"); + for (uint32_t deadline = 1275; deadline <= 1650; + deadline += 75) { + now_ms = deadline; + process_rumble_timer(&g_rumble_timer); + } + require(devices[1].rumble_calls == + (host_rumble_duration_ms() == + kXInputHostRumbleDurationMs + ? 7 + : 6) && + !g_slots[1].profile_feedback.active, + "combined three-pulse sequence did not terminate"); + + const int old_rumble_calls = devices[2].rumble_calls; + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + platform_on_device_disconnected(&devices[2]); + uni_hid_device_t replacement = device(2); + replacement.report_parser.set_lightbar_color = set_lightbar; + require(platform_on_device_ready(&replacement) == + UNI_ERROR_SUCCESS, + "replacement feedback controller did not become ready"); + now_ms = 1700; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == old_rumble_calls && + replacement.rumble_calls == 0 && + replacement.lightbar_calls == 1 && + !g_slots[2].profile_feedback.active && + !g_slots[2].profile_feedback_pending, + "slot replacement accepted stale queued profile feedback"); + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + now_ms = 1705; + process_rumble_timer(&g_rumble_timer); + require(replacement.rumble_calls == 0 && + replacement.lightbar_calls == 1, + "stale generation feedback reached a replacement controller"); + + const int slot_three_rumble_calls = devices[3].rumble_calls; + const int slot_three_player_led_calls = + devices[3].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 3, generations[3], 1, + ControllerProfileConfirmationPolicy::kLed); + now_ms = 1800; + process_rumble_timer(&g_rumble_timer); + require(observed_status_led_on && + g_slots[3].profile_feedback.pulses_started == 1 && + devices[3].player_led_calls == + slot_three_player_led_calls + 1 && + devices[3].player_leds == 0x01, + "one-blink LED profile indication did not start"); + now_ms = 1875; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on, + "one-blink LED profile indication did not turn off"); + now_ms = 1950; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[3].profile_feedback.active && + devices[3].rumble_calls == + slot_three_rumble_calls, + "one-blink LED-only profile indication did not terminate cleanly"); + + bluepad32_input_backend_queue_profile_feedback( + 3, generations[3], 4, + ControllerProfileConfirmationPolicy::kLed); + now_ms = 2000; + process_rumble_timer(&g_rumble_timer); + require(devices[3].player_leds == 0x0f, + "profile 4 did not persist four player LEDs"); + for (uint8_t pulse = 1; pulse <= 4; ++pulse) { + require(observed_status_led_on && + g_slots[3].profile_feedback.on && + g_slots[3].profile_feedback.pulses_started == + pulse, + "four-blink LED sequence missed an on phase"); + now_ms = static_cast( + 2075u + static_cast(pulse - 1u) * 150u); + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + !g_slots[3].profile_feedback.on, + "four-blink LED sequence missed an off phase"); + if (pulse != 4) { + now_ms += 75; + process_rumble_timer(&g_rumble_timer); + } + } + now_ms = 2600; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[3].profile_feedback.active && + devices[3].rumble_calls == + slot_three_rumble_calls, + "four-blink LED-only profile indication did not terminate"); +} + +void test_stateful_host_rumble_restore() { + start_pairing_backend(); + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + uint32_t generations[kSlotCount]{}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(platform_on_device_ready(&devices[slot]) == + UNI_ERROR_SUCCESS, + "rumble restore controller did not become ready"); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(slot, &snapshot); + generations[slot] = snapshot.connection_generation; + } + +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY + test_adapter_mode = AdapterUsbMode::kXInput; + const ControllerRumbleOutput desired[kSlotCount] = { + {0x11, 0x21}, {0x12, 0x22}, {0x13, 0x23}, {0x14, 0x24}}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + bluepad32_input_backend_queue_rumble(slot, desired[slot]); + } + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 1 && + devices[slot].last_rumble_duration_ms == + kXInputHostRumbleDurationMs && + g_slots[slot].retained_host_rumble_valid, + "initial XInput rumble was not dispatched and retained"); + bluepad32_input_backend_queue_profile_feedback( + slot, generations[slot], static_cast(slot + 1u), + ControllerProfileConfirmationPolicy::kRumble); + } + now_ms = 10; + process_rumble_timer(&g_rumble_timer); + for (now_ms = 85; now_ms <= 610; now_ms += 75) { + process_rumble_timer(&g_rumble_timer); + } + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == + static_cast(slot + 3u) && + devices[slot].last_low == + desired[slot].low_frequency_magnitude && + devices[slot].last_high == + desired[slot].high_frequency_magnitude && + devices[slot].last_rumble_duration_ms == + kXInputHostRumbleDurationMs && + !g_slots[slot].profile_feedback.active, + "XInput rumble did not resume after its profile pulse count"); + } + + const int stop_calls_before = devices[0].rumble_calls; + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x51, 0x61}); + now_ms = 700; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 705; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0, 0}); + for (now_ms = 780; now_ms <= 1005; now_ms += 75) { + process_rumble_timer(&g_rumble_timer); + } + require(devices[0].rumble_calls == stop_calls_before + 4 && + devices[0].last_rumble_duration_ms == 0 && + devices[0].last_low == 0 && + devices[0].last_high == 0 && + g_slots[0].retained_host_rumble_valid, + "newest XInput stop did not win during profile pulses"); + + test_adapter_mode = AdapterUsbMode::kSwitchProbe; + const int switch_calls_before = devices[1].rumble_calls; + bluepad32_input_backend_queue_rumble( + 1, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 1100; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[1].retained_host_rumble_valid, + "Switch rumble retained stale XInput desired state"); + bluepad32_input_backend_queue_profile_feedback( + 1, generations[1], 1, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 1105; + process_rumble_timer(&g_rumble_timer); + now_ms = 1180; + process_rumble_timer(&g_rumble_timer); + now_ms = 1255; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == switch_calls_before + 2 && + devices[1].last_rumble_duration_ms == + kProfileFeedbackPhaseDurationMs, + "finite Switch rumble resumed after profile feedback"); + + test_adapter_mode = AdapterUsbMode::kXInput; + bluepad32_input_backend_queue_rumble( + 2, ControllerRumbleOutput{0x71, 0x81}); + now_ms = 1300; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 3, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 1305; + process_rumble_timer(&g_rumble_timer); + const int old_device_calls = devices[2].rumble_calls; + platform_on_device_disconnected(&devices[2]); + uni_hid_device_t replacement = device(2); + require(platform_on_device_ready(&replacement) == + UNI_ERROR_SUCCESS, + "rumble restore replacement did not become ready"); + now_ms = 2000; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == old_device_calls && + replacement.rumble_calls == 0 && + !g_slots[2].retained_host_rumble_valid && + !g_slots[2].profile_feedback.active, + "stale XInput rumble resumed on a replacement connection"); +#else + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 1, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 5; + process_rumble_timer(&g_rumble_timer); + now_ms = 80; + process_rumble_timer(&g_rumble_timer); + now_ms = 155; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 2 && + !g_slots[0].retained_host_rumble_valid, + "bounded Switch rumble resumed after profile feedback"); +#endif } void test_motion_hotkey() { @@ -1393,12 +1729,17 @@ void test_motion_hotkey() { input.gamepad.buttons = kMotionHotkeyButtonMask; input.gamepad.misc_buttons = kMotionHotkeyMiscMask; platform_on_controller_data(&slot_zero, &input); + Bluepad32SlotSnapshot backend_snapshot{}; + bluepad32_input_backend_snapshot(0, &backend_snapshot); require(read_controller_state(0, &snapshot) && + backend_snapshot.pre_hotkey_button_mask == + kMotionHotkeyLogicalButtonMask && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 0 : 3) && !snapshot.dpad_up && !snapshot.button_right_shoulder && !snapshot.button_start, - "motion chord did not toggle motion or suppress its inputs"); + "motion chord was not published pre-hotkey or suppressed " + "from normal output"); process_rumble_timer(&g_rumble_timer); require(slot_zero.rumble_calls == 1 && @@ -1452,6 +1793,7 @@ void test_motion_hotkey() { require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && g_slots[0].motion_enabled == kDefaultMotionEnabled && !g_slots[0].motion_hotkey_latched && + g_slots[0].pre_hotkey_button_mask == 0 && !g_slots[0].feedback_pending, "disconnect did not reset slot 0 motion hotkey state"); } @@ -1641,8 +1983,12 @@ int main(int argc, char** argv) { test_pairing_window_policy(); } else if (scenario == "slot-lighting") { test_slot_lighting(); - } else if (scenario == "abxy-hotkey") { - test_abxy_hotkey(); + } else if (scenario == "stateful-rumble") { + test_stateful_host_rumble_restore(); + } else if (scenario == "profile-chord-raw") { + test_profile_chord_remains_raw(); + } else if (scenario == "profile-feedback") { + test_profile_feedback_scheduler(); } else if (scenario == "motion-hotkey") { test_motion_hotkey(); } else if (scenario == "analog-state") { diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index d2fa5c2..ce48daf 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -16,11 +16,20 @@ struct FakeProfileRow { uint8_t active_profile = 0; ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{}; }; +struct ActivationAttempt { + uint32_t transaction_id = 0; + ControllerIdentity identity{}; + uint8_t profile_index = 0; +}; + std::array rows{}; uint32_t database_generation = 7; uint32_t configuration_reset_generation = 3; unsigned active_snapshot_count = 0; +std::array activation_attempts{}; +size_t activation_attempt_count = 0; +unsigned activation_busy_attempts = 0; void require(bool condition, const char* message) { if (!condition) { @@ -44,6 +53,9 @@ void prepare_profiles() { database_generation = 7; configuration_reset_generation = 3; active_snapshot_count = 0; + activation_attempts = {}; + activation_attempt_count = 0; + activation_busy_attempts = 0; for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { FakeProfileRow& row = rows[slot]; @@ -78,6 +90,31 @@ Bluepad32SlotSnapshot make_snapshot(uint8_t slot, snapshot.state = controller_neutral_state(); return snapshot; } +void apply_button_masks(uint16_t pre_hotkey_mask, + uint16_t output_mask, + Bluepad32SlotSnapshot* snapshot) { + snapshot->pre_hotkey_button_mask = pre_hotkey_mask; + snapshot->state = controller_neutral_state(); + controller_profile_apply_button_mask(output_mask, &snapshot->state); +} +void apply_button_mask(uint16_t mask, Bluepad32SlotSnapshot* snapshot) { + apply_button_masks(mask, mask, snapshot); +} + +ControllerProfileRuntimeProfileChangeEvent take_profile_change( + uint8_t slot, bool* available) { + ControllerProfileRuntimeProfileChangeEvent event{}; + *available = + controller_profile_runtime_take_profile_change(slot, &event); + return event; +} + +constexpr uint16_t logical_button_bit( + ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + ControllerProfileTransformResult runtime_transform( uint8_t slot, const Bluepad32SlotSnapshot& snapshot, @@ -248,6 +285,292 @@ void test_analog_thresholds_rumble_and_local_confirmation() { "local confirmation was scaled or lost its profile policy"); } +void test_default_switching_retry_commit_and_feedback() { + prepare_profiles(); + ControllerProfile& initial_profile = rows[0].profiles[0]; + initial_profile.macro_trigger = static_cast( + ControllerProfileLogicalButton::kLeftShoulder); + initial_profile.macro_step_count = 2; + initial_profile.macro_steps[0].type = + ControllerProfileMacroStepType::kState; + initial_profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + initial_profile.macro_steps[0].duration_ms = 1000; + initial_profile.macro_steps[0].output_button_mask = + logical_button_bit(ControllerProfileLogicalButton::kNorth); + initial_profile.macro_steps[1].type = + ControllerProfileMacroStepType::kEnd; + + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(!event_available, + "initial profile load published confirmation feedback"); + + activation_busy_attempts = 2; + const uint16_t held_mask = static_cast( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD | + logical_button_bit(ControllerProfileLogicalButton::kSouth)); + apply_button_mask(held_mask, &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 1 && + rows[0].active_profile == 0 && + output.state.button_south && + !output.state.button_left_shoulder && + !output.state.button_right_shoulder && + !output.state.button_select && + !output.state.button_start && + !output.state.button_north, + "default chord was not consumed before a busy activation and synthetic trigger"); + output = runtime_transform(0, snapshot, 2); + output = runtime_transform(0, snapshot, 3); + require(activation_attempt_count == 3 && + activation_attempts[0].transaction_id == + activation_attempts[1].transaction_id && + activation_attempts[1].transaction_id == + activation_attempts[2].transaction_id && + (activation_attempts[0].transaction_id & 0x80000000u) != 0 && + activation_attempts[0].transaction_id != 0 && + activation_attempts[0].profile_index == 1 && + output.state.button_south && !output.state.button_north, + "held busy chord did not retry one internal activation transaction"); + + (void)runtime_transform(0, snapshot, 4); + require(activation_attempt_count == 3, + "accepted activation repeated while the chord remained held"); + (void)take_profile_change(0, &event_available); + require(!event_available, + "activation feedback was published before storage commit"); + + rows[0].active_profile = 1; + rows[0].profiles[1].button_map[ + static_cast(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kNorth); + rows[0].profiles[1].confirmation_policy = + ControllerProfileConfirmationPolicy::kLed; + ++database_generation; + output = runtime_transform(0, snapshot, 5); + const ControllerProfileRuntimeProfileChangeEvent event = + take_profile_change(0, &event_available); + require(event_available && event.connection_generation == 1 && + event.database_generation == database_generation && + event.active_profile_number == 2 && + event.policy == ControllerProfileConfirmationPolicy::kLed && + output.state.button_north && + !output.state.button_south && + !output.state.button_left_shoulder && + activation_attempt_count == 3, + "committed activation did not cancel, transform, and publish exactly one event"); + (void)take_profile_change(0, &event_available); + require(!event_available, + "committed profile change feedback was published twice"); + + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 6); + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshot); + (void)runtime_transform(0, snapshot, 7); + require(activation_attempt_count == 4 && + activation_attempts[3].profile_index == 2 && + activation_attempts[3].transaction_id != + activation_attempts[0].transaction_id && + (activation_attempts[3].transaction_id & 0x80000000u) != 0, + "release did not re-arm one activation for the next profile"); +} + +void test_identity_promotion_preserves_held_switching() { + prepare_profiles(); + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + snapshot.identity = controller_identity_global(); + apply_button_mask( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot); + + const ControllerProfileTransformResult unresolved = + runtime_transform(0, snapshot, 0); + require(activation_attempt_count == 1 && + controller_identity_is_global( + activation_attempts[0].identity) && + unresolved.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "unresolved BLE identity did not begin one activation"); + + snapshot.identity = rows[0].identity; + const ControllerProfileTransformResult promoted = + runtime_transform(0, snapshot, 1); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(activation_attempt_count == 1 && + promoted.left_trigger_digital_threshold == 1000 && + !event_available, + "identity-only BLE promotion re-armed a held activation or " + "failed to refresh its profile"); + + (void)runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1, + "promoted BLE identity repeated an accepted held activation"); + + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot); + (void)runtime_transform(0, snapshot, 4); + require(activation_attempt_count == 2 && + controller_identity_equal( + activation_attempts[1].identity, + rows[0].identity), + "physical release did not re-arm the promoted identity"); +} + +void configure_motion_suppression_probe(ControllerProfile* profile) { + profile->macro_trigger = static_cast( + ControllerProfileLogicalButton::kDpadUp); + profile->macro_step_count = 2; + profile->macro_steps[0].type = + ControllerProfileMacroStepType::kState; + profile->macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + profile->macro_steps[0].duration_ms = 1000; + profile->macro_steps[0].output_button_mask = + logical_button_bit(ControllerProfileLogicalButton::kNorth); + profile->macro_steps[1].type = + ControllerProfileMacroStepType::kEnd; +} + +void test_switching_uses_pre_hotkey_buttons_only() { + constexpr uint16_t kMotionHotkeyLogicalMask = + static_cast( + logical_button_bit( + ControllerProfileLogicalButton::kDpadUp) | + logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder) | + logical_button_bit( + ControllerProfileLogicalButton::kStart)); + constexpr uint16_t kCustomChord = + static_cast( + kMotionHotkeyLogicalMask | + logical_button_bit( + ControllerProfileLogicalButton::kSouth)); + + for (uint8_t custom = 0; custom < 2; ++custom) { + prepare_profiles(); + ControllerProfile& profile = rows[0].profiles[0]; + configure_motion_suppression_probe(&profile); + const uint16_t switching_chord = + custom != 0 ? kCustomChord + : CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD; + profile.switching_chord = custom != 0 ? kCustomChord : 0; + + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + const uint16_t pre_hotkey_mask = static_cast( + switching_chord | kMotionHotkeyLogicalMask); + const uint16_t output_mask = static_cast( + pre_hotkey_mask & ~kMotionHotkeyLogicalMask); + apply_button_masks( + pre_hotkey_mask, output_mask, &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 1 && + !output.state.dpad_up && + !output.state.button_right_shoulder && + !output.state.button_start && + !output.state.button_left_shoulder && + !output.state.button_select && + !output.state.button_south && + !output.state.button_north, + custom != 0 + ? "custom chord hidden by motion suppression" + : "default chord hidden by motion suppression"); + + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + !output.state.button_north, + "held pre-hotkey chord repeated activation or leaked " + "into synthetic input"); + } +} + +void test_custom_switching_chord_and_wrap() { + prepare_profiles(); + constexpr uint16_t kCustomChord = + static_cast( + logical_button_bit(ControllerProfileLogicalButton::kSouth) | + logical_button_bit(ControllerProfileLogicalButton::kCapture)); + rows[0].profiles[0].switching_chord = kCustomChord; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 0 && + output.state.button_left_shoulder && + output.state.button_right_shoulder && + output.state.button_select && output.state.button_start, + "nonzero switching chord did not replace the default"); + + apply_button_mask(kCustomChord, &snapshot); + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 1 && + !output.state.button_south && + !output.state.button_capture, + "custom switching chord was not consumed or activated"); + + prepare_profiles(); + rows[0].active_profile = 3; + rows[0].profiles[3].switching_chord = kCustomChord; + snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 10); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(!event_available, + "initial profile 4 load published a change event"); + apply_button_mask(kCustomChord, &snapshot); + (void)runtime_transform(0, snapshot, 11); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 0, + "profile switching did not wrap profile 4 to profile 1"); +} +void test_switching_slot_isolation() { + prepare_profiles(); + std::array + snapshots{}; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + snapshots[slot] = make_snapshot(slot); + (void)runtime_transform(slot, snapshots[slot], 0); + } + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshots[2]); + (void)runtime_transform(2, snapshots[2], 1); + snapshots[0].state.button_south = true; + const ControllerProfileTransformResult untouched = + runtime_transform(0, snapshots[0], 1); + require(activation_attempt_count == 1 && + controller_identity_equal( + activation_attempts[0].identity, + rows[2].identity) && + untouched.state.button_south, + "one slot's switching chord affected another slot"); + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshots[3]); + (void)runtime_transform(2, snapshots[2], 2); + (void)runtime_transform(3, snapshots[3], 2); + require(activation_attempt_count == 2 && + controller_identity_equal( + activation_attempts[1].identity, + rows[3].identity), + "held switching state was shared between controller slots"); +} + + void configure_synthetic_profile(uint8_t slot) { ControllerProfile& profile = rows[slot].profiles[0]; profile = controller_profile_default(rows[slot].identity, 0); @@ -347,6 +670,12 @@ void test_all_runtime_cancellation_causes() { output = runtime_transform(0, snapshot, 33); require_no_synthetic_output( output, "profile/database generation change did not cancel"); + bool event_available = false; + const ControllerProfileRuntimeProfileChangeEvent cancellation_event = + take_profile_change(0, &event_available); + require(event_available && + cancellation_event.active_profile_number == 2, + "profile commit feedback was not observed with cancellation"); prepare_profiles(); configure_synthetic_profile(0); @@ -413,6 +742,20 @@ uint32_t profile_service_database_generation() { uint32_t configuration_service_reset_generation() { return configuration_reset_generation; } +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + require(activation_attempt_count < activation_attempts.size(), + "activation attempt fixture overflow"); + activation_attempts[activation_attempt_count++] = { + transaction_id, identity, profile_index}; + if (activation_busy_attempts != 0) { + --activation_busy_attempts; + return ConfigurationTransactionStatus::kBusy; + } + return ConfigurationTransactionStatus::kPending; +} + void profile_service_active_profile_snapshot( const ControllerIdentity& identity, @@ -439,6 +782,11 @@ int main() { test_four_slot_cache_and_unchanged_generation(); test_activation_disconnect_and_default_preservation(); test_analog_thresholds_rumble_and_local_confirmation(); + test_default_switching_retry_commit_and_feedback(); + test_identity_promotion_preserves_held_switching(); + test_switching_uses_pre_hotkey_buttons_only(); + test_custom_switching_chord_and_wrap(); + test_switching_slot_isolation(); test_all_runtime_cancellation_causes(); test_runtime_slot_synthetic_isolation(); return 0; diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 646fd9b..57eacec 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -211,6 +211,100 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { "activation did not publish profile, index, and generation together"); } +void test_host_and_controller_mutations_are_serialized() { + const ControllerIdentity identity = controller_identity_global(); + ControllerProfile profile = + controller_profile_default(identity, 1); + profile.weak_rumble_scale = 23; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "serialization fixture profile did not encode"); + + constexpr uint32_t kHostTransactionId = 0x11223344; + constexpr uint32_t kInternalTransactionId = 0x80000019; + require(profile_service_begin( + kHostTransactionId, identity, 1, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving, + "host write did not acquire the profile mutation boundary"); + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kBusy, + "controller activation raced a receiving host write"); + ProfileServiceTransactionSnapshot snapshot = + transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kReceiving, + "busy controller activation replaced the host transaction"); + + require(profile_service_append( + kHostTransactionId, 0, encoded, sizeof(encoded)) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(kHostTransactionId) == + ConfigurationTransactionStatus::kPending, + "host write did not reach pending after controller contention"); + profile_service_task_on_storage_core(3000); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "serialized host write did not commit"); + + constexpr uint32_t kHostActivationTransactionId = 0x22334455; + require(profile_service_activate( + kHostActivationTransactionId, identity, 0) == + ConfigurationTransactionStatus::kPending, + "host activation did not acquire the released boundary"); + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kBusy, + "controller activation raced a pending host activation"); + snapshot = transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kPending && + active_profile_snapshot(identity).profile_index == 3, + "pending host activation was replaced or leaked before commit"); + profile_service_task_on_storage_core(4000); + require(active_profile_snapshot(identity).profile_index == 0, + "serialized host activation did not commit"); + require(profile_service_activate_internal( + 0x19, identity, 2) == + ConfigurationTransactionStatus::kMalformed, + "internal activation admitted a transaction without the high bit"); + + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kPending, + "controller activation did not acquire the released boundary"); + require(profile_service_begin( + 0x55667788, identity, 0, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kBusy, + "host write raced a pending controller activation"); + snapshot = transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted && + active_profile_snapshot(identity).profile_index == 0, + "pending controller activation replaced host-visible status or leaked before commit"); + + profile_service_task_on_storage_core(5000); + const ProfileServiceActiveProfileSnapshot active = + active_profile_snapshot(identity); + snapshot = transaction_snapshot(); + require(active.valid && active.profile_index == 2 && + snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "controller activation did not publish while preserving host-visible status"); +} + } // namespace ProfileStorageIo pico_profile_storage_io() { @@ -219,5 +313,6 @@ ProfileStorageIo pico_profile_storage_io() { int main() { test_pending_commands_are_not_decoded_as_profile_writes(); + test_host_and_controller_mutations_are_serialized(); return 0; } diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 7484f39..72484d0 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -42,7 +42,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "lifecycle", "pairing-policy", "slot-lighting", - "abxy-hotkey", + "profile-chord-raw", + "profile-feedback", + "stateful-rumble", "motion-hotkey", "analog-state", "rumble-mode",