Add persistent profile switching feedback
This commit is contained in:
parent
114266836d
commit
045edc984e
12 changed files with 1573 additions and 198 deletions
|
|
@ -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<uint8_t>(1u << slot_index));
|
||||
}
|
||||
}
|
||||
bool valid_confirmation_policy(
|
||||
ControllerProfileConfirmationPolicy policy) {
|
||||
return static_cast<uint8_t>(policy) <=
|
||||
static_cast<uint8_t>(
|
||||
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<uint8_t>(
|
||||
(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<uint16_t>(
|
||||
1u << static_cast<uint8_t>(button));
|
||||
}
|
||||
|
||||
constexpr uint16_t logical_button_mask(
|
||||
uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) {
|
||||
return static_cast<uint16_t>(
|
||||
((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<uint16_t>(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<int32_t>(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<int32_t>(
|
||||
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<int32_t>(
|
||||
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<int32_t>(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<uint8_t>(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<uint8_t>(
|
||||
ControllerProfileConfirmationPolicy::
|
||||
kRumble)) != 0,
|
||||
(policy & static_cast<uint8_t>(
|
||||
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<int32_t>(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<int32_t>(
|
||||
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<uint8_t>(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<uint16_t>(
|
||||
pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons);
|
||||
publish_device_state(
|
||||
static_cast<uint8_t>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(
|
||||
(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<uint8_t>(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<uint16_t>(
|
||||
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<uint8_t>(
|
||||
(context->active_profile_index + 1u) %
|
||||
CONTROLLER_PROFILE_COUNT);
|
||||
context->switching_transaction_id =
|
||||
next_activation_transaction_id();
|
||||
controller_profile_apply_button_mask(
|
||||
static_cast<uint16_t>(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<uint16_t>(
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kLeftShoulder)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kRightShoulder)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSelect)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(slot + 1u),
|
||||
ControllerProfileConfirmationPolicy::kRumble);
|
||||
bluepad32_input_backend_queue_rumble(
|
||||
slot, ControllerRumbleOutput{
|
||||
static_cast<uint8_t>(0x10u + slot),
|
||||
static_cast<uint8_t>(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<uint32_t>(
|
||||
2075u + static_cast<uint32_t>(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<uint8_t>(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<int>(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") {
|
||||
|
|
|
|||
|
|
@ -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<FakeProfileRow, CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT> rows{};
|
||||
uint32_t database_generation = 7;
|
||||
uint32_t configuration_reset_generation = 3;
|
||||
unsigned active_snapshot_count = 0;
|
||||
std::array<ActivationAttempt, 32> 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<uint16_t>(
|
||||
1u << static_cast<uint8_t>(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<uint8_t>(
|
||||
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<uint16_t>(
|
||||
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<uint8_t>(ControllerProfileLogicalButton::kSouth)] =
|
||||
static_cast<uint8_t>(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<uint8_t>(
|
||||
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<uint16_t>(
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kDpadUp) |
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kRightShoulder) |
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kStart));
|
||||
constexpr uint16_t kCustomChord =
|
||||
static_cast<uint16_t>(
|
||||
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<uint16_t>(
|
||||
switching_chord | kMotionHotkeyLogicalMask);
|
||||
const uint16_t output_mask = static_cast<uint16_t>(
|
||||
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<uint16_t>(
|
||||
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<Bluepad32SlotSnapshot,
|
||||
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue