feat(profiles): add solo Joy-Con layouts and digital stick mappings
Add per-profile native solo routing, stick/click swapping, SL/SR output mappings, and digital left-stick directions with analog priority. Extend both codecs to schema 11 while preserving legacy profiles, expose the controls and preview in Studio, and carry rail outputs through Pro emulation. Verified with 541 tests, firmware builds, editor checks, and saved-profile preservation during native 0.89 deployment.
This commit is contained in:
parent
d424fe0f4a
commit
d15b0ec711
31 changed files with 2803 additions and 183 deletions
|
|
@ -90,3 +90,61 @@ constexpr uint8_t kLegacySchema6Profile[384] = {
|
|||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
// Schema9 settings exercise all three gestures, extra controls, and macro bindings.
|
||||
// Kept as fixed legacy bytes, independent of the current encoder.
|
||||
constexpr uint8_t kLegacySchema9Profile[384] = {
|
||||
0x09, 0x00, 0x80, 0x01, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0xff, 0x7b, 0x00, 0x38, 0xfe, 0xe8, 0x03, 0x30, 0x75, 0x80, 0x01, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xeb, 0xfc, 0x41, 0x01, 0xd0, 0x07, 0x18, 0x79, 0xc0, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x60, 0xea, 0x80, 0x01, 0x34, 0x12, 0x00, 0x00, 0xd0, 0x07,
|
||||
0xe8, 0xfd, 0xc0, 0x00, 0xcd, 0xab, 0x10, 0x00, 0x7b, 0xd2, 0x01, 0x21, 0x41, 0x00, 0x80, 0x00,
|
||||
0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03,
|
||||
0x01, 0x00, 0x41, 0x00, 0x01, 0x11, 0x02, 0x00, 0x46, 0x11, 0x01, 0x11, 0x04, 0x00, 0x24, 0x22,
|
||||
0x01, 0x11, 0x08, 0x00, 0x7c, 0x33, 0x01, 0x11, 0x1f, 0x19, 0x00, 0x01, 0x00, 0xfe, 0xff, 0x03,
|
||||
0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1a, 0x00, 0x02, 0x00, 0xfe, 0xff,
|
||||
0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1b, 0x00, 0x04, 0x00, 0xfe,
|
||||
0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1c, 0x00, 0x08, 0x00,
|
||||
0xfe, 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x12, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, 0x18, 0xff, 0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x1e, 0x63, 0xff, 0x04, 0x80,
|
||||
0x01, 0x01, 0x01, 0x17, 0x25, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xff, 0x01, 0xfe, 0x02, 0xfd, 0x03, 0xfc, 0x11, 0x00, 0x10, 0x0c, 0x0d, 0x0e, 0x0f, 0x0f,
|
||||
0x0e, 0x0d, 0x0c, 0x03, 0x02, 0xff, 0x01, 0x02, 0x04, 0x40, 0x55, 0x2a, 0xff, 0x02, 0x18, 0x00,
|
||||
0x0f, 0x00, 0x12, 0xff, 0xff, 0x03, 0x10, 0xc8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
// Schema10 retains rail destinations, right-solo layout, and stick swapping.
|
||||
// Fixed legacy bytes ensure migration never depends on the current encoder.
|
||||
constexpr uint8_t kLegacySchema10Profile[384] = {
|
||||
0x0a, 0x00, 0x80, 0x01, 0x12, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0xff, 0x7b, 0x00, 0x38, 0xfe, 0xe8, 0x03, 0x30, 0x75, 0x80, 0x01, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xeb, 0xfc, 0x41, 0x01, 0xd0, 0x07, 0x18, 0x79, 0xc0, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x60, 0xea, 0x80, 0x01, 0x34, 0x12, 0x15, 0x00, 0xd0, 0x07,
|
||||
0xe8, 0xfd, 0xc0, 0x00, 0xcd, 0xab, 0x15, 0x00, 0x7b, 0xd2, 0x01, 0x21, 0x41, 0x00, 0x80, 0x00,
|
||||
0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03,
|
||||
0x01, 0x00, 0x41, 0x00, 0x01, 0x11, 0x02, 0x00, 0x46, 0x11, 0x01, 0x11, 0x04, 0x00, 0x24, 0x22,
|
||||
0x01, 0x11, 0x08, 0x00, 0x7c, 0x33, 0x01, 0x11, 0x1f, 0x19, 0x00, 0x01, 0x00, 0xfe, 0xff, 0x03,
|
||||
0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1a, 0x00, 0x02, 0x00, 0xfe, 0xff,
|
||||
0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1b, 0x00, 0x04, 0x00, 0xfe,
|
||||
0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1c, 0x00, 0x08, 0x00,
|
||||
0xfe, 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x12, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, 0x18, 0x14, 0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x1e, 0x63, 0xff, 0x04, 0x80,
|
||||
0x01, 0x01, 0x01, 0x17, 0x25, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xff, 0x01, 0xfe, 0x02, 0xfd, 0x03, 0xfc, 0x13, 0x00, 0x10, 0x0c, 0x0d, 0x0e, 0x0f, 0x15,
|
||||
0x0e, 0x0d, 0x0c, 0x03, 0x02, 0xff, 0x01, 0x02, 0x04, 0x40, 0x55, 0x2a, 0xff, 0x02, 0x18, 0x00,
|
||||
0x0f, 0x00, 0x12, 0xff, 0xff, 0x03, 0x10, 0xc8, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1164,27 +1164,31 @@ void test_shortcut_selector_rollover_without_modifier_release() {
|
|||
void test_extra_hotkeys_consume_mappings_and_rearm() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
profile.switching_chord = (1u << 18) | (1u << 24);
|
||||
profile.motion_toggle_chord = (1u << 19) | (1u << 16);
|
||||
profile.shortcuts.modifier = 20;
|
||||
profile.shortcuts.selectors[7] = 0;
|
||||
profile.extra_button_map[0] = 3;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.extra_button_map[1] = 1;
|
||||
profile.extra_button_map[2] = 2;
|
||||
profile.extra_button_map[6] = 17;
|
||||
profile.extra_button_map[6] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(activation_attempt_count == 0 && output.state.button_north,
|
||||
"partial extra switching chord was swallowed or activated");
|
||||
require(activation_attempt_count == 0 && output.state.extra_buttons == 0x08 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"partial extra switching chord lost its rail route or active layout");
|
||||
snapshot.state.extra_buttons = 0x41;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 1 &&
|
||||
activation_attempts[0].profile_index == 1 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.right_trigger == 0 && output.state.extra_buttons == 0,
|
||||
"extra switching chord did not select the next profile and consume mappings");
|
||||
output.state.right_trigger == 0 && output.state.extra_buttons == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"extra switching chord leaked rails or lost the active layout while suppressed");
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
require(activation_attempt_count == 1, "held extra switching chord retriggered");
|
||||
snapshot.state = controller_neutral_state();
|
||||
|
|
@ -1194,8 +1198,10 @@ void test_extra_hotkeys_consume_mappings_and_rearm() {
|
|||
output = runtime_transform(0, snapshot, 5);
|
||||
require(motion_toggle_count == 1 && last_motion_toggle_slot == 0 &&
|
||||
last_motion_toggle_connection_generation == 9 &&
|
||||
!output.state.button_east && output.state.left_trigger == 0,
|
||||
"extra/trigger motion chord lost its logical slot or leaked mapped inputs");
|
||||
!output.state.button_east && output.state.left_trigger == 0 &&
|
||||
output.state.extra_buttons == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"extra/trigger motion chord leaked a mapped rail or lost slot/layout routing");
|
||||
(void)runtime_transform(0, snapshot, 6);
|
||||
require(motion_toggle_count == 1, "held extra motion chord retriggered");
|
||||
snapshot.state = controller_neutral_state();
|
||||
|
|
@ -1216,6 +1222,237 @@ void test_extra_hotkeys_consume_mappings_and_rearm() {
|
|||
"released extra switching chord did not rearm");
|
||||
}
|
||||
|
||||
void test_shift_rails_reset_with_live_profile_layout_changes() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
profile.shift.modifier = 9;
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
|
||||
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.shift.button_map[9] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
auto snapshot = make_snapshot(0);
|
||||
auto output = runtime_transform(0, snapshot, 0);
|
||||
require(output.state.extra_buttons == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kRightSolo,
|
||||
"neutral active input lost its solo layout or created a rail press");
|
||||
|
||||
apply_button_mask(1u | (1u << 9), &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 1);
|
||||
require(output.state.extra_buttons == 0x60 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"Shift failed to route both ordinary and extra sources or leaked its modifier");
|
||||
apply_button_mask(1u, &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(output.state.extra_buttons == 0x60,
|
||||
"toggle Shift dropped its rail routes when the modifier was released");
|
||||
++configuration_reset_generation;
|
||||
output = runtime_transform(0, snapshot, 3);
|
||||
require(output.state.extra_buttons == 0x18 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kRightSolo,
|
||||
"configuration reset left stale Shift rails or reset profile-owned layout");
|
||||
apply_button_mask(1u | (1u << 9), &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 4);
|
||||
require(output.state.extra_buttons == 0x60,
|
||||
"Shift rail routes did not rearm after reset");
|
||||
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 5);
|
||||
require(output.state.extra_buttons == 0x40 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"same-profile live edit retained old Shift rails or stale native layout");
|
||||
rows[0].active_profile = 1;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 6);
|
||||
require(output.state.extra_buttons == 0 && output.state.button_south &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kPaired,
|
||||
"activating a normal profile retained the previous profile's rails or layout");
|
||||
|
||||
snapshot.active = false;
|
||||
output = runtime_transform(0, snapshot, 7);
|
||||
require(output.state.extra_buttons == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kPaired,
|
||||
"disconnect retained mapped rails or active-only routing metadata");
|
||||
}
|
||||
|
||||
void test_stick_swap_precedes_final_macro_overrides_in_all_output_modes() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.swap_sticks = true;
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.macros[0] = {1u, CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
|
||||
profile.macro_step_count = 1;
|
||||
auto& step = profile.macro_steps[0];
|
||||
step.override_flags =
|
||||
kControllerProfileOverrideButtons | kControllerProfileOverrideLeftStick;
|
||||
step.output_button_mask =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kLeftStick);
|
||||
step.left_stick_x = 1234;
|
||||
step.left_stick_y = -5678;
|
||||
step.duration_ms = 10;
|
||||
auto snapshot = make_snapshot(0);
|
||||
snapshot.state.left_stick_x = 111;
|
||||
snapshot.state.left_stick_y = -222;
|
||||
snapshot.state.right_stick_x = 333;
|
||||
snapshot.state.right_stick_y = -444;
|
||||
snapshot.state.button_left_stick = true;
|
||||
snapshot.state.extra_buttons = 1;
|
||||
auto output = runtime_transform(0, snapshot, 0, AdapterUsbMode::kXInput);
|
||||
require(output.state.left_stick_x == 333 && output.state.left_stick_y == -444 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == -222 &&
|
||||
!output.state.button_left_stick && output.state.button_right_stick,
|
||||
"stick axes and clicks were not swapped for a non-native output mode");
|
||||
snapshot.state.button_south = true;
|
||||
output = runtime_transform(0, snapshot, 1, AdapterUsbMode::kXInput);
|
||||
require(output.state.left_stick_x == 1234 && output.state.left_stick_y == -5678 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == -222 &&
|
||||
controller_profile_extract_button_mask(output.state) ==
|
||||
logical_button_bit(ControllerProfileLogicalButton::kLeftStick) &&
|
||||
output.state.extra_buttons == 0x40,
|
||||
"stick swap moved final macro outputs or broadened its standard-button override");
|
||||
output = runtime_transform(0, snapshot, 11, AdapterUsbMode::kXInput);
|
||||
require(output.state.left_stick_x == 333 && output.state.left_stick_y == -444 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == -222 &&
|
||||
!output.state.button_left_stick && output.state.button_right_stick,
|
||||
"macro completion failed to restore the live swapped sticks and clicks");
|
||||
|
||||
profile.swap_sticks = false;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 12, AdapterUsbMode::kXInput);
|
||||
require(output.state.left_stick_x == 111 && output.state.left_stick_y == -222 &&
|
||||
output.state.right_stick_x == 333 && output.state.right_stick_y == -444 &&
|
||||
output.state.button_left_stick && !output.state.button_right_stick,
|
||||
"live swap edit retained old output pairs or restarted a held macro");
|
||||
}
|
||||
|
||||
void test_shift_directions_select_maps_and_consume_modifiers() {
|
||||
for (const auto mode : {ControllerProfileShiftMode::kHold,
|
||||
ControllerProfileShiftMode::kToggle}) {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
profile.shift.mode = mode;
|
||||
profile.shift.modifier = 9;
|
||||
profile.button_map[9] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.shift.button_map[9] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
auto snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
apply_button_mask(1u, &snapshot);
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767,
|
||||
"inactive Shift did not use the base direction map");
|
||||
apply_button_mask(1u | (1u << 9), &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(output.state.left_stick_x == 32767 && output.state.left_stick_y == 0 &&
|
||||
output.state.extra_buttons == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"Shift did not OR ordinary and extra directions or leaked its consumed modifier");
|
||||
apply_button_mask(1u << 9, &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 3);
|
||||
require(output.state.left_stick_x == 32767 && output.state.left_stick_y == 0,
|
||||
"releasing the ordinary source cleared a held Shift extra direction");
|
||||
apply_button_mask(0, &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
output = runtime_transform(0, snapshot, 4);
|
||||
const bool toggled = mode == ControllerProfileShiftMode::kToggle;
|
||||
require(output.state.left_stick_x == (toggled ? 32767 : 0) &&
|
||||
output.state.left_stick_y == (toggled ? 0 : 32767),
|
||||
"modifier release failed to retain toggle Shift or restore hold Shift's base map");
|
||||
snapshot.state.extra_buttons = 0;
|
||||
output = runtime_transform(0, snapshot, 5);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == 0,
|
||||
"Shift retained a direction after its last source released");
|
||||
}
|
||||
}
|
||||
|
||||
void test_consumed_direction_sources_and_live_layout_changes() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
profile.motion_toggle_chord = (1u << 18) | (1u << 16);
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.triggers[0].digital_threshold = 12345;
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
auto snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(output.state.left_stick_x == 32767 && output.state.left_stick_y == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kRightSolo,
|
||||
"partial extra hotkey lost its direction or native-layout metadata");
|
||||
apply_button_mask(1u << 12, &snapshot);
|
||||
snapshot.state.extra_buttons = 1;
|
||||
snapshot.state.left_trigger = 12345;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(motion_toggle_count == 1 &&
|
||||
output.state.left_stick_x == -32767 && output.state.left_stick_y == 0 &&
|
||||
output.state.left_trigger == 0 && output.state.extra_buttons == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kRightSolo,
|
||||
"consumed extra/trigger hotkey leaked directions or suppressed unrelated movement/layout");
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 3);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == 32767 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"live profile edit retained an old direction or native layout");
|
||||
snapshot.active = false;
|
||||
output = runtime_transform(0, snapshot, 4);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kPaired,
|
||||
"disconnect retained digital movement or active-only native-layout metadata");
|
||||
}
|
||||
|
||||
void test_direction_fallback_precedes_final_macro_override() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.swap_sticks = true;
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.macros[0] = {1u, CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
|
||||
profile.macro_step_count = 1;
|
||||
auto& step = profile.macro_steps[0];
|
||||
step.override_flags = kControllerProfileOverrideLeftStick;
|
||||
step.left_stick_x = 1234;
|
||||
step.left_stick_y = -5678;
|
||||
step.duration_ms = 10;
|
||||
auto snapshot = make_snapshot(0);
|
||||
snapshot.state.left_stick_x = 111;
|
||||
snapshot.state.dpad_up = true;
|
||||
auto output = runtime_transform(0, snapshot, 0);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == 0,
|
||||
"runtime applied direction fallback before the physical stick swap");
|
||||
snapshot.state.button_south = true;
|
||||
output = runtime_transform(0, snapshot, 1);
|
||||
require(output.state.left_stick_x == 1234 && output.state.left_stick_y == -5678 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == 0 &&
|
||||
output.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
"digital directions replaced final macro output or lost native-layout metadata");
|
||||
output = runtime_transform(0, snapshot, 11);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767 &&
|
||||
output.state.right_stick_x == 111 && output.state.right_stick_y == 0,
|
||||
"macro completion failed to restore a still-held mapped direction");
|
||||
}
|
||||
|
||||
void test_accelerometer_swing_requires_evidence_and_settle() {
|
||||
WiiSwingDetector detector;
|
||||
WiiAccelerometerSample sample{};
|
||||
|
|
@ -1656,6 +1893,11 @@ int main() {
|
|||
test_held_synthetic_sources_and_disconnect_rearming();
|
||||
test_shortcut_selector_rollover_without_modifier_release();
|
||||
test_extra_hotkeys_consume_mappings_and_rearm();
|
||||
test_shift_rails_reset_with_live_profile_layout_changes();
|
||||
test_stick_swap_precedes_final_macro_overrides_in_all_output_modes();
|
||||
test_shift_directions_select_maps_and_consume_modifiers();
|
||||
test_consumed_direction_sources_and_live_layout_changes();
|
||||
test_direction_fallback_precedes_final_macro_override();
|
||||
test_accelerometer_swing_requires_evidence_and_settle();
|
||||
test_swing_output_isolated_from_motion_remaps_and_profiles();
|
||||
test_swing_modifier_release_cancels_and_requires_fresh_settle();
|
||||
|
|
|
|||
|
|
@ -136,9 +136,9 @@ void test_profile_wire_schema() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"default profile did not encode");
|
||||
require(encoded[0] == 9 && encoded[1] == 0 &&
|
||||
require(encoded[0] == 11 && encoded[1] == 0 &&
|
||||
encoded[2] == 0x80 && encoded[3] == 1,
|
||||
"profile header is not little-endian v9/384");
|
||||
"profile header is not little-endian v11/384");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
require(encoded[4 + index] == index,
|
||||
|
|
@ -226,7 +226,7 @@ void test_profile_wire_schema() {
|
|||
"nonzero reserved profile byte was accepted");
|
||||
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"invalid direct output was accepted");
|
||||
invalid = profile;
|
||||
|
|
@ -582,8 +582,8 @@ void test_set_b_sparse_extension_and_migration() {
|
|||
profile.shortcuts.selectors[7] = 0;
|
||||
require(!controller_profile_validate(profile), "duplicate shortcut was accepted");
|
||||
profile.shortcuts.selectors[7] = 15;
|
||||
profile.shift.button_map[0] = 16;
|
||||
require(!controller_profile_validate(profile), "analog Shift output was accepted");
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(profile), "out-of-range Shift output was accepted");
|
||||
profile.shift.button_map[0] = 0;
|
||||
profile.turbo_overrides[2].rate_hz = 31;
|
||||
require(!controller_profile_validate(profile), "out-of-range Turbo rate was accepted");
|
||||
|
|
@ -730,17 +730,17 @@ void test_extra_control_schema_round_trip_and_output_limits() {
|
|||
"out-of-range extra-control mask was accepted");
|
||||
encoded[offset] &= 0x7f;
|
||||
}
|
||||
profile.extra_button_map[0] = 18;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(profile),
|
||||
"extra input was accepted as a console output destination");
|
||||
"out-of-range extra output was accepted");
|
||||
profile.extra_button_map[0] = 0;
|
||||
profile.triggers[0].output = 18;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(profile),
|
||||
"analog trigger was allowed to route into a source-only control");
|
||||
"analog trigger was allowed to route into an invalid control");
|
||||
profile.triggers[0].output = 0;
|
||||
profile.shift.extra_button_map[0] = 16;
|
||||
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(profile),
|
||||
"Shift extra mapping admitted an analog destination");
|
||||
"Shift extra mapping admitted an invalid destination");
|
||||
}
|
||||
|
||||
void test_schema7_migration_preserves_extra_controls_and_macros() {
|
||||
|
|
@ -813,6 +813,195 @@ void test_schema8_migration_preserves_remote_swing() {
|
|||
}
|
||||
}
|
||||
|
||||
void test_schema9_migration_preserves_all_settings() {
|
||||
ControllerProfile migrated{};
|
||||
migrated.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
migrated.swap_sticks = true;
|
||||
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_decode(kLegacySchema9Profile,
|
||||
sizeof(kLegacySchema9Profile), &migrated) &&
|
||||
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
|
||||
memcmp(kLegacySchema9Profile + 2, upgraded + 2,
|
||||
sizeof(upgraded) - 2) == 0 &&
|
||||
migrated.native_joycon_layout ==
|
||||
ControllerProfileNativeJoyconLayout::kPaired &&
|
||||
!migrated.swap_sticks &&
|
||||
migrated.swing.macro == 0 &&
|
||||
migrated.nunchuk_swing.button == 15 &&
|
||||
migrated.combined_swing.macro == 3 &&
|
||||
migrated.combination_window_ms == 200,
|
||||
"schema9 migration lost settings or enabled native layout/stick swapping");
|
||||
const uint16_t output_offsets[] = {4, 60, 70, 344, 267, 351};
|
||||
for (uint16_t offset : output_offsets) {
|
||||
uint8_t malformed[sizeof(kLegacySchema9Profile)]{};
|
||||
memcpy(malformed, kLegacySchema9Profile, sizeof(malformed));
|
||||
malformed[offset] = offset == 267 || offset == 351 ? 16 : 18;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
|
||||
"schema9 accepted a formerly invalid output destination");
|
||||
}
|
||||
for (size_t offset = 376; offset < sizeof(kLegacySchema9Profile); ++offset) {
|
||||
uint8_t malformed[sizeof(kLegacySchema9Profile)]{};
|
||||
memcpy(malformed, kLegacySchema9Profile, sizeof(malformed));
|
||||
malformed[offset] = 1;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
|
||||
"schema9 reinterpreted reserved bytes as layout/stick swapping");
|
||||
}
|
||||
}
|
||||
|
||||
void test_schema10_migration_preserves_native_settings() {
|
||||
ControllerProfile migrated{};
|
||||
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_decode(kLegacySchema10Profile,
|
||||
sizeof(kLegacySchema10Profile), &migrated) &&
|
||||
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
|
||||
upgraded[0] == 11 &&
|
||||
memcmp(kLegacySchema10Profile + 2, upgraded + 2,
|
||||
sizeof(upgraded) - 2) == 0 &&
|
||||
migrated.native_joycon_layout ==
|
||||
ControllerProfileNativeJoyconLayout::kRightSolo &&
|
||||
migrated.swap_sticks &&
|
||||
migrated.button_map[0] == CONTROLLER_PROFILE_LEFT_SL_OUTPUT &&
|
||||
migrated.extra_button_map[0] == CONTROLLER_PROFILE_LEFT_SR_OUTPUT &&
|
||||
migrated.shift.button_map[0] == CONTROLLER_PROFILE_RIGHT_SL_OUTPUT &&
|
||||
migrated.shift.extra_button_map[0] == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT &&
|
||||
migrated.triggers[0].output == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT &&
|
||||
migrated.triggers[1].output == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT,
|
||||
"schema10 migration changed rail destinations, layout, or swapping");
|
||||
const uint16_t output_offsets[] = {4, 60, 70, 344, 267, 351};
|
||||
for (uint16_t offset : output_offsets) {
|
||||
uint8_t malformed[sizeof(kLegacySchema10Profile)]{};
|
||||
memcpy(malformed, kLegacySchema10Profile, sizeof(malformed));
|
||||
malformed[offset] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
|
||||
"schema10 accepted a schema11 direction destination");
|
||||
}
|
||||
for (size_t offset = 378; offset < sizeof(kLegacySchema10Profile); ++offset) {
|
||||
uint8_t malformed[sizeof(kLegacySchema10Profile)]{};
|
||||
memcpy(malformed, kLegacySchema10Profile, sizeof(malformed));
|
||||
malformed[offset] = 1;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
|
||||
"schema10 accepted nonzero reserved padding");
|
||||
}
|
||||
}
|
||||
|
||||
void test_direction_outputs_preserve_input_namespace() {
|
||||
ControllerProfile profile{};
|
||||
require(controller_profile_decode(kLegacySchema10Profile,
|
||||
sizeof(kLegacySchema10Profile), &profile),
|
||||
"direction fixture did not decode");
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.button_map[13] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
profile.button_map[14] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.triggers[1].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.shortcuts.modifier = 24;
|
||||
profile.shift.modifier = 24;
|
||||
profile.macros[0].trigger_mask = 1u << 24;
|
||||
profile.macros[0].cancel_control = 24;
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
encoded[0] == 11 && encoded[16] == 22 && encoded[17] == 23 &&
|
||||
encoded[18] == 24 && encoded[19] == 25 &&
|
||||
decoded.button_map[10] == 10 &&
|
||||
decoded.extra_button_map[0] == 24 &&
|
||||
decoded.shift.button_map[0] == 23 &&
|
||||
decoded.shift.extra_button_map[0] == 25 &&
|
||||
decoded.triggers[0].output == 22 && decoded.triggers[1].output == 22 &&
|
||||
decoded.shortcuts.modifier == 24 && decoded.shift.modifier == 24 &&
|
||||
decoded.macros[0].trigger_mask == 1u << 24 &&
|
||||
decoded.macros[0].cancel_control == 24,
|
||||
"direction destinations changed encoding, clicks, or source indices");
|
||||
uint8_t round_trip[sizeof(encoded)]{};
|
||||
require(controller_profile_encode(decoded, round_trip, sizeof(round_trip)) &&
|
||||
memcmp(encoded, round_trip, sizeof(encoded)) == 0,
|
||||
"direction destinations did not survive re-encoding");
|
||||
for (size_t offset = 378; offset < sizeof(encoded); ++offset) {
|
||||
encoded[offset] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"schema11 consumed reserved padding");
|
||||
encoded[offset] = 0;
|
||||
}
|
||||
profile.shift.modifier = 25;
|
||||
require(!controller_profile_validate(profile),
|
||||
"new output destination expanded source-control indices");
|
||||
profile.shift.modifier = 24;
|
||||
profile.swing.button = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.swing.macro = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
require(!controller_profile_validate(profile),
|
||||
"direction destination expanded swing output buttons");
|
||||
}
|
||||
|
||||
void test_legacy_button_only_destinations() {
|
||||
for (uint8_t schema = 1; schema <= 2; ++schema) {
|
||||
uint8_t encoded[sizeof(kLegacyDefaultProfile)]{};
|
||||
memcpy(encoded, kLegacyDefaultProfile, sizeof(encoded));
|
||||
encoded[0] = schema;
|
||||
ControllerProfile decoded{};
|
||||
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"legacy button-only fixture did not decode");
|
||||
encoded[4] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"schema1/2 accepted a schema3 output destination");
|
||||
}
|
||||
}
|
||||
|
||||
void test_native_layout_and_mapping_boundaries() {
|
||||
ControllerProfile profile{};
|
||||
require(controller_profile_decode(kLegacySchema9Profile,
|
||||
sizeof(kLegacySchema9Profile), &profile),
|
||||
"native layout fixture did not decode");
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
|
||||
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.triggers[1].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
for (uint8_t layout = 0; layout <= 2; ++layout) {
|
||||
profile.native_joycon_layout =
|
||||
static_cast<ControllerProfileNativeJoyconLayout>(layout);
|
||||
for (uint8_t swap = 0; swap <= 1; ++swap) {
|
||||
profile.swap_sticks = swap != 0;
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
encoded[376] == layout && encoded[377] == swap &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
decoded.native_joycon_layout == profile.native_joycon_layout &&
|
||||
decoded.swap_sticks == profile.swap_sticks &&
|
||||
decoded.button_map[0] == 18 &&
|
||||
decoded.extra_button_map[0] == 19 &&
|
||||
decoded.shift.button_map[0] == 20 &&
|
||||
decoded.shift.extra_button_map[0] == 21 &&
|
||||
decoded.triggers[0].output == 21 &&
|
||||
decoded.triggers[1].output == 21,
|
||||
"native fields or shared rail destinations did not round-trip");
|
||||
}
|
||||
}
|
||||
const uint8_t invalid_layouts[] = {3, 255};
|
||||
for (uint8_t layout : invalid_layouts) {
|
||||
profile.native_joycon_layout =
|
||||
static_cast<ControllerProfileNativeJoyconLayout>(layout);
|
||||
require(!controller_profile_validate(profile),
|
||||
"out-of-range native layout was accepted");
|
||||
encoded[376] = layout;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"out-of-range wire native layout was accepted");
|
||||
}
|
||||
encoded[376] = 2;
|
||||
const uint8_t invalid_flags[] = {2, 128, 255};
|
||||
for (uint8_t flags : invalid_flags) {
|
||||
encoded[377] = flags;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"reserved stick-swap flags were accepted");
|
||||
}
|
||||
}
|
||||
|
||||
void test_swing_wire_settings_and_rejection() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
|
|
@ -895,8 +1084,8 @@ void test_swing_wire_settings_and_rejection() {
|
|||
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
|
||||
"out-of-range combination window was accepted from wire");
|
||||
}
|
||||
for (size_t offset = 376; offset < sizeof(encoded); ++offset) {
|
||||
require(encoded[offset] == 0, "schema9 reserved tail was not zero");
|
||||
for (size_t offset = 378; offset < sizeof(encoded); ++offset) {
|
||||
require(encoded[offset] == 0, "profile reserved tail was not zero");
|
||||
encoded[offset] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero swing extension reservation was accepted");
|
||||
|
|
@ -986,7 +1175,28 @@ void test_swing_macro_targets_require_playable_steps() {
|
|||
}
|
||||
|
||||
} // namespace
|
||||
int main() {
|
||||
int main(int argc, char** argv) {
|
||||
if (argc == 2 && std::strcmp(argv[1], "--legacy9") == 0) {
|
||||
std::cout.write(reinterpret_cast<const char*>(kLegacySchema9Profile),
|
||||
sizeof(kLegacySchema9Profile));
|
||||
return std::cout ? 0 : 1;
|
||||
}
|
||||
if (argc == 2 && std::strcmp(argv[1], "--legacy10") == 0) {
|
||||
std::cout.write(reinterpret_cast<const char*>(kLegacySchema10Profile),
|
||||
sizeof(kLegacySchema10Profile));
|
||||
return std::cout ? 0 : 1;
|
||||
}
|
||||
if (argc == 2 && std::strcmp(argv[1], "--codec") == 0) {
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
std::cin.read(reinterpret_cast<char*>(encoded), sizeof(encoded));
|
||||
const size_t size = static_cast<size_t>(std::cin.gcount());
|
||||
ControllerProfile profile{};
|
||||
require(controller_profile_decode(encoded, size, &profile) &&
|
||||
controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"cross-language profile failed to decode/encode");
|
||||
std::cout.write(reinterpret_cast<const char*>(encoded), sizeof(encoded));
|
||||
return std::cout ? 0 : 1;
|
||||
}
|
||||
test_pair_identity_wire_and_member_validation();
|
||||
test_profile_wire_schema();
|
||||
test_legacy_profile_migration();
|
||||
|
|
@ -997,6 +1207,11 @@ int main() {
|
|||
test_extra_control_schema_round_trip_and_output_limits();
|
||||
test_schema7_migration_preserves_extra_controls_and_macros();
|
||||
test_schema8_migration_preserves_remote_swing();
|
||||
test_schema9_migration_preserves_all_settings();
|
||||
test_schema10_migration_preserves_native_settings();
|
||||
test_direction_outputs_preserve_input_namespace();
|
||||
test_legacy_button_only_destinations();
|
||||
test_native_layout_and_mapping_boundaries();
|
||||
test_swing_wire_settings_and_rejection();
|
||||
test_swing_macro_targets_require_playable_steps();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ void test_button_masks_and_direct_mapping() {
|
|||
"disabled button mapping still produced output");
|
||||
|
||||
ControllerProfile invalid = default_profile();
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 18 was accepted");
|
||||
"out-of-range logical output was accepted");
|
||||
invalid.button_map[0] = 0xfe;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 0xfe was accepted");
|
||||
|
|
@ -433,7 +433,7 @@ void test_rumble_scaling_and_confirmation_policy() {
|
|||
"confirmation policy was not exposed unchanged");
|
||||
}
|
||||
|
||||
void test_extra_sources_route_without_creating_output_channels() {
|
||||
void test_extra_sources_preserve_standard_routes_and_suppress_unmapped_inputs() {
|
||||
ControllerProfile profile = default_profile();
|
||||
ControllerState input{};
|
||||
input.extra_buttons = 0x7f;
|
||||
|
|
@ -475,6 +475,310 @@ void test_extra_sources_route_without_creating_output_channels() {
|
|||
"reserved extra bit became a control or output");
|
||||
}
|
||||
|
||||
void test_native_extra_destinations_combine_mapped_sources() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.button_map[1] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.extra_button_map[6] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.triggers[0].digital_threshold = 30000;
|
||||
ControllerState input{};
|
||||
input.button_south = true;
|
||||
input.button_east = true;
|
||||
input.extra_buttons = 0x41;
|
||||
input.left_trigger = 30000;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x48 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.left_trigger == 0,
|
||||
"native destinations lost contributors or leaked their original outputs");
|
||||
|
||||
input.button_south = false;
|
||||
input.button_east = false;
|
||||
input.extra_buttons = 0;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x08,
|
||||
"releasing mapped buttons cleared an independently held trigger rail");
|
||||
input.left_trigger = 29999;
|
||||
input.extra_buttons = 1;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x08,
|
||||
"a below-threshold trigger cleared an independently held extra-source rail");
|
||||
input.extra_buttons = 0;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0,
|
||||
"released extra destinations retained stale mapped presses");
|
||||
}
|
||||
|
||||
void test_trigger_rails_use_transformed_source_thresholds() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
profile.triggers[0].lower_deadzone = 1000;
|
||||
profile.triggers[0].upper_saturation = 11000;
|
||||
profile.triggers[0].digital_threshold = 32768;
|
||||
profile.triggers[1].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.triggers[1].lower_deadzone = 2000;
|
||||
profile.triggers[1].upper_saturation = 22000;
|
||||
profile.triggers[1].digital_threshold = UINT16_MAX;
|
||||
ControllerState input{};
|
||||
input.left_trigger = 5999;
|
||||
input.right_trigger = 21999;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0 &&
|
||||
output.state.left_trigger == 0 && output.state.right_trigger == 0,
|
||||
"trigger rails activated below their transformed thresholds");
|
||||
input.left_trigger = 6000;
|
||||
input.right_trigger = 22000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x50,
|
||||
"trigger rails missed their calibrated threshold or saturation boundary");
|
||||
profile.triggers[0].curve_q8_8 = 512;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x40,
|
||||
"trigger-to-rail routing ignored the source response curve");
|
||||
input.left_trigger = 11000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x50,
|
||||
"curved trigger rail did not preserve the saturated endpoint");
|
||||
|
||||
profile.triggers[0].digital_threshold = 0;
|
||||
profile.triggers[1].digital_threshold = 0;
|
||||
input.left_trigger = 1000;
|
||||
input.right_trigger = 2000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0,
|
||||
"zero-threshold rails activated inside calibrated trigger deadzones");
|
||||
input = {};
|
||||
require(controller_profile_transform(input, profile).state.extra_buttons == 0,
|
||||
"neutral triggers created zero-threshold rail presses");
|
||||
input.left_trigger = 1001;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.extra_buttons == 0x10,
|
||||
"first nonzero transformed trigger failed its zero-threshold rail route");
|
||||
}
|
||||
|
||||
void test_stick_swap_keeps_physical_calibration_and_mapped_clicks() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.swap_sticks = true;
|
||||
profile.sticks[0].center_x = 1200;
|
||||
profile.sticks[0].center_y = -900;
|
||||
profile.sticks[0].inner_deadzone = 500;
|
||||
profile.sticks[0].outer_saturation = 20000;
|
||||
profile.sticks[0].invert_x = true;
|
||||
profile.sticks[1].center_x = -3000;
|
||||
profile.sticks[1].center_y = 2000;
|
||||
profile.sticks[1].inner_deadzone = 2000;
|
||||
profile.sticks[1].outer_saturation = 10000;
|
||||
profile.sticks[1].invert_y = true;
|
||||
ControllerState input{};
|
||||
input.left_stick_x = 1700;
|
||||
input.left_stick_y = -900;
|
||||
input.right_stick_x = -3000;
|
||||
input.right_stick_y = 4000;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == 0 &&
|
||||
output.state.right_stick_x == 0 && output.state.right_stick_y == 0,
|
||||
"stick swap moved calibration or inner deadzones off their physical sticks");
|
||||
|
||||
input.left_stick_x = 21200;
|
||||
input.right_stick_y = 12000;
|
||||
input.button_left_stick = true;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 &&
|
||||
output.state.left_stick_y == INT16_MIN &&
|
||||
output.state.right_stick_x == INT16_MIN &&
|
||||
output.state.right_stick_y == 0 &&
|
||||
!output.state.button_left_stick && output.state.button_right_stick,
|
||||
"stick swap separated clicks from calibrated saturated axis pairs");
|
||||
|
||||
profile.button_map[10] = 0;
|
||||
profile.button_map[0] = 11;
|
||||
input.button_south = true;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.button_south && output.state.button_left_stick &&
|
||||
!output.state.button_right_stick,
|
||||
"stick clicks were swapped before normal output mapping");
|
||||
profile.swap_sticks = false;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == INT16_MIN &&
|
||||
output.state.left_stick_y == 0 &&
|
||||
output.state.right_stick_x == 0 &&
|
||||
output.state.right_stick_y == INT16_MIN &&
|
||||
!output.state.button_left_stick && output.state.button_right_stick,
|
||||
"disabling stick swap did not restore the calibrated normal mappings");
|
||||
}
|
||||
|
||||
void test_left_stick_directions_cancel_and_normalize_without_button_leaks() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.button_map[13] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
profile.button_map[14] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
const struct {
|
||||
uint16_t directions;
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
} cases[] = {
|
||||
{0, 0, 0},
|
||||
{1, 0, -32767}, {2, 0, 32767}, {4, -32767, 0}, {8, 32767, 0},
|
||||
{5, -23169, -23169}, {9, 23169, -23169},
|
||||
{6, -23169, 23169}, {10, 23169, 23169},
|
||||
{3, 0, 0}, {12, 0, 0}, {15, 0, 0},
|
||||
{7, -32767, 0}, {11, 32767, 0},
|
||||
{13, 0, -32767}, {14, 0, 32767},
|
||||
};
|
||||
for (const auto& entry : cases) {
|
||||
ControllerState input{};
|
||||
controller_profile_apply_button_mask(entry.directions << 12, &input);
|
||||
input.right_stick_x = 123;
|
||||
input.right_stick_y = -456;
|
||||
const auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == entry.x &&
|
||||
output.state.left_stick_y == entry.y,
|
||||
"direction combination lost a cardinal, diagonal, or independent cancellation");
|
||||
const int32_t x = output.state.left_stick_x;
|
||||
const int32_t y = output.state.left_stick_y;
|
||||
require(x * x + y * y <= 32767 * 32767,
|
||||
"digital left-stick vector exceeded the unit radius");
|
||||
require(controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.extra_buttons == 0 &&
|
||||
output.state.left_trigger == 0 && output.state.right_trigger == 0 &&
|
||||
output.state.right_stick_x == 123 &&
|
||||
output.state.right_stick_y == -456,
|
||||
"direction mapping leaked Dpad, clicks, rails, triggers, or right-stick movement");
|
||||
}
|
||||
}
|
||||
|
||||
void test_left_stick_direction_sources_combine_and_release_independently() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.extra_button_map[6] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
profile.triggers[0].digital_threshold = 30000;
|
||||
ControllerState input{};
|
||||
input.button_south = true;
|
||||
input.extra_buttons = 0x41;
|
||||
input.left_trigger = 30000;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 32767 && output.state.left_stick_y == 0 &&
|
||||
output.state.extra_buttons == 0x40 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.left_trigger == 0,
|
||||
"direction contributors accumulated magnitude or corrupted an independent rail");
|
||||
input.button_south = false;
|
||||
input.left_trigger = 29999;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 32767 && output.state.extra_buttons == 0x40,
|
||||
"releasing button and trigger contributors cleared a held extra direction");
|
||||
input.extra_buttons = 0;
|
||||
input.left_trigger = 30000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 32767 && output.state.extra_buttons == 0,
|
||||
"trigger direction depended on a button contributor or retained a released rail");
|
||||
input.left_trigger = 0;
|
||||
require(states_equal(controller_profile_transform(input, profile).state,
|
||||
controller_neutral_state()),
|
||||
"releasing every direction contributor left stale output");
|
||||
}
|
||||
|
||||
void test_trigger_directions_use_transformed_threshold_and_nonzero_guard() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.triggers[0].lower_deadzone = 1000;
|
||||
profile.triggers[0].upper_saturation = 11000;
|
||||
profile.triggers[0].digital_threshold = 32768;
|
||||
profile.triggers[1].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.triggers[1].lower_deadzone = 2000;
|
||||
profile.triggers[1].upper_saturation = 22000;
|
||||
profile.triggers[1].digital_threshold = UINT16_MAX;
|
||||
ControllerState input{};
|
||||
input.left_trigger = 5999;
|
||||
input.right_trigger = 21999;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(states_equal(output.state, controller_neutral_state()),
|
||||
"trigger directions activated below transformed thresholds");
|
||||
input.left_trigger = 6000;
|
||||
input.right_trigger = 22000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == -23169 && output.state.left_stick_y == -23169 &&
|
||||
output.state.left_trigger == 0 && output.state.right_trigger == 0 &&
|
||||
output.state.extra_buttons == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"trigger directions missed threshold equality, normalization, or leaked source output");
|
||||
profile.triggers[0].curve_q8_8 = 512;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767,
|
||||
"trigger direction threshold ignored the response curve");
|
||||
input.left_trigger = 11000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == -23169 && output.state.left_stick_y == -23169,
|
||||
"curved trigger direction lost its saturated endpoint");
|
||||
profile.triggers[0].digital_threshold = 0;
|
||||
profile.triggers[1].digital_threshold = 0;
|
||||
input.left_trigger = 1000;
|
||||
input.right_trigger = 2000;
|
||||
require(states_equal(controller_profile_transform(input, profile).state,
|
||||
controller_neutral_state()),
|
||||
"zero-threshold directions activated inside trigger deadzones");
|
||||
input = {};
|
||||
require(states_equal(controller_profile_transform(input, profile).state,
|
||||
controller_neutral_state()),
|
||||
"neutral zero-threshold triggers synthesized directions");
|
||||
input.left_trigger = 1001;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == -32767 && output.state.left_stick_y == 0,
|
||||
"first nonzero curved trigger value missed a zero-threshold direction");
|
||||
}
|
||||
|
||||
void test_left_stick_directions_yield_to_whole_calibrated_swapped_vector() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
ControllerState input{};
|
||||
input.dpad_up = true;
|
||||
input.left_stick_x = 1;
|
||||
auto output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 1 && output.state.left_stick_y == 0,
|
||||
"digital direction blended into the neutral axis of a live analog vector");
|
||||
input.left_stick_x = 0;
|
||||
input.left_stick_y = 1;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == 1,
|
||||
"digital direction replaced a nonzero analog Y axis");
|
||||
|
||||
profile.sticks[0].center_x = 1000;
|
||||
profile.sticks[0].center_y = -2000;
|
||||
profile.sticks[0].inner_deadzone = 500;
|
||||
input.left_stick_x = 1500;
|
||||
input.left_stick_y = -2000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767,
|
||||
"calibration or deadzone was applied after digital fallback");
|
||||
input.left_stick_x = 1501;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x > 0 && output.state.left_stick_y == 0,
|
||||
"first analog value outside the deadzone did not own the whole vector");
|
||||
|
||||
profile.swap_sticks = true;
|
||||
profile.sticks[1].center_x = -3000;
|
||||
profile.sticks[1].center_y = 2000;
|
||||
profile.sticks[1].inner_deadzone = 1000;
|
||||
input.right_stick_x = -2000;
|
||||
input.right_stick_y = 2000;
|
||||
input.button_left_stick = true;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x == 0 && output.state.left_stick_y == -32767 &&
|
||||
output.state.right_stick_x > 0 && output.state.right_stick_y == 0 &&
|
||||
!output.state.button_left_stick && output.state.button_right_stick,
|
||||
"directions followed physical left or changed swapped analog movement and clicks");
|
||||
input.right_stick_x = -1999;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.left_stick_x > 0 && output.state.left_stick_y == 0 &&
|
||||
output.state.right_stick_x > 0 && output.state.right_stick_y == 0,
|
||||
"mapped-left analog priority ignored physical-right calibration after swapping");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -483,7 +787,14 @@ int main() {
|
|||
test_stick_curves_and_monotonicity();
|
||||
test_trigger_boundaries_curves_and_thresholds();
|
||||
test_trigger_and_button_cross_mapping();
|
||||
test_extra_sources_route_without_creating_output_channels();
|
||||
test_extra_sources_preserve_standard_routes_and_suppress_unmapped_inputs();
|
||||
test_native_extra_destinations_combine_mapped_sources();
|
||||
test_trigger_rails_use_transformed_source_thresholds();
|
||||
test_stick_swap_keeps_physical_calibration_and_mapped_clicks();
|
||||
test_left_stick_directions_cancel_and_normalize_without_button_leaks();
|
||||
test_left_stick_direction_sources_combine_and_release_independently();
|
||||
test_trigger_directions_use_transformed_threshold_and_nonzero_guard();
|
||||
test_left_stick_directions_yield_to_whole_calibrated_swapped_vector();
|
||||
test_default_whole_state_equivalence();
|
||||
test_rumble_scaling_and_confirmation_policy();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -85,31 +85,281 @@ const profile = {
|
|||
},
|
||||
};
|
||||
const sample = { buttons: ["south"], extra_buttons: extras, triggers: { left: 123, right: 50000 } };
|
||||
assert.deepEqual(transformMappings(sample, profile), {
|
||||
buttons: ["south"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
const ordinaryMapped = transformMappings(sample, profile);
|
||||
assert.deepEqual(ordinaryMapped.buttons, ["south"]);
|
||||
assert.deepEqual(ordinaryMapped.triggers, { left: 123, right: 50000 });
|
||||
for (const [index, extra] of extras.entries()) {
|
||||
profile.extra_button_map[extra] = buttons[index + 1];
|
||||
}
|
||||
assert.deepEqual(transformMappings(sample, profile).buttons, buttons.slice(0, 8));
|
||||
profile.extra_button_map.c = "left_trigger";
|
||||
profile.extra_button_map.gl = "south";
|
||||
assert.deepEqual(transformMappings(sample, profile), {
|
||||
buttons: ["south", ...buttons.slice(3, 8)],
|
||||
triggers: { left: 65535, right: 50000 },
|
||||
});
|
||||
const extraMapped = transformMappings(sample, profile);
|
||||
assert.deepEqual(extraMapped.buttons, ["south", ...buttons.slice(3, 8)]);
|
||||
assert.deepEqual(extraMapped.triggers, { left: 65535, right: 50000 });
|
||||
profile.shift.mode = "hold";
|
||||
profile.shift.modifier = "c";
|
||||
profile.shift.extra_button_map.gr = "system";
|
||||
assert.deepEqual(transformMappings(sample, profile, true), {
|
||||
buttons: ["south", "system"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
const shiftedMapped = transformMappings(sample, profile, true);
|
||||
assert.deepEqual(shiftedMapped.buttons, ["south", "system"]);
|
||||
assert.deepEqual(shiftedMapped.triggers, { left: 123, right: 50000 });
|
||||
assert.equal(transformMappings(sample, profile, false).triggers.left, 123,
|
||||
"the Shift modifier is consumed even on the base layer");
|
||||
assert.deepEqual(transformMappings({ ...sample, extra_buttons: [] }, profile), {
|
||||
buttons: ["south"], triggers: { left: 123, right: 50000 },
|
||||
});
|
||||
const releasedExtraMapped = transformMappings({ ...sample, extra_buttons: [] }, profile);
|
||||
assert.deepEqual(releasedExtraMapped.buttons, ["south"]);
|
||||
assert.deepEqual(releasedExtraMapped.triggers, { left: 123, right: 50000 });
|
||||
profile.shift.mode = "off";
|
||||
profile.triggers.right.output = "north";
|
||||
assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22933 } }, profile).buttons, []);
|
||||
assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22934 } }, profile).buttons, ["north"]);
|
||||
|
||||
// Test output mapping to extra rails/buttons and swap_sticks
|
||||
profile.triggers.right.output = "right_trigger";
|
||||
profile.button_map.south = "left_sl";
|
||||
profile.button_map.east = "right_sr";
|
||||
assert.deepEqual(transformMappings({ ...sample, buttons: ["south", "east"], extra_buttons: [] }, profile).buttons, ["left_sl", "right_sr"]);
|
||||
|
||||
const swapProfile = {
|
||||
...profile,
|
||||
swap_sticks: true,
|
||||
button_map: { ...profile.button_map, left_stick: "left_stick", right_stick: "right_stick" },
|
||||
};
|
||||
assert.deepEqual(
|
||||
transformMappings({ ...sample, buttons: ["left_stick"], extra_buttons: [] }, swapProfile).buttons,
|
||||
["right_stick"],
|
||||
"swap_sticks must swap left_stick click output to right_stick"
|
||||
);
|
||||
assert.deepEqual(
|
||||
transformMappings({ ...sample, buttons: ["right_stick"], extra_buttons: [] }, swapProfile).buttons,
|
||||
["left_stick"],
|
||||
"swap_sticks must swap right_stick click output to left_stick"
|
||||
);
|
||||
assert.deepEqual(
|
||||
transformMappings({ ...sample, buttons: ["left_stick", "right_stick"], extra_buttons: [] }, swapProfile).buttons,
|
||||
["left_stick", "right_stick"],
|
||||
"both stick clicks remain pressed when swapped"
|
||||
);
|
||||
|
||||
const railProfile = {
|
||||
...profile,
|
||||
button_map: { ...profile.button_map, south: "right_sl", east: "right_sl" },
|
||||
extra_button_map: { ...profile.extra_button_map, c: "right_sr" },
|
||||
triggers: {
|
||||
...profile.triggers,
|
||||
left: { ...defaultTrigger, lower_deadzone: 1000, output: "right_sr", digital_threshold: 0 },
|
||||
},
|
||||
};
|
||||
const neutralSample = { buttons: [], extra_buttons: [], triggers: { left: 0, right: 0 } };
|
||||
assert.deepEqual(transformMappings(neutralSample, railProfile).buttons, [],
|
||||
"a zero-threshold extra output cannot activate at rest");
|
||||
assert.deepEqual(transformMappings({ ...neutralSample, triggers: { left: 1000, right: 0 } }, railProfile).buttons, [],
|
||||
"extra trigger routing uses the transformed value, not raw input");
|
||||
assert.deepEqual(transformMappings({ ...neutralSample, triggers: { left: 2000, right: 0 } }, railProfile).buttons, ["right_sr"]);
|
||||
assert.deepEqual(transformMappings({ ...neutralSample, buttons: ["south", "east"], extra_buttons: ["c"] }, railProfile).buttons,
|
||||
["right_sl", "right_sr"], "mapped sources OR together without raw-extra passthrough");
|
||||
railProfile.shift = {
|
||||
...railProfile.shift, mode: "hold", modifier: "c",
|
||||
button_map: { ...railProfile.shift.button_map, south: "left_sr" },
|
||||
extra_button_map: { ...railProfile.shift.extra_button_map, gl: "left_sl" },
|
||||
};
|
||||
assert.deepEqual(transformMappings({ ...neutralSample, buttons: ["south"], extra_buttons: ["c", "gl"] }, railProfile, true).buttons,
|
||||
["left_sl", "left_sr"], "Shift routes ordinary and extra sources to rails while consuming its modifier");
|
||||
|
||||
const axisProfile = {
|
||||
...swapProfile,
|
||||
sticks: {
|
||||
left: { ...defaultStick, inner_deadzone: 5000 },
|
||||
right: { ...defaultStick },
|
||||
},
|
||||
button_map: { ...swapProfile.button_map, south: "left_stick", left_stick: "south" },
|
||||
};
|
||||
const axisSample = {
|
||||
...neutralSample, buttons: ["south", "left_stick"],
|
||||
left_stick: { x: 1234, y: -2345 },
|
||||
right_stick: { x: -32768, y: 17000 },
|
||||
};
|
||||
const physicalSettings = JSON.stringify(axisProfile.sticks);
|
||||
assert.deepEqual(globalThis.ProfilePlaytestMath.transformSticks(axisSample, axisProfile),
|
||||
{ left: { x: -32768, y: 17000 }, right: { x: 0, y: 0 } },
|
||||
"swap follows per-physical-stick calibration, including signed boundaries");
|
||||
assert.deepEqual(transformMappings(axisSample, axisProfile).buttons, ["south", "right_stick"],
|
||||
"swap applies to mapped click outputs, not physical click sources");
|
||||
assert.equal(JSON.stringify(axisProfile.sticks), physicalSettings);
|
||||
axisProfile.swap_sticks = false;
|
||||
assert.deepEqual(globalThis.ProfilePlaytestMath.transformSticks(axisSample, axisProfile),
|
||||
{ left: { x: 0, y: 0 }, right: { x: -32768, y: 17000 } },
|
||||
"turning swap off restores output channels without moving calibration");
|
||||
|
||||
// Test left stick direction outputs and analog stick priority
|
||||
const directionProfile = {
|
||||
...profile,
|
||||
button_map: {
|
||||
...profile.button_map,
|
||||
dpad_up: "left_stick_up",
|
||||
dpad_down: "left_stick_down",
|
||||
dpad_left: "left_stick_left",
|
||||
dpad_right: "left_stick_right",
|
||||
},
|
||||
sticks: {
|
||||
left: { ...defaultStick, inner_deadzone: 5000 },
|
||||
right: { ...defaultStick },
|
||||
},
|
||||
swap_sticks: false,
|
||||
};
|
||||
|
||||
// 1. Cardinal directions produce magnitude 32767
|
||||
const upSample = { ...neutralSample, buttons: ["dpad_up"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(upSample, directionProfile),
|
||||
{ left: { x: 0, y: -32767 }, right: { x: 0, y: 0 } },
|
||||
"left_stick_up produces y = -32767"
|
||||
);
|
||||
assert.deepEqual(
|
||||
transformMappings(upSample, directionProfile).buttons,
|
||||
[],
|
||||
"direction outputs do not populate digital buttons array"
|
||||
);
|
||||
|
||||
const downSample = { ...neutralSample, buttons: ["dpad_down"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(downSample, directionProfile),
|
||||
{ left: { x: 0, y: 32767 }, right: { x: 0, y: 0 } },
|
||||
"left_stick_down produces y = 32767"
|
||||
);
|
||||
|
||||
const leftSample = { ...neutralSample, buttons: ["dpad_left"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(leftSample, directionProfile),
|
||||
{ left: { x: -32767, y: 0 }, right: { x: 0, y: 0 } },
|
||||
"left_stick_left produces x = -32767"
|
||||
);
|
||||
|
||||
const rightSample = { ...neutralSample, buttons: ["dpad_right"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(rightSample, directionProfile),
|
||||
{ left: { x: 32767, y: 0 }, right: { x: 0, y: 0 } },
|
||||
"left_stick_right produces x = 32767"
|
||||
);
|
||||
|
||||
// 2. Diagonal directions produce component 23169
|
||||
const upRightSample = { ...neutralSample, buttons: ["dpad_up", "dpad_right"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(upRightSample, directionProfile),
|
||||
{ left: { x: 23169, y: -23169 }, right: { x: 0, y: 0 } },
|
||||
"diagonal up-right produces x = 23169, y = -23169"
|
||||
);
|
||||
|
||||
const downLeftSample = { ...neutralSample, buttons: ["dpad_down", "dpad_left"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(downLeftSample, directionProfile),
|
||||
{ left: { x: -23169, y: 23169 }, right: { x: 0, y: 0 } },
|
||||
"diagonal down-left produces x = -23169, y = 23169"
|
||||
);
|
||||
|
||||
// 3. Opposites cancel independently
|
||||
const oppositesHorizontal = { ...neutralSample, buttons: ["dpad_left", "dpad_right"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(oppositesHorizontal, directionProfile),
|
||||
{ left: { x: 0, y: 0 }, right: { x: 0, y: 0 } },
|
||||
"left + right opposites cancel to 0"
|
||||
);
|
||||
|
||||
const oppositesAndVertical = { ...neutralSample, buttons: ["dpad_left", "dpad_right", "dpad_up"], left_stick: { x: 0, y: 0 }, right_stick: { x: 0, y: 0 } };
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(oppositesAndVertical, directionProfile),
|
||||
{ left: { x: 0, y: -32767 }, right: { x: 0, y: 0 } },
|
||||
"horizontal opposites cancel while vertical up remains cardinal"
|
||||
);
|
||||
|
||||
// 4. Analog priority: any nonzero mapped analog vector takes priority over digital directions
|
||||
const movingAnalogWithDpad = {
|
||||
...neutralSample,
|
||||
buttons: ["dpad_up"],
|
||||
left_stick: { x: 0, y: 10000 }, // outside inner_deadzone of 5000
|
||||
right_stick: { x: 0, y: 0 },
|
||||
};
|
||||
const analogResult = globalThis.ProfilePlaytestMath.transformSticks(movingAnalogWithDpad, directionProfile);
|
||||
assert.ok(
|
||||
analogResult.left.y > 0,
|
||||
"analog vector outside deadzone takes full priority over digital up direction"
|
||||
);
|
||||
|
||||
// 5. Analog inside inner deadzone evaluates to zero and lets digital directions take over
|
||||
const deadzoneAnalogWithDpad = {
|
||||
...neutralSample,
|
||||
buttons: ["dpad_up"],
|
||||
left_stick: { x: 0, y: 3000 }, // inside inner_deadzone of 5000 -> output is (0, 0)
|
||||
right_stick: { x: 0, y: 0 },
|
||||
};
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(deadzoneAnalogWithDpad, directionProfile),
|
||||
{ left: { x: 0, y: -32767 }, right: { x: 0, y: 0 } },
|
||||
"analog input within deadzone yields (0,0), allowing digital direction to activate"
|
||||
);
|
||||
|
||||
// 6. Stick swap: directions target mapped left stick, which is unaffected by physical stick swap
|
||||
const swappedDirectionProfile = {
|
||||
...directionProfile,
|
||||
swap_sticks: true,
|
||||
};
|
||||
// When swapped, physical right stick maps to left stick output.
|
||||
// If physical right stick has input, it overrides digital directions.
|
||||
const swappedRightStickActive = {
|
||||
...neutralSample,
|
||||
buttons: ["dpad_up"],
|
||||
left_stick: { x: 0, y: 10000 }, // physical left stick (swapped to right output)
|
||||
right_stick: { x: 0, y: 0 }, // physical right stick (swapped to left output, neutral)
|
||||
};
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(swappedRightStickActive, swappedDirectionProfile),
|
||||
{
|
||||
left: { x: 0, y: -32767 },
|
||||
right: globalThis.ProfilePlaytestMath.transformStick(
|
||||
swappedRightStickActive.left_stick, swappedDirectionProfile.sticks.left
|
||||
),
|
||||
},
|
||||
"digital directions apply to mapped left stick when swapped mapped-left input is neutral"
|
||||
);
|
||||
|
||||
// 7. Extra buttons and trigger routing to directions
|
||||
const triggerAndExtraDirectionProfile = {
|
||||
...profile,
|
||||
button_map: { ...profile.button_map },
|
||||
extra_button_map: { ...profile.extra_button_map, c: "left_stick_right" },
|
||||
triggers: {
|
||||
left: { ...defaultTrigger, lower_deadzone: 1000, output: "left_stick_up", digital_threshold: 20000 },
|
||||
right: { ...defaultTrigger, output: "right_trigger" },
|
||||
},
|
||||
sticks: { left: { ...defaultStick }, right: { ...defaultStick } },
|
||||
swap_sticks: false,
|
||||
};
|
||||
const extraSample = {
|
||||
buttons: [],
|
||||
extra_buttons: ["c"],
|
||||
triggers: { left: 30000, right: 0 },
|
||||
left_stick: { x: 0, y: 0 },
|
||||
right_stick: { x: 0, y: 0 },
|
||||
};
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(extraSample, triggerAndExtraDirectionProfile),
|
||||
{ left: { x: 23169, y: -23169 }, right: { x: 0, y: 0 } },
|
||||
"extra button c (right) and left trigger above threshold (up) form diagonal up-right"
|
||||
);
|
||||
|
||||
const shiftedDirectionProfile = {
|
||||
...directionProfile,
|
||||
shift: {
|
||||
...directionProfile.shift,
|
||||
mode: "hold",
|
||||
modifier: "c",
|
||||
button_map: { ...directionProfile.button_map, dpad_up: "left_stick_down" },
|
||||
},
|
||||
};
|
||||
const shiftedDirectionSample = { ...upSample, extra_buttons: ["c"] };
|
||||
const shiftedDirections = transformMappings(shiftedDirectionSample, shiftedDirectionProfile, true);
|
||||
assert.deepEqual(
|
||||
globalThis.ProfilePlaytestMath.transformSticks(shiftedDirectionSample, shiftedDirectionProfile, shiftedDirections),
|
||||
{ left: { x: 0, y: 32767 }, right: { x: 0, y: 0 } },
|
||||
"stick preview must consume the selected Shift mapping rather than remap the base layer"
|
||||
);
|
||||
assert.deepEqual(shiftedDirections.buttons, [], "movement outputs and the Shift modifier cannot become click/button outputs");
|
||||
|
|
|
|||
|
|
@ -312,6 +312,9 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
updated.nunchuk_swing = {255, 0, 18, 0};
|
||||
updated.combined_swing = {15, 255, 16};
|
||||
updated.combination_window_ms = 200;
|
||||
updated.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
updated.swap_sticks = true;
|
||||
updated.button_map[4] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
|
||||
updated.macros[0].trigger_mask = 1u << 24;
|
||||
updated.macros[0].cancel_control = 19;
|
||||
updated.macros[0].step_count = 1;
|
||||
|
|
@ -325,7 +328,7 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"extended service profile did not encode");
|
||||
require(profile_service_begin(
|
||||
20, id, 6, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION,
|
||||
20, id, 6, CONTROLLER_PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION,
|
||||
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
|
||||
ConfigurationTransactionStatus::kUnsupportedSchema &&
|
||||
profile_service_begin(21, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
|
||||
|
|
@ -361,7 +364,8 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
} malformed_gestures[] = {
|
||||
{366, 25}, {367, 0}, {368, 0}, {369, 3}, {370, 25},
|
||||
{371, 1}, {371, 4}, {372, 16}, {373, 0}, {374, 25},
|
||||
{375, 29}, {375, 201}, {376, 1}, {121, 0}};
|
||||
{375, 29}, {375, 201}, {376, 3}, {377, 2}, {378, 1}, {121, 0},
|
||||
{4, CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT}};
|
||||
uint8_t stored_before[sizeof(flash.bytes)];
|
||||
memcpy(stored_before, flash.bytes, sizeof(stored_before));
|
||||
uint32_t transaction_id = 25;
|
||||
|
|
@ -411,6 +415,9 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
"service did not receive both parts of the profile payload");
|
||||
profile_service_selected_snapshot(&selected);
|
||||
require(selected.metadata.generation == old_selection.metadata.generation &&
|
||||
selected.profile.native_joycon_layout ==
|
||||
old_selection.profile.native_joycon_layout &&
|
||||
selected.profile.swap_sticks == old_selection.profile.swap_sticks &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz ==
|
||||
old_selection.profile.turbo_defaults.rate_hz &&
|
||||
selected.profile.nunchuk_swing.macro ==
|
||||
|
|
@ -438,12 +445,17 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
selected.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.combined_swing.modifier == 16 &&
|
||||
selected.profile.combination_window_ms == 200 &&
|
||||
selected.profile.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo &&
|
||||
selected.profile.swap_sticks &&
|
||||
selected.profile.button_map[4] == CONTROLLER_PROFILE_RIGHT_SL_OUTPUT &&
|
||||
active_snapshot(id).profile.swing.button == 2 &&
|
||||
active_snapshot(id).profile.swing.sensitivity == 2 &&
|
||||
active_snapshot(id).profile.swing.modifier == 24 &&
|
||||
active_snapshot(id).profile.nunchuk_swing.macro == 0 &&
|
||||
active_snapshot(id).profile.combined_swing.button == 15 &&
|
||||
active_snapshot(id).profile.combination_window_ms == 200 &&
|
||||
active_snapshot(id).profile.native_joycon_layout == ControllerProfileNativeJoyconLayout::kLeftSolo &&
|
||||
active_snapshot(id).profile.swap_sticks &&
|
||||
active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) &&
|
||||
active_snapshot(id).profile.macros[0].cancel_control == 19 &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "core/controller_identity.h"
|
||||
#include "profile/controller_profile.h"
|
||||
#include "profile/profile_storage.h"
|
||||
#include "controller_profile_legacy_fixtures.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
|
@ -938,6 +939,85 @@ void test_schema8_swing_migration_is_lazy_and_preserves_existing_data() {
|
|||
"editing migrated gestures lost Remote, macros, extras, or metadata");
|
||||
}
|
||||
|
||||
void test_schema9_native_layout_migration_preserves_profiles() {
|
||||
erase_all();
|
||||
install_populated_catalog(3);
|
||||
const auto id = controller_identity_global();
|
||||
uint8_t legacy[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
static_assert(sizeof(kLegacySchema9Profile) == sizeof(legacy));
|
||||
memcpy(legacy, kLegacySchema9Profile, sizeof(legacy));
|
||||
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
|
||||
legacy, sizeof(legacy));
|
||||
ProfileStorage storage;
|
||||
ControllerProfile migrated{};
|
||||
uint8_t migrated_bytes[sizeof(legacy)]{};
|
||||
require(storage.initialize(fake_io()) &&
|
||||
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
|
||||
migrated.native_joycon_layout == ControllerProfileNativeJoyconLayout::kPaired &&
|
||||
!migrated.swap_sticks &&
|
||||
controller_profile_encode(migrated, migrated_bytes, sizeof(migrated_bytes)) &&
|
||||
memcmp(legacy + 2, migrated_bytes + 2, sizeof(legacy) - 2) == 0 &&
|
||||
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
|
||||
flash.programs == 0 && flash.erases == 0,
|
||||
"reading schema9 changed bindings/gestures or rewrote persistent storage");
|
||||
migrated.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
migrated.swap_sticks = true;
|
||||
migrated.button_map[5] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
|
||||
"migrated profile could not save solo layout and rail output");
|
||||
ProfileStorage reloaded;
|
||||
ControllerProfile recovered{};
|
||||
uint8_t expected[sizeof(legacy)]{}, actual[sizeof(legacy)]{};
|
||||
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
|
||||
controller_profile_encode(migrated, expected, sizeof(expected)) &&
|
||||
controller_profile_encode(recovered, actual, sizeof(actual)) &&
|
||||
memcmp(expected, actual, sizeof(expected)) == 0 &&
|
||||
reloaded.find(id)->active_profile == 0 &&
|
||||
reloaded.get_alias(id, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "A") == 0 &&
|
||||
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "A0") == 0,
|
||||
"solo-layout save/reload lost existing profile settings or metadata");
|
||||
}
|
||||
|
||||
void test_schema10_direction_migration_preserves_layout_and_swap() {
|
||||
erase_all();
|
||||
install_populated_catalog(3);
|
||||
const auto id = controller_identity_global();
|
||||
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
|
||||
kLegacySchema10Profile, sizeof(kLegacySchema10Profile));
|
||||
ProfileStorage storage;
|
||||
ControllerProfile migrated{};
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(storage.initialize(fake_io()) &&
|
||||
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
|
||||
migrated.native_joycon_layout != ControllerProfileNativeJoyconLayout::kPaired &&
|
||||
migrated.swap_sticks &&
|
||||
controller_profile_encode(migrated, encoded, sizeof(encoded)) &&
|
||||
memcmp(encoded + 2, kLegacySchema10Profile + 2, sizeof(encoded) - 2) == 0 &&
|
||||
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
|
||||
flash.programs == 0 && flash.erases == 0,
|
||||
"schema10 read changed solo layout/swap/rails or rewrote storage");
|
||||
migrated.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
|
||||
"schema10 profile could not add a digital stick direction");
|
||||
ProfileStorage reloaded;
|
||||
ControllerProfile recovered{};
|
||||
uint8_t expected[sizeof(encoded)]{};
|
||||
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
|
||||
controller_profile_encode(migrated, expected, sizeof(expected)) &&
|
||||
controller_profile_encode(recovered, encoded, sizeof(encoded)) &&
|
||||
memcmp(encoded, expected, sizeof(encoded)) == 0 &&
|
||||
reloaded.find(id)->active_profile == 0 &&
|
||||
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "A0") == 0,
|
||||
"digital direction save lost other settings, active selection or metadata");
|
||||
}
|
||||
|
||||
void require_pair_bank(const ProfileStorage &storage,
|
||||
const ControllerIdentity &owner, uint8_t base,
|
||||
uint8_t active, char name_prefix) {
|
||||
|
|
@ -1168,6 +1248,8 @@ int main() {
|
|||
test_unreadable_legacy_data_is_not_erased();
|
||||
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
|
||||
test_schema8_swing_migration_is_lazy_and_preserves_existing_data();
|
||||
test_schema9_native_layout_migration_preserves_profiles();
|
||||
test_schema10_direction_migration_preserves_layout_and_swap();
|
||||
test_pair_seed_independence_reconnect_and_compaction();
|
||||
test_pair_capacity_and_unseeded_mutations();
|
||||
test_pair_seed_interruption_and_ambiguous_readback();
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
#include "pico/stdlib.h"
|
||||
#include "platform/pico/bootsel_pairing_button.h"
|
||||
#include "profile/controller_profile_runtime.h"
|
||||
#include "profile/profile_service.h"
|
||||
|
||||
namespace {
|
||||
uint64_t now_us = 1000000;
|
||||
uint32_t stage;
|
||||
Bluepad32NativeGamepadSnapshot source;
|
||||
ControllerProfile profile;
|
||||
uint32_t profile_generation = 1;
|
||||
bool alternating_shortcut;
|
||||
bool shortcut_phase;
|
||||
probe_controller_input controls[2];
|
||||
|
|
@ -39,6 +41,7 @@ int bluepad32_input_backend_native_sample_result(uint8_t, uint64_t) { return -1;
|
|||
void bluepad32_input_backend_native_sample_cancel(uint8_t) {}
|
||||
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
|
||||
void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); }
|
||||
uint32_t profile_service_database_generation() { return profile_generation; }
|
||||
bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||
|
|
@ -392,6 +395,284 @@ void nunchuk_buttons_map_to_native_left_shoulders() {
|
|||
no_mouse_or_rails();
|
||||
}
|
||||
|
||||
void inactive_child(uint8_t instance) {
|
||||
memset(reports[instance], 0x5a, sizeof(reports[instance]));
|
||||
assert(!peek(instance) && !controls[instance].active);
|
||||
assert(controls[instance].buttons[0] == 0 && controls[instance].buttons[1] == 0);
|
||||
for (uint8_t byte : controls[instance].stick) assert(byte == 0);
|
||||
for (uint8_t byte : reports[instance]) assert(byte == 0x5a);
|
||||
}
|
||||
|
||||
void solo_controls_and_explicit_rails() {
|
||||
source.accel_valid = source.gyro_valid = false;
|
||||
calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700);
|
||||
calibrate(1, 1800, 1900, 1700, 1800, 1400, 1500);
|
||||
bool ControllerState::* const faces[] = {
|
||||
&ControllerState::button_south, &ControllerState::button_east,
|
||||
&ControllerState::button_west, &ControllerState::button_north};
|
||||
const uint8_t face_bits[2][4] = {{0x02, 0x08, 0x01, 0x04}, {0x04, 0x01, 0x08, 0x02}};
|
||||
ControllerState& state = source.controller.state;
|
||||
for (uint8_t instance = 0; instance < 2; ++instance) {
|
||||
const bool left = instance == 1;
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
profile.native_joycon_layout = left ? ControllerProfileNativeJoyconLayout::kLeftSolo :
|
||||
ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
++profile_generation;
|
||||
for (unsigned face = 0; face < 4; ++face) {
|
||||
state = {};
|
||||
state.*faces[face] = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == face_bits[instance][face]);
|
||||
inactive_child(instance ^ 1);
|
||||
}
|
||||
// Dpad is not silently merged into the four solo face actions.
|
||||
state = {};
|
||||
state.dpad_up = state.dpad_down = state.dpad_left = state.dpad_right = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0);
|
||||
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kDpadRight)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
||||
++profile_generation;
|
||||
consume(instance);
|
||||
assert(reports[instance][2] == face_bits[instance][0]);
|
||||
|
||||
state = {};
|
||||
state.button_select = state.button_start = state.button_system = state.button_capture = true;
|
||||
state.button_left_stick = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0xc0 && reports[instance][3] == 1);
|
||||
state = {};
|
||||
state.button_right_stick = true;
|
||||
state.right_stick_x = INT16_MIN;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0);
|
||||
assert(stick_x(instance) == (left ? 1800 : 2000));
|
||||
assert(stick_y(instance) == (left ? 1900 : 2100));
|
||||
// A live swap selects the physical right stick AND click, without a
|
||||
// physical publication or a second swap in the native routing layer.
|
||||
profile.swap_sticks = true;
|
||||
++profile_generation;
|
||||
consume(instance);
|
||||
assert(reports[instance][2] == 0x80);
|
||||
assert(stick_x(instance) == (left ? 1800 : 2000));
|
||||
assert(stick_y(instance) == (left ? 3700 : 400));
|
||||
state.button_right_stick = false;
|
||||
state.button_left_stick = true;
|
||||
state.right_stick_x = 0;
|
||||
state.right_stick_y = INT16_MIN;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0);
|
||||
assert(stick_x(instance) == (left ? 3500 : 400));
|
||||
assert(stick_y(instance) == (left ? 1900 : 2100));
|
||||
|
||||
state = {};
|
||||
state.button_left_shoulder = true;
|
||||
state.left_trigger = UINT16_MAX;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == (left ? 0x30 : 0));
|
||||
assert(reports[instance][3] == 0);
|
||||
state = {};
|
||||
state.button_right_shoulder = true;
|
||||
state.right_trigger = UINT16_MAX;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == (left ? 0 : 0x30));
|
||||
assert(reports[instance][3] == 0);
|
||||
const uint8_t sl = left ? CONTROLLER_PROFILE_LEFT_SL_OUTPUT : CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
|
||||
const unsigned shoulder_l = static_cast<unsigned>(ControllerProfileLogicalButton::kLeftShoulder);
|
||||
const unsigned shoulder_r = static_cast<unsigned>(ControllerProfileLogicalButton::kRightShoulder);
|
||||
profile.button_map[shoulder_l] = sl;
|
||||
profile.button_map[shoulder_r] = sl + 1;
|
||||
++profile_generation;
|
||||
state = {};
|
||||
state.button_left_shoulder = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0x80);
|
||||
state.button_right_shoulder = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0xc0);
|
||||
state.button_left_shoulder = false;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0x40);
|
||||
// Mapping to the other child's rails never creates a selected-side chord.
|
||||
profile.button_map[shoulder_l] = left ? CONTROLLER_PROFILE_RIGHT_SL_OUTPUT : CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.button_map[shoulder_r] = left ? CONTROLLER_PROFILE_RIGHT_SR_OUTPUT : CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
++profile_generation;
|
||||
state.button_left_shoulder = true;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0);
|
||||
inactive_child(instance ^ 1);
|
||||
// A mapped analog source reaches the same rail wire bit only at its
|
||||
// transformed digital threshold; unmapped physical extras cannot leak.
|
||||
profile.triggers[0].output = sl;
|
||||
profile.triggers[0].digital_threshold = 20000;
|
||||
++profile_generation;
|
||||
state = {};
|
||||
state.extra_buttons = 0x7f;
|
||||
state.left_trigger = 19999;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0);
|
||||
state.left_trigger = 20000;
|
||||
publish(false); consume(instance);
|
||||
assert(reports[instance][2] == 0 && reports[instance][3] == 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
void profile_changes_retire_tokens_without_source_publication() {
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
++profile_generation;
|
||||
source.controller.state = {};
|
||||
source.controller.state.button_south = source.controller.state.dpad_left = true;
|
||||
source.accel_valid = source.gyro_valid = false;
|
||||
publish(false);
|
||||
const uint32_t paired_right = peek(0), paired_left = peek(1);
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
++profile_generation;
|
||||
assert(!probe_controller_input_commit_native_report(0, paired_right));
|
||||
assert(!probe_controller_input_commit_native_report(1, paired_left));
|
||||
consume(0);
|
||||
assert(reports[0][2] == 0x02);
|
||||
inactive_child(1);
|
||||
const uint32_t solo_right = peek(0);
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
++profile_generation;
|
||||
assert(!probe_controller_input_commit_native_report(0, solo_right));
|
||||
consume(1);
|
||||
assert(reports[1][2] == 0x04);
|
||||
inactive_child(0);
|
||||
const uint32_t solo_left = peek(1);
|
||||
// Even an ordinary mapping edit in the same layout retires old reports.
|
||||
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kSouth)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
|
||||
++profile_generation;
|
||||
assert(!probe_controller_input_commit_native_report(1, solo_left));
|
||||
consume(1);
|
||||
assert(reports[1][2] == 0x02);
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kPaired;
|
||||
++profile_generation;
|
||||
pair();
|
||||
assert(reports[0][2] == 0x08 && reports[1][2] == 0x04);
|
||||
}
|
||||
|
||||
void digital_dpad_reaches_the_mapped_left_stick() {
|
||||
source.accel_valid = source.gyro_valid = false;
|
||||
source.controller.active = true;
|
||||
calibrate(0, 2048, 2048, 1000, 1000, 1000, 1000);
|
||||
calibrate(1, 2048, 2048, 1000, 1000, 1000, 1000);
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.button_map[13] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
|
||||
profile.button_map[14] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
|
||||
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
++profile_generation;
|
||||
ControllerState& state = source.controller.state;
|
||||
state = {};
|
||||
state.dpad_up = true;
|
||||
publish(false); pair();
|
||||
assert(stick_x(1) == 2048 && stick_y(1) == 3048);
|
||||
assert(stick_x(0) == 2048 && stick_y(0) == 2048);
|
||||
assert(reports[0][2] == 0 && reports[1][2] == 0);
|
||||
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
++profile_generation;
|
||||
consume(0);
|
||||
assert(stick_x(0) == 1048 && stick_y(0) == 2048);
|
||||
inactive_child(1);
|
||||
state.dpad_right = true;
|
||||
publish(false); consume(0);
|
||||
assert(stick_x(0) == 1341 && stick_y(0) == 2755);
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
++profile_generation;
|
||||
consume(1);
|
||||
assert(stick_x(1) == 2755 && stick_y(1) == 1341);
|
||||
inactive_child(0);
|
||||
|
||||
// Digital directions still target mapped LEFT after swapping. Physical
|
||||
// left movement now belongs to mapped right and must not block them.
|
||||
profile.swap_sticks = true;
|
||||
++profile_generation;
|
||||
state.dpad_up = false;
|
||||
state.left_stick_x = INT16_MAX;
|
||||
publish(false); consume(1);
|
||||
assert(stick_x(1) == 2048 && stick_y(1) == 1048);
|
||||
state.right_stick_y = INT16_MAX;
|
||||
publish(false); consume(1);
|
||||
assert(stick_x(1) == 1048 && stick_y(1) == 2048);
|
||||
// Releasing all inputs cannot leave a generated stick or click held.
|
||||
state = {};
|
||||
publish(false); consume(1);
|
||||
assert(stick_x(1) == 2048 && stick_y(1) == 2048 && reports[1][2] == 0);
|
||||
}
|
||||
|
||||
void solo_motion_rotates_coherently_and_resets_frame() {
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
source.controller.state = {};
|
||||
++source.controller.connection_generation;
|
||||
source.track_stationary_bias = false;
|
||||
source.accel_valid = source.gyro_valid = true;
|
||||
source.accel_q13[0] = 2048;
|
||||
source.accel_q13[1] = 4096;
|
||||
source.accel_q13[2] = -4096;
|
||||
// Parallel acceleration/rate vectors turn about reference gravity. A
|
||||
// one-sided or sign-inconsistent rotation cannot preserve this motion.
|
||||
source.gyro_q10[0] = 30 * 1024;
|
||||
source.gyro_q10[1] = 60 * 1024;
|
||||
source.gyro_q10[2] = -60 * 1024;
|
||||
const ControllerProfileNativeJoyconLayout layouts[] = {
|
||||
ControllerProfileNativeJoyconLayout::kPaired,
|
||||
ControllerProfileNativeJoyconLayout::kLeftSolo,
|
||||
ControllerProfileNativeJoyconLayout::kRightSolo,
|
||||
ControllerProfileNativeJoyconLayout::kPaired};
|
||||
const int32_t body_accel[4][3] = {
|
||||
{2048, 4096, 4096}, {4096, 4096, -2048},
|
||||
{-4096, 4096, 2048}, {2048, 4096, 4096}};
|
||||
uint32_t previous_token = 0;
|
||||
uint8_t previous_instance = 0;
|
||||
publish();
|
||||
for (unsigned layout = 0; layout < 4; ++layout) {
|
||||
profile.native_joycon_layout = layouts[layout];
|
||||
++profile_generation;
|
||||
if (previous_token)
|
||||
assert(!probe_controller_input_commit_native_report(previous_instance, previous_token));
|
||||
const uint8_t instance = layout == 1 ? 1 : layout == 2 ? 0 :
|
||||
(SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 0 : 1;
|
||||
consume(instance);
|
||||
if (layout == 1 || layout == 2) inactive_child(instance ^ 1);
|
||||
if (!(SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance))) {
|
||||
assert(imu_length(instance) == 0);
|
||||
continue;
|
||||
}
|
||||
assert(imu_length(instance) == 30);
|
||||
const uint8_t* imu = reports[instance] + probe_model_imu_data_offset(instance);
|
||||
assert(bits(imu, 12, 12) == 1); // No committed timestamp from the old frame.
|
||||
for (unsigned axis = 0; axis < 3; ++axis)
|
||||
assert(bits(imu, 128 + axis * 32, 32) == static_cast<uint32_t>(body_accel[layout][axis] * 32768));
|
||||
double initial[4]; quaternion(instance, initial);
|
||||
const double w = sqrt((1.0 + body_accel[layout][2] / 6144.0) / 2.0);
|
||||
const double expected[4] = {
|
||||
w, body_accel[layout][1] / (12288.0 * w),
|
||||
-body_accel[layout][0] / (12288.0 * w), 0};
|
||||
double dot = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) dot += initial[i] * expected[i];
|
||||
assert(fabs(fabs(dot) - 1.0) < 1e-6);
|
||||
for (unsigned sample = 0; sample < 250; ++sample) { publish(); consume(instance); }
|
||||
double turned[4]; quaternion(instance, turned);
|
||||
const double half = sqrt(.5);
|
||||
const double expected_turn[4] = {
|
||||
half * initial[0], half * (initial[1] - initial[2]),
|
||||
half * (initial[1] + initial[2]), half * initial[0]};
|
||||
dot = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) dot += turned[i] * expected_turn[i];
|
||||
assert(fabs(fabs(dot) - 1.0) < 1e-5);
|
||||
consume(instance);
|
||||
assert(imu_length(instance) == 0); // No repeated sensor provenance.
|
||||
publish();
|
||||
previous_token = peek(instance);
|
||||
previous_instance = instance;
|
||||
assert(previous_token && imu_length(instance) == 30);
|
||||
// Next layout uses this exact fresh source sample, not a new publication.
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -407,5 +688,9 @@ int main() {
|
|||
selected_motion_target_keeps_both_control_halves();
|
||||
wii_bias_and_independent_sensor_freshness();
|
||||
nunchuk_buttons_map_to_native_left_shoulders();
|
||||
solo_controls_and_explicit_rails();
|
||||
profile_changes_retire_tokens_without_source_publication();
|
||||
digital_dpad_reaches_the_mapped_left_stick();
|
||||
solo_motion_rotates_coherently_and_resets_frame();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include "platform/pico/controller_color_config.h"
|
||||
#include "tusb.h"
|
||||
#include "pico/time.h"
|
||||
#include "profile/controller_profile_transform.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
|
|
@ -810,6 +811,112 @@ void test_motion_cadence_survives_usb_poll_quantization() {
|
|||
expect(!switch_pro_task(0), "motion replayed a stale catch-up burst");
|
||||
}
|
||||
|
||||
void test_profile_rails_and_stick_swap_reach_pro_reports() {
|
||||
initialize_contexts();
|
||||
ControllerProfile profile = controller_profile_default(controller_identity_global(), 0);
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
|
||||
profile.button_map[1] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
|
||||
profile.button_map[2] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
|
||||
profile.button_map[3] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
|
||||
const uint8_t expected_right[] = {0, 0, 0x20, 0x10};
|
||||
const uint8_t expected_left[] = {0x20, 0x10, 0, 0};
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
ControllerState input{};
|
||||
controller_profile_apply_button_mask(static_cast<uint16_t>(1u << i), &input);
|
||||
const auto mapped = controller_profile_transform(input, profile);
|
||||
usb_output_driver_set_input(0, mapped.state,
|
||||
mapped.left_trigger_digital_threshold,
|
||||
mapped.right_trigger_digital_threshold);
|
||||
now_ms += 15;
|
||||
expect(switch_pro_task(0), "mapped rail did not produce a Pro report");
|
||||
const SentReport* packet = latest_regular_report(0);
|
||||
// Compare raw bytes: sharing the packed struct here would hide reversed
|
||||
// Left SL/SR declarations. Both button bytes use SR bit4 / SL bit5.
|
||||
expect(packet && packet->data[3] == expected_right[i] &&
|
||||
packet->data[5] == expected_left[i],
|
||||
"profile rail mapping reached the wrong Pro report bit or leaked its face button");
|
||||
const SwitchProReport current = get_current_report(0, "rail GET_REPORT failed");
|
||||
const auto* bytes = reinterpret_cast<const uint8_t*>(¤t);
|
||||
expect(bytes[3] == expected_right[i] && bytes[5] == expected_left[i],
|
||||
"Pro GET_REPORT disagreed with streamed rail state");
|
||||
}
|
||||
ControllerState input{};
|
||||
auto mapped = controller_profile_transform(input, profile);
|
||||
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
|
||||
mapped.right_trigger_digital_threshold);
|
||||
now_ms += 15;
|
||||
expect(switch_pro_task(0), "rail release did not produce a Pro report");
|
||||
const SentReport* released = latest_regular_report(0);
|
||||
expect(released && released->data[3] == 0 && released->data[5] == 0,
|
||||
"released rail mapping stayed pressed");
|
||||
|
||||
profile.swap_sticks = true;
|
||||
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
input.left_stick_x = 1600;
|
||||
input.left_stick_y = 3200;
|
||||
input.right_stick_x = -1600;
|
||||
input.right_stick_y = -3200;
|
||||
input.button_left_stick = true;
|
||||
mapped = controller_profile_transform(input, profile);
|
||||
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
|
||||
mapped.right_trigger_digital_threshold);
|
||||
now_ms += 15;
|
||||
expect(switch_pro_task(0), "swapped sticks did not produce a Pro report");
|
||||
SwitchProReport swapped = copy_switch_report(latest_regular_report(0));
|
||||
expect(swapped.inputs.leftStick.getX() == (controller_axis_to_unsigned(input.right_stick_x) >> 4) &&
|
||||
swapped.inputs.leftStick.getY() ==
|
||||
((-(controller_axis_to_unsigned(input.right_stick_y) >> 4)) & 0xfff) &&
|
||||
swapped.inputs.rightStick.getX() == (controller_axis_to_unsigned(input.left_stick_x) >> 4) &&
|
||||
swapped.inputs.rightStick.getY() ==
|
||||
((-(controller_axis_to_unsigned(input.left_stick_y) >> 4)) & 0xfff) &&
|
||||
swapped.inputs.buttonThumbR && !swapped.inputs.buttonThumbL,
|
||||
"Pro output lost swapped axes/clicks or applied native-only solo routing");
|
||||
SwitchProReport untouched = get_current_report(1, "other Pro instance GET_REPORT failed");
|
||||
const auto* untouched_bytes = reinterpret_cast<const uint8_t*>(&untouched);
|
||||
expect((untouched_bytes[3] & 0x30) == 0 && (untouched_bytes[5] & 0x30) == 0 &&
|
||||
!untouched.inputs.buttonThumbL && !untouched.inputs.buttonThumbR,
|
||||
"mapped rails or stick clicks crossed Pro instances");
|
||||
}
|
||||
|
||||
void test_digital_dpad_reaches_pro_stick_without_dpad_leakage() {
|
||||
initialize_contexts();
|
||||
ControllerProfile profile = controller_profile_default(controller_identity_global(), 0);
|
||||
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
|
||||
const auto send = [&](const ControllerState& input) {
|
||||
const auto mapped = controller_profile_transform(input, profile);
|
||||
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
|
||||
mapped.right_trigger_digital_threshold);
|
||||
now_ms += 15;
|
||||
expect(switch_pro_task(0), "digital-stick Pro input did not send");
|
||||
return copy_switch_report(latest_regular_report(0));
|
||||
};
|
||||
ControllerState input{};
|
||||
input.dpad_up = input.dpad_right = true;
|
||||
SwitchProReport digital = send(input);
|
||||
input = {};
|
||||
input.left_stick_x = 23169;
|
||||
input.left_stick_y = -23169;
|
||||
SwitchProReport analog = send(input);
|
||||
expect(digital.inputs.leftStick.getX() == analog.inputs.leftStick.getX() &&
|
||||
digital.inputs.leftStick.getY() == analog.inputs.leftStick.getY() &&
|
||||
!digital.inputs.dpadUp && !digital.inputs.dpadRight &&
|
||||
!digital.inputs.buttonThumbL,
|
||||
"digital diagonal did not reach the Pro left stick or leaked Dpad/L3 buttons");
|
||||
input.dpad_up = true;
|
||||
input.left_stick_x = -1600;
|
||||
input.left_stick_y = 3200;
|
||||
SwitchProReport priority = send(input);
|
||||
input.dpad_up = false;
|
||||
SwitchProReport reference = send(input);
|
||||
expect(priority.inputs.leftStick.getX() == reference.inputs.leftStick.getX() &&
|
||||
priority.inputs.leftStick.getY() == reference.inputs.leftStick.getY(),
|
||||
"digital direction overrode active Pro analog movement");
|
||||
input = {};
|
||||
SwitchProReport released = send(input);
|
||||
expect_neutral_sticks(released, "released digital directions left the Pro stick held");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" absolute_time_t get_absolute_time(void) {
|
||||
|
|
@ -854,6 +961,8 @@ int main() {
|
|||
test_startup_identify_preserves_first_reply_counter();
|
||||
test_failed_startup_identify_retries_preserve_counter();
|
||||
test_input_reports_and_timers_are_isolated();
|
||||
test_profile_rails_and_stick_swap_reach_pro_reports();
|
||||
test_digital_dpad_reaches_pro_stick_without_dpad_leakage();
|
||||
test_callback_send_and_imu_modes_are_isolated();
|
||||
test_rumble_callbacks_and_decoders_are_isolated();
|
||||
test_grip_colors_are_isolated();
|
||||
|
|
|
|||
|
|
@ -1964,6 +1964,8 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
"nunchuk_swing",
|
||||
"combined_swing",
|
||||
"combination_window_ms",
|
||||
"native_joycon_layout",
|
||||
"swap_sticks",
|
||||
):
|
||||
del legacy_json_object[field]
|
||||
del legacy_json_object["motion_toggle_chord"]
|
||||
|
|
@ -2036,6 +2038,8 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None:
|
|||
"nunchuk_swing",
|
||||
"combined_swing",
|
||||
"combination_window_ms",
|
||||
"native_joycon_layout",
|
||||
"swap_sticks",
|
||||
):
|
||||
del obj[field]
|
||||
for macro in obj["macros"]:
|
||||
|
|
@ -2087,7 +2091,10 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
legacy_json["schema_version"] = 6
|
||||
del legacy_json["extra_button_map"]
|
||||
del legacy_json["shift"]["extra_button_map"]
|
||||
for field in ("swing", "nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
for field in (
|
||||
"swing", "nunchuk_swing", "combined_swing", "combination_window_ms",
|
||||
"native_joycon_layout", "swap_sticks",
|
||||
):
|
||||
del legacy_json[field]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
|
|
@ -2108,7 +2115,7 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, 8, 9])
|
||||
@pytest.mark.parametrize("version", [7, 8, 9, 10, 11])
|
||||
def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
|
||||
version: int,
|
||||
) -> None:
|
||||
|
|
@ -2145,6 +2152,9 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
|
|||
obj["macros"][index]["trigger"] = names
|
||||
obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3]
|
||||
obj["schema_version"] = version
|
||||
if version < 10:
|
||||
del obj["native_joycon_layout"]
|
||||
del obj["swap_sticks"]
|
||||
if version == 7:
|
||||
del obj["swing"]
|
||||
elif version == 8:
|
||||
|
|
@ -2196,11 +2206,15 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
|
|||
("button_map", "south"),
|
||||
("extra_button_map", "c"),
|
||||
("triggers", "left", "output"),
|
||||
("shift", "button_map", "south"),
|
||||
("shift", "extra_button_map", "c"),
|
||||
],
|
||||
)
|
||||
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> None:
|
||||
def test_schema9_rejects_extra_output_destinations(path: tuple[str, ...]) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
obj["schema_version"] = 9
|
||||
del obj["native_joycon_layout"]
|
||||
del obj["swap_sticks"]
|
||||
target = obj
|
||||
for key in path[:-1]:
|
||||
target = target[key]
|
||||
|
|
@ -2209,6 +2223,288 @@ def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) ->
|
|||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("layout", "layout_byte", "swap"),
|
||||
[("paired", 0, False), ("left_solo", 1, True), ("right_solo", 2, False)],
|
||||
)
|
||||
def test_native_profile_fields_and_rail_outputs_round_trip(
|
||||
layout: str, layout_byte: int, swap: bool
|
||||
) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj["native_joycon_layout"] = layout
|
||||
obj["swap_sticks"] = swap
|
||||
obj["switching_chord"] = ["right_sr"]
|
||||
obj["macros"][0]["trigger"] = ["right_sr"]
|
||||
obj["macros"][0]["cancel"] = "right_sr"
|
||||
obj["shift"]["modifier"] = "left_sl"
|
||||
obj["button_map"]["south"] = "left_sl"
|
||||
obj["extra_button_map"]["c"] = "left_sr"
|
||||
obj["shift"]["button_map"]["east"] = "right_sl"
|
||||
obj["shift"]["button_map"]["north"] = "left_trigger"
|
||||
obj["shift"]["extra_button_map"]["right_sr"] = "right_sr"
|
||||
obj["triggers"]["left"]["output"] = "right_sr"
|
||||
obj["triggers"]["right"]["output"] = "right_sr"
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
payload = profile.to_bytes()
|
||||
assert payload[376:384] == bytes((layout_byte, int(swap), 0, 0, 0, 0, 0, 0))
|
||||
assert payload[4] == 18
|
||||
assert payload[344] == 19
|
||||
assert payload[268] == 20
|
||||
assert payload[270] == 16
|
||||
assert payload[357] == payload[60] == payload[70] == 21
|
||||
assert payload[358] == payload[362] == 1 << 6
|
||||
assert payload[266] == 21
|
||||
assert profile.macros[0].cancel_control == 24
|
||||
assert config_manager.ControllerProfile.from_bytes(payload) == profile
|
||||
assert profile.to_json_object() == obj
|
||||
obj["triggers"]["left"]["output"] = "left_trigger"
|
||||
obj["triggers"]["right"]["output"] = "left_trigger"
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "source"),
|
||||
[
|
||||
(("button_map", "south"), "c"),
|
||||
(("extra_button_map", "c"), "gl"),
|
||||
(("triggers", "left", "output"), "gr"),
|
||||
(("shift", "button_map", "south"), "c"),
|
||||
(("shift", "extra_button_map", "c"), "gl"),
|
||||
],
|
||||
)
|
||||
def test_source_only_controls_are_not_output_destinations(
|
||||
path: tuple[str, ...], source: str
|
||||
) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
target = obj
|
||||
for key in path[:-1]:
|
||||
target = target[key]
|
||||
target[path[-1]] = source
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
def test_left_stick_direction_destinations_round_trip_without_new_sources() -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj["button_map"].update(
|
||||
dpad_up="left_stick_up", dpad_down="left_stick_down",
|
||||
dpad_left="left_stick_left", dpad_right="left_stick_right",
|
||||
)
|
||||
obj["extra_button_map"]["c"] = "left_stick_left"
|
||||
obj["shift"]["button_map"]["south"] = "left_stick_down"
|
||||
obj["shift"]["extra_button_map"]["right_sr"] = "left_stick_right"
|
||||
obj["triggers"]["left"]["output"] = "left_stick_up"
|
||||
obj["triggers"]["right"]["output"] = "left_stick_up"
|
||||
obj["shift"]["modifier"] = "right_sr"
|
||||
obj["shortcuts"]["modifier"] = "right_sr"
|
||||
obj["macros"][0]["trigger"] = ["right_sr"]
|
||||
obj["macros"][0]["cancel"] = "right_sr"
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
encoded = profile.to_bytes()
|
||||
assert encoded[:4] == struct.pack("<HH", 11, 384)
|
||||
assert encoded[16:20] == bytes((22, 23, 24, 25))
|
||||
assert encoded[344] == 24
|
||||
assert encoded[267] == 23
|
||||
assert encoded[357] == 25
|
||||
assert encoded[60] == encoded[70] == 22
|
||||
assert encoded[378:384] == bytes(6)
|
||||
assert profile.shift.modifier == profile.shortcuts.modifier == 24
|
||||
assert profile.macros[0].trigger_mask == 1 << 24
|
||||
assert profile.macros[0].cancel_control == 24
|
||||
assert profile.button_map[10] == 10
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
|
||||
assert profile.to_json_object() == obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "offset"),
|
||||
[
|
||||
(("button_map", "south"), 4),
|
||||
(("extra_button_map", "c"), 344),
|
||||
(("triggers", "left", "output"), 60),
|
||||
(("triggers", "right", "output"), 70),
|
||||
(("shift", "button_map", "south"), 267),
|
||||
(("shift", "extra_button_map", "c"), 351),
|
||||
],
|
||||
)
|
||||
def test_schema10_rejects_direction_destinations(
|
||||
path: tuple[str, ...], offset: int
|
||||
) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
obj["schema_version"] = 10
|
||||
target = obj
|
||||
for key in path[:-1]:
|
||||
target = target[key]
|
||||
target[path[-1]] = "left_stick_up"
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
payload[0] = 10
|
||||
payload[offset] = 22
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
[
|
||||
(("switching_chord",), ["left_stick_up"]),
|
||||
(("motion_toggle_chord",), ["left_stick_up"]),
|
||||
(("shift", "modifier"), "left_stick_up"),
|
||||
(("shortcuts", "modifier"), "left_stick_up"),
|
||||
(("macros", 0, "trigger"), ["left_stick_up"]),
|
||||
(("macros", 0, "cancel"), "left_stick_up"),
|
||||
(("swing", "modifier"), "left_stick_up"),
|
||||
(("swing", "button"), "left_stick_up"),
|
||||
(("macros", 0, "steps", 0, "output_buttons"), ["left_stick_up"]),
|
||||
],
|
||||
)
|
||||
def test_direction_outputs_do_not_expand_sources_or_macro_swing_buttons(
|
||||
path: tuple[str | int, ...], value: object
|
||||
) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
target = obj
|
||||
for key in path[:-1]:
|
||||
target = target[key]
|
||||
target[path[-1]] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
def test_schema10_native_settings_migrate_without_persistent_writes() -> None:
|
||||
base = custom_profile()
|
||||
expected = replace(
|
||||
base,
|
||||
native_joycon_layout=2,
|
||||
swap_sticks=True,
|
||||
button_map=(18, *base.button_map[1:]),
|
||||
extra_button_map=(19, *base.extra_button_map[1:]),
|
||||
shift=replace(
|
||||
base.shift,
|
||||
button_map=(20, *base.shift.button_map[1:]),
|
||||
extra_button_map=(21, *base.shift.extra_button_map[1:]),
|
||||
),
|
||||
left_trigger=replace(base.left_trigger, output=21),
|
||||
right_trigger=replace(base.right_trigger, output=21),
|
||||
)
|
||||
legacy_wire = bytearray(expected.to_bytes())
|
||||
legacy_wire[0] = 10
|
||||
legacy_json = expected.to_json_object()
|
||||
legacy_json["schema_version"] = 10
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == expected
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == expected
|
||||
device = FakeDevice()
|
||||
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
|
||||
profiles_before = device.profiles.copy()
|
||||
active_before = device.active_profiles.copy()
|
||||
generation_before = device.profile_generation
|
||||
assert config_manager.read_profile(device, device.stable_identity, 1) == expected
|
||||
assert device.profiles == profiles_before
|
||||
assert device.active_profiles == active_before
|
||||
assert device.profile_generation == generation_before
|
||||
assert expected.to_bytes()[2:] == legacy_wire[2:]
|
||||
listing = config_manager.parse_profile_list(
|
||||
config_manager.parse_response(
|
||||
make_response(
|
||||
config_manager.OP_PROFILE_LIST,
|
||||
device._profile_list_payload(),
|
||||
schema=10,
|
||||
),
|
||||
config_manager.OP_PROFILE_LIST,
|
||||
)
|
||||
)
|
||||
assert listing[1].identity == device.stable_identity
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [1, 2])
|
||||
def test_legacy_button_maps_reject_analog_destinations(version: int) -> None:
|
||||
payload = legacy_profile_wire(version)
|
||||
assert config_manager.ControllerProfile.from_bytes(payload).button_map == tuple(range(16))
|
||||
payload[4] = 16
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
def test_schema9_gestures_migrate_without_persistent_writes() -> None:
|
||||
expected = replace(
|
||||
custom_profile(),
|
||||
swing=config_manager.ProfileSwing(255, 2, 24, 0),
|
||||
nunchuk_swing=config_manager.ProfileSwing(15, 0, 18),
|
||||
combined_swing=config_manager.ProfileCombinedSwing(255, 1, 16),
|
||||
combination_window_ms=200,
|
||||
)
|
||||
legacy_wire = bytearray(expected.to_bytes())
|
||||
legacy_wire[0] = 9
|
||||
legacy_json = expected.to_json_object()
|
||||
legacy_json["schema_version"] = 9
|
||||
del legacy_json["native_joycon_layout"]
|
||||
del legacy_json["swap_sticks"]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == expected
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == expected
|
||||
device = FakeDevice()
|
||||
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
|
||||
profiles_before = device.profiles.copy()
|
||||
active_before = device.active_profiles.copy()
|
||||
generation_before = device.profile_generation
|
||||
assert config_manager.read_profile(device, device.stable_identity, 1) == expected
|
||||
assert device.profiles == profiles_before
|
||||
assert device.active_profiles == active_before
|
||||
assert device.profile_generation == generation_before
|
||||
migrated = config_manager.read_selected_profile(device)
|
||||
assert migrated.native_joycon_layout == 0
|
||||
assert migrated.swap_sticks is False
|
||||
assert migrated.to_bytes()[2:] == legacy_wire[2:]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "value"),
|
||||
[(4, 18), (60, 18), (70, 24), (344, 18), (267, 16), (351, 16),
|
||||
(376, 1), (377, 1), (383, 1)],
|
||||
)
|
||||
def test_schema9_keeps_legacy_output_and_reserved_byte_bounds(
|
||||
offset: int, value: int
|
||||
) -> None:
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
payload[0] = 9
|
||||
payload[offset] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "value"),
|
||||
[(376, 3), (376, 255), (377, 2), (377, 128), (377, 255), (383, 1)],
|
||||
)
|
||||
def test_native_fields_reject_invalid_wire_values(offset: int, value: int) -> None:
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
payload[offset] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[("native_joycon_layout", "solo"), ("native_joycon_layout", 1),
|
||||
("native_joycon_layout", None), ("swap_sticks", 1),
|
||||
("swap_sticks", "false"), ("swap_sticks", None)],
|
||||
)
|
||||
def test_native_fields_reject_invalid_json_types(field: str, value: object) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj[field] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
def test_missing_native_fields_preserve_legacy_behavior() -> None:
|
||||
expected = custom_profile()
|
||||
obj = expected.to_json_object()
|
||||
del obj["native_joycon_layout"]
|
||||
del obj["swap_sticks"]
|
||||
assert config_manager.ControllerProfile.from_json_object(obj) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offset,value",
|
||||
[
|
||||
|
|
@ -2289,6 +2585,8 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
|
|||
"nunchuk_swing",
|
||||
"combined_swing",
|
||||
"combination_window_ms",
|
||||
"native_joycon_layout",
|
||||
"swap_sticks",
|
||||
):
|
||||
del obj[key]
|
||||
assert config_manager.ControllerProfile.from_json_object(obj) == profile
|
||||
|
|
@ -2389,7 +2687,7 @@ def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) ->
|
|||
(374, 25),
|
||||
(375, 29),
|
||||
(375, 201),
|
||||
(376, 1),
|
||||
(378, 1),
|
||||
],
|
||||
)
|
||||
def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
|
||||
|
|
@ -2422,6 +2720,8 @@ def test_swing_json_fields_are_strict(mutation: str) -> None:
|
|||
else:
|
||||
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
del obj[field]
|
||||
del obj["native_joycon_layout"]
|
||||
del obj["swap_sticks"]
|
||||
obj["schema_version"] = 7
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
|
@ -2435,7 +2735,10 @@ def test_schema8_swing_migration_preserves_remote_binding() -> None:
|
|||
legacy_json = profile.to_json_object()
|
||||
legacy_json["schema_version"] = 8
|
||||
del legacy_json["swing"]["macro"]
|
||||
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
for field in (
|
||||
"nunchuk_swing", "combined_swing", "combination_window_ms",
|
||||
"native_joycon_layout", "swap_sticks",
|
||||
):
|
||||
del legacy_json[field]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
|
|
@ -2566,7 +2869,7 @@ def test_combination_window_rejects_out_of_range_or_noninteger_values(
|
|||
(("shortcuts", "profiles", 1), "south"),
|
||||
(("shortcuts", "profiles", 0), "left_shoulder"),
|
||||
(("shift", "modifier"), None),
|
||||
(("shift", "button_map", "south"), "left_trigger"),
|
||||
(("shift", "button_map", "south"), "unknown"),
|
||||
(("turbo_settings", "defaults", "rate_hz"), 0),
|
||||
(("turbo_settings", "defaults", "rate_hz"), 31),
|
||||
(("turbo_settings", "defaults", "duty_percent"), 0),
|
||||
|
|
@ -2598,7 +2901,7 @@ def test_set_b_rejects_invalid_json_settings(path: tuple, value: object) -> None
|
|||
(288, 1),
|
||||
(383, 1),
|
||||
(265, 3),
|
||||
(267, 16),
|
||||
(267, 26),
|
||||
(283, 31),
|
||||
(284, 100),
|
||||
(285, 0),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
|
||||
from switch_pico_bridge import config_manager
|
||||
|
||||
|
||||
def test_controller_profile_native(tmp_path: Path) -> None:
|
||||
|
|
@ -28,3 +31,83 @@ def test_controller_profile_native(tmp_path: Path) -> None:
|
|||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
||||
legacy = subprocess.run(
|
||||
[str(executable), "--legacy9"], check=True, cwd=root, capture_output=True
|
||||
).stdout
|
||||
migrated = config_manager.ControllerProfile.from_bytes(legacy)
|
||||
assert migrated.to_bytes()[2:] == legacy[2:]
|
||||
assert migrated.native_joycon_layout == 0
|
||||
assert migrated.swap_sticks is False
|
||||
assert migrated.swing.macro == 0
|
||||
assert migrated.nunchuk_swing.button == 15
|
||||
assert migrated.combined_swing.macro == 3
|
||||
upgraded = subprocess.run(
|
||||
[str(executable), "--codec"],
|
||||
input=legacy, check=True, cwd=root, capture_output=True,
|
||||
).stdout
|
||||
assert upgraded == migrated.to_bytes()
|
||||
|
||||
legacy10 = subprocess.run(
|
||||
[str(executable), "--legacy10"], check=True, cwd=root, capture_output=True
|
||||
).stdout
|
||||
migrated10 = config_manager.ControllerProfile.from_bytes(legacy10)
|
||||
assert legacy10[:4] == bytes((10, 0, 128, 1))
|
||||
assert migrated10.native_joycon_layout == 2
|
||||
assert migrated10.swap_sticks is True
|
||||
assert migrated10.button_map[0] == 18
|
||||
assert migrated10.extra_button_map[0] == 19
|
||||
assert migrated10.shift.button_map[0] == 20
|
||||
assert migrated10.shift.extra_button_map[0] == 21
|
||||
assert migrated10.left_trigger.output == migrated10.right_trigger.output == 21
|
||||
assert migrated10.to_bytes()[2:] == legacy10[2:]
|
||||
upgraded10 = subprocess.run(
|
||||
[str(executable), "--codec"],
|
||||
input=legacy10, check=True, cwd=root, capture_output=True,
|
||||
).stdout
|
||||
assert upgraded10 == migrated10.to_bytes()
|
||||
|
||||
directions = replace(
|
||||
migrated10,
|
||||
button_map=(*migrated10.button_map[:12], 22, 23, 24, 25),
|
||||
extra_button_map=(24, *migrated10.extra_button_map[1:]),
|
||||
shift=replace(
|
||||
migrated10.shift,
|
||||
button_map=(23, *migrated10.shift.button_map[1:]),
|
||||
extra_button_map=(25, *migrated10.shift.extra_button_map[1:]),
|
||||
),
|
||||
left_trigger=replace(migrated10.left_trigger, output=22),
|
||||
right_trigger=replace(migrated10.right_trigger, output=22),
|
||||
)
|
||||
direction_wire = directions.to_bytes()
|
||||
native_directions = subprocess.run(
|
||||
[str(executable), "--codec"],
|
||||
input=direction_wire, check=True, cwd=root, capture_output=True,
|
||||
).stdout
|
||||
assert native_directions == direction_wire
|
||||
assert config_manager.ControllerProfile.from_bytes(native_directions) == directions
|
||||
assert config_manager.ControllerProfile.from_json(directions.to_json()) == directions
|
||||
|
||||
for layout in range(len(config_manager.NATIVE_JOYCON_LAYOUTS)):
|
||||
for swap in (False, True):
|
||||
profile = replace(
|
||||
migrated,
|
||||
native_joycon_layout=layout,
|
||||
swap_sticks=swap,
|
||||
button_map=(18, *migrated.button_map[1:]),
|
||||
extra_button_map=(19, *migrated.extra_button_map[1:]),
|
||||
shift=replace(
|
||||
migrated.shift,
|
||||
button_map=(20, *migrated.shift.button_map[1:]),
|
||||
extra_button_map=(21, *migrated.shift.extra_button_map[1:]),
|
||||
),
|
||||
left_trigger=replace(migrated.left_trigger, output=21),
|
||||
right_trigger=replace(migrated.right_trigger, output=21),
|
||||
)
|
||||
encoded = profile.to_bytes()
|
||||
native = subprocess.run(
|
||||
[str(executable), "--codec"],
|
||||
input=encoded, check=True, cwd=root, capture_output=True,
|
||||
).stdout
|
||||
assert native == encoded
|
||||
assert config_manager.ControllerProfile.from_bytes(native) == profile
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ def test_native_hub_editor_discovers_root_and_preserves_saved_state(
|
|||
(0x2066, {"c", "right_sl", "right_sr"}),
|
||||
],
|
||||
)
|
||||
def test_switch2_input_choices_are_never_output_targets(
|
||||
def test_switch2_source_choices_stay_physical_with_expanded_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
product_id: int,
|
||||
extras: set[str],
|
||||
|
|
@ -207,11 +207,13 @@ def test_switch2_input_choices_are_never_output_targets(
|
|||
assert owner["controller"]["style"] == "switch"
|
||||
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
||||
assert set(owner["modifier_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
||||
assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"])
|
||||
assert set(schema["extra_buttons"]) & set(schema["output_controls"]) == {
|
||||
"left_sl", "left_sr", "right_sl", "right_sr",
|
||||
}
|
||||
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [6, 7, 8])
|
||||
@pytest.mark.parametrize("version", [6, 7, 8, 9])
|
||||
def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
version: int,
|
||||
|
|
@ -227,8 +229,10 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|||
legacy_wire[344:] = bytes(40)
|
||||
elif version == 7:
|
||||
legacy_wire[364:] = bytes(20)
|
||||
else:
|
||||
elif version == 8:
|
||||
legacy_wire[367:] = bytes(17)
|
||||
else:
|
||||
legacy_wire[376:] = bytes(8)
|
||||
device.profiles[key] = bytes(legacy_wire)
|
||||
device.profile_aliases[key[0]] = "Living room"
|
||||
device.profile_names[key] = "Racing"
|
||||
|
|
@ -241,6 +245,8 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|||
== profile
|
||||
)
|
||||
assert migrated["profile"]["swing"] == profile.swing.to_json_object()
|
||||
assert migrated["profile"]["native_joycon_layout"] == "paired"
|
||||
assert migrated["profile"]["swap_sticks"] is False
|
||||
draft = migrated["profile"]
|
||||
draft["extra_button_map"] = dict(
|
||||
zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])
|
||||
|
|
@ -657,7 +663,6 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
|
|||
]
|
||||
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
|
||||
assert playtest["triggers"] == {"left": 123, "right": 65000}
|
||||
assert playtest["battery"] == 100
|
||||
assert playtest["capabilities"] == [
|
||||
"rumble",
|
||||
"lightbar",
|
||||
|
|
@ -1170,3 +1175,95 @@ def test_wii_orientation_endpoint_validation_and_failures(
|
|||
)
|
||||
assert status == 503
|
||||
assert "error" in err
|
||||
|
||||
|
||||
def test_native_layout_draft_roundtrip_is_isolated_from_other_profiles(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
original_profiles = dict(device.profiles)
|
||||
original_active = dict(device.active_profiles)
|
||||
original_configuration = device.configuration
|
||||
key = (device.stable_identity.to_bytes(), 7)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
assert status == 200
|
||||
assert schema["native_joycon_layouts"] == ["paired", "left_solo", "right_solo"]
|
||||
status, selected = request_json(f"{base_url}/api/profiles/1/8")
|
||||
assert status == 200
|
||||
draft = selected["profile"]
|
||||
draft["native_joycon_layout"] = "right_solo"
|
||||
draft["swap_sticks"] = True
|
||||
draft["button_map"]["left_shoulder"] = "right_sl"
|
||||
draft["button_map"]["right_shoulder"] = "right_sr"
|
||||
draft["shift"].update(mode="hold", modifier="select")
|
||||
draft["shift"]["button_map"]["south"] = "left_sl"
|
||||
draft["triggers"]["left"]["output"] = "left_sr"
|
||||
draft["sticks"]["left"]["inner_deadzone"] = 4000
|
||||
status, validated = request_json(
|
||||
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
|
||||
)
|
||||
assert status == 200
|
||||
assert validated["profile"] == draft
|
||||
assert device.profiles == original_profiles
|
||||
assert device.active_profiles == original_active
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/1/8", method="PUT",
|
||||
value=validated["profile"], token=token,
|
||||
)
|
||||
assert status == 200
|
||||
status, stored = request_json(f"{base_url}/api/profiles/1/8")
|
||||
assert status == 200
|
||||
assert stored["profile"] == draft
|
||||
expected_profiles = dict(original_profiles)
|
||||
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
|
||||
assert device.profiles == expected_profiles
|
||||
assert device.active_profiles == original_active
|
||||
assert device.configuration == original_configuration
|
||||
|
||||
|
||||
def test_left_stick_direction_outputs_roundtrip_and_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
original_profiles = dict(device.profiles)
|
||||
original_active = dict(device.active_profiles)
|
||||
original_configuration = device.configuration
|
||||
key = (device.stable_identity.to_bytes(), 0)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
assert status == 200
|
||||
assert "left_stick_up" in schema["output_controls"]
|
||||
assert "left_stick_down" in schema["output_controls"]
|
||||
assert "left_stick_left" in schema["output_controls"]
|
||||
assert "left_stick_right" in schema["output_controls"]
|
||||
status, selected = request_json(f"{base_url}/api/profiles/1/1")
|
||||
assert status == 200
|
||||
draft = selected["profile"]
|
||||
# Map D-pad to left stick movement
|
||||
draft["button_map"]["dpad_up"] = "left_stick_up"
|
||||
draft["button_map"]["dpad_down"] = "left_stick_down"
|
||||
draft["button_map"]["dpad_left"] = "left_stick_left"
|
||||
draft["button_map"]["dpad_right"] = "left_stick_right"
|
||||
draft["extra_button_map"]["c"] = "left_stick_up"
|
||||
draft["shift"]["button_map"]["south"] = "left_stick_down"
|
||||
draft["triggers"]["left"]["output"] = "left_stick_left"
|
||||
status, validated = request_json(
|
||||
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
|
||||
)
|
||||
assert status == 200
|
||||
assert validated["profile"] == draft
|
||||
assert device.profiles == original_profiles
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/1/1", method="PUT",
|
||||
value=validated["profile"], token=token,
|
||||
)
|
||||
assert status == 200
|
||||
status, stored = request_json(f"{base_url}/api/profiles/1/1")
|
||||
assert status == 200
|
||||
assert stored["profile"] == draft
|
||||
expected_profiles = dict(original_profiles)
|
||||
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
|
||||
assert device.profiles == expected_profiles
|
||||
assert device.active_profiles == original_active
|
||||
assert device.configuration == original_configuration
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ def test_switch_pro_driver_four_contexts_native(tmp_path: Path) -> None:
|
|||
str(root / "src" / "firmware" / "usb" / "switch" / "switch_pro_driver.cpp"),
|
||||
str(root / "src" / "firmware" / "usb" / "usb_output_driver.cpp"),
|
||||
str(root / "src" / "firmware" / "usb" / "switch" / "switch_haptics.cpp"),
|
||||
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile_transform.cpp"),
|
||||
str(root / "tests" / "switch_pro_driver_context_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue