From d15b0ec711244eef75cb5cb212f46ee7a64e28d1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 15 Sep 2026 17:33:31 -0600 Subject: [PATCH] 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. --- README.md | 53 ++- src/firmware/core/controller_state.h | 3 +- src/firmware/profile/controller_profile.cpp | 77 ++++- src/firmware/profile/controller_profile.h | 22 +- .../profile/controller_profile_transform.cpp | 86 ++++- .../profile/controller_profile_transform.h | 2 + .../usb/switch/switch_pro_descriptors.h | 2 +- src/firmware/usb/switch/switch_pro_driver.cpp | 8 +- .../usb/usb_configuration_management.cpp | 6 +- src/switch_pico_bridge/config_manager.py | 184 ++++++++-- src/switch_pico_bridge/profile_web.py | 31 +- .../web/controller_layouts.js | 6 +- src/switch_pico_bridge/web/profile_editor.css | 10 + .../web/profile_editor.html | 27 +- src/switch_pico_bridge/web/profile_editor.js | 132 +++++++- .../web/profile_playtest.js | 84 ++++- tests/controller_profile_legacy_fixtures.h | 58 ++++ tests/controller_profile_runtime_test.cpp | 258 +++++++++++++- tests/controller_profile_test.cpp | 243 ++++++++++++- tests/controller_profile_transform_test.cpp | 319 +++++++++++++++++- tests/profile_playtest_test.js | 276 ++++++++++++++- tests/profile_service_test.cpp | 16 +- tests/profile_storage_test.cpp | 82 +++++ tests/switch2_native_gamepad_bridge_test.cpp | 285 ++++++++++++++++ tests/switch_pro_driver_context_test.cpp | 109 ++++++ tests/test_config_manager.py | 317 ++++++++++++++++- tests/test_controller_profile_native.py | 83 +++++ tests/test_profile_web.py | 107 +++++- .../test_switch_pro_driver_context_native.py | 3 + .../native_gamepad_input.cpp | 89 +++-- tools/switch2_usb_probe/probe_build.cmake | 8 +- 31 files changed, 2803 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 8bcd8c1..6297d9d 100644 --- a/README.md +++ b/README.md @@ -350,9 +350,9 @@ Output mode is selected before TinyUSB starts and never changes while mounted. A Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble. -`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port. +`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to ordinary buttons, triggers, or Switch SL/SR rail outputs; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port. -Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only controls. Map each to a normal button or trigger, assign a button-only alternate Shift mapping, or use it in action/macro chords, cancellation and modifiers. Extra mappings default to disabled. The live playtest shows raw extras separately; they are not fictitious output channels on the emulated Switch Pro/XInput controller. +Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional input controls. Base and Shift mappings can route them to ordinary buttons, triggers, or rail outputs; they also remain available in action/macro chords, cancellation and modifiers. Extra input mappings default to disabled. **Left SL, Left SR, Right SL and Right SR** are real output destinations in both native Joy-Con and Switch Pro reports. C/GL/GR remain input sources, not destinations. XInput and generic HID have no rail-button equivalents. Macro and swing output actions retain their existing ordinary-button contract. Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 left/right solo and paired layouts, original Switch Pro, DualSense, Xbox, and Wii Remote/Nunchuk views. Hotspots follow the artwork's actual coordinates; solo Joy-Con views rotate with their firmware input mappings. Rear buttons and rails are labeled below the front view rather than drawn in fictitious positions. On narrow screens, pan the diagram or use the **Source control** menu. @@ -360,6 +360,49 @@ Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 **Wii orientation** is separate from layout preview. Select a connected Wii Remote owner, choose **Horizontal** or **Vertical**, and click **Apply orientation**. Studio waits for firmware confirmation before reporting success. This changes the current connection's physical button mapping, not saved profiles or adapter configuration. Nunchuk mappings remain vertical while attached; unplugging restores the selected standalone orientation. Changing orientation clears old held-input/macro/capture state and advances the logical input generation without reconnecting Bluetooth. Reconnect uses the horizontal default, or vertical if + is held while connecting. +**Native Joy-Con output layout:** for a Pro Controller, DualSense or other full +controller feeding the native hub, choose **Paired**, **Left sideways** or +**Right sideways** in the profile's Mapping section. This is output routing, +not physical Bluetooth grouping or artwork Preview. Sideways mode rotates face +actions, stick axes and the motion frame coherently, uses the mapped left stick +and click for the selected half, and leaves the other half neutral. Both USB +children remain enumerated. + +For a single right Joy-Con game, select the full controller's owner and a +separate game profile, choose **Right sideways**, then map two real buttons to +**Right SL/Right SR**. The explicit **Map shoulders to SL/SR** button changes +only that draft's two base shoulder mappings; selecting a layout never remaps +buttons automatically. D-pad sources can be mapped to the horizontal face +positions through the ordinary mapping controls. Save, activate the intended +profile, then assign the controller in Switch **Change Grip/Order** with the +mapped SL+SR combination. Keep existing game profiles intact rather than +replacing them. Console/game compatibility still requires hardware qualification. + +**Swap left & right sticks (including clicks)** is in Analog. It works in both +native Joy-Con and ordinary Pro-emulation firmware (and other output modes): +each physical stick is calibrated first, then the output axis pairs and mapped +stick clicks exchange sides. Final-output macro overrides remain downstream. +In native solo mode, turning this on makes the physical right stick drive the +selected Joy-Con instead of the physical left stick. Native layout routing is +ignored by Pro-emulation firmware; neither this setting nor rail bits change a +Pro Controller's identity into a Joy-Con for Joy-Con-only games. + +**D-pad as an alternate left stick:** the output menu also offers **Left Stick +Up, Down, Left and Right** movement destinations. These are distinct from the +left-stick click. **Map D-pad to left stick** changes only the four base D-pad +mappings in the unsaved draft; other buttons and analog settings stay intact. +Base/Shift buttons, extra sources and thresholded triggers can use these +destinations in both native Joy-Con and Pro-emulation firmware. + +Directions always target the **mapped left stick after calibration and stick +swapping**, before native sideways rotation. Opposite directions cancel per +axis. Cardinals use full scale; diagonals are normalized inside the stick's +radius. Any nonzero mapped-left analog vector takes priority over the entire +digital vector. If analog drift prevents D-pad movement, adjust the physical +stick's calibration/inner deadzone in Analog; no hidden deadzone is added. +Movement releases when its sources release and never presses the stick click. +Final-output macro overrides retain their existing downstream behavior. + **Joy-Con 2 pair profiles:** the first successful join of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Splitting or losing a half restores solo banks; joining the same members again restores their existing pair bank without copying. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings. Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 2–7 and 8–13 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind. @@ -367,7 +410,7 @@ Pair keys contain both complete Bluetooth addresses and address types, in canoni The read-only playtest endpoint (`0x39`) uses schema 5, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), legacy Wii Remote with unknown orientation (4), Wii Remote + Nunchuk (5), Wii horizontal (6), or Wii vertical (7). Host tools still read schema 2/54-byte, schema 3/55-byte and schema 4/56-byte payloads. Orientation requests (`0x3d`) contain the 14-byte controller identity, four-byte little-endian connection generation, and orientation byte (0 horizontal, 1 vertical); stale/replaced/non-Wii/extension targets are rejected before Bluetooth-core dispatch. These operations do not change profile records or persistent configuration. Profile names and controller aliases are stored as independently checksummed -catalog metadata. Runtime profiles use schema 9 and unchanged 384-byte records. Schemas 1–8 retain existing settings, including schema-8 Remote swing mappings; new Nunchuk and combined actions default to disabled. Names remain separate. The +catalog metadata. Runtime profiles use schema 11 and unchanged 384-byte records. Schemas 1–10 retain existing settings; schemas before 10 default native layout to Paired and stick swapping to off. Byte 376 stores the native layout and bit 0 of byte 377 stores stick swapping; bytes 378–383 remain reserved. Schema 11 adds four left-stick direction destinations without changing input-control indices or record size. Names remain separate. Update firmware and restart the profile editor together before saving the new schema. The editor can rename and copy profiles across controllers and slots, import or export JSON backups, and reset one section without discarding the rest of the draft. Its response-curve cards provide named presets, exact Q8.8 fine @@ -1386,7 +1429,7 @@ Use the physical controller's row from `profiles list`, not the global fallback. can remove an approval after its profile-catalog entry has been forgotten. Approvals persist in adapter configuration schema 4 (232 bytes), alongside the Joy-Con default mode. Schema 1/2 migration starts with no approvals; schema 3 -migration preserves its approval list. Profile schema 9/catalog 3 are separate. +migration preserves its approval list. Profile schema 11/catalog 3 are separate. The native encoder preserves safe unity bytes when synchronized, otherwise encodes independent actuator/band/substep state with documented quantization. @@ -1849,7 +1892,7 @@ records are compacted into its peer and the new superblock is published last. Interrupted or corrupt appends leave the previous valid record available. Catalogs 1/2 and retired four-profile banks migrate through the alternate arena; the old published data is retained until all copies and the new superblock -verify. Schema 1–8 profiles retain their meaning when decoded as schema 9. +verify. Schema 1–10 profiles retain their meaning when decoded as schema 11. Keep a profile export before downgrading: older firmware cannot read the new catalog/profile format. diff --git a/src/firmware/core/controller_state.h b/src/firmware/core/controller_state.h index 3989db8..183448b 100644 --- a/src/firmware/core/controller_state.h +++ b/src/firmware/core/controller_state.h @@ -44,7 +44,8 @@ struct ControllerState { bool button_left_stick; bool button_right_stick; - // Source-only controls: C, GL, GR, left SL/SR, right SL/SR. + // C, GL, GR, left SL/SR, right SL/SR: raw sources before profile mapping, + // explicitly mapped Switch-family outputs afterward. uint8_t extra_buttons = 0; uint16_t left_trigger; diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index 586efe6..c7526d4 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -52,8 +52,10 @@ bool valid_button(uint8_t button) { button == CONTROLLER_PROFILE_NO_BUTTON; } -bool valid_control_output(uint8_t output) { - return output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL || +bool valid_control_output( + uint8_t output, + uint8_t control_count = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT) { + return output < control_count || output == CONTROLLER_PROFILE_NO_BUTTON; } @@ -302,7 +304,9 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } - if (!valid_source_control(profile.shortcuts.modifier) || + if (static_cast(profile.native_joycon_layout) > + static_cast(ControllerProfileNativeJoyconLayout::kRightSolo) || + !valid_source_control(profile.shortcuts.modifier) || !valid_swing_action(profile.swing.button, profile.swing.macro, profile.swing.modifier) || !valid_swing_action(profile.nunchuk_swing.button, @@ -337,12 +341,12 @@ bool controller_profile_validate(const ControllerProfile& profile) { selectors |= static_cast(1u << selector); } for (uint8_t output : profile.shift.button_map) { - if (!valid_button(output)) { + if (!valid_control_output(output)) { return false; } } for (uint8_t output : profile.shift.extra_button_map) { - if (!valid_button(output)) { + if (!valid_control_output(output)) { return false; } } @@ -588,6 +592,8 @@ bool controller_profile_encode(const ControllerProfile& profile, output[373] = profile.combined_swing.macro; output[374] = profile.combined_swing.modifier; output[375] = profile.combination_window_ms; + output[376] = static_cast(profile.native_joycon_layout); + output[377] = profile.swap_sticks ? 1u : 0u; return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } @@ -624,7 +630,9 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, const bool has_swing = schema_version >= CONTROLLER_PROFILE_SWING_SCHEMA_VERSION; const bool has_combined_swing = - schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION; + schema_version >= CONTROLLER_PROFILE_COMBINED_SWING_SCHEMA_VERSION; + const bool has_native_layout = + schema_version >= CONTROLLER_PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION; if ((has_control_mapping ? input[61] != 0 || input[71] != 0 : !profile_bytes_are_zero(&input[60], 2) || @@ -860,8 +868,10 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } if (!has_extra_controls && (!profile_bytes_are_zero(&input[344], 40) || - !valid_control_output(profile.shortcuts.modifier) || - !valid_control_output(profile.shift.modifier))) { + !valid_control_output(profile.shortcuts.modifier, + CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) || + !valid_control_output(profile.shift.modifier, + CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL))) { return false; } } else { @@ -883,7 +893,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } const size_t reserved_offset = - has_combined_swing ? 376 : (has_swing ? 367 : 364); + has_native_layout ? 378 : + (has_combined_swing ? 376 : (has_swing ? 367 : 364)); if (!profile_bytes_are_zero( &input[reserved_offset], expected_size - reserved_offset)) { return false; @@ -899,7 +910,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; } else { for (const ControllerProfileMacro& macro : profile.macros) { - if (!valid_control_output(macro.cancel_control)) { + if (!valid_control_output(macro.cancel_control, + CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL)) { return false; } } @@ -914,6 +926,51 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, profile.combined_swing = {input[372], input[373], input[374]}; profile.combination_window_ms = input[375]; } + if (has_native_layout) { + if ((input[377] & ~1u) != 0) { + return false; + } + profile.native_joycon_layout = + static_cast(input[376]); + profile.swap_sticks = (input[377] & 1u) != 0; + } + if (schema_version < CONTROLLER_PROFILE_SCHEMA_VERSION) { + const uint8_t output_count = + has_native_layout ? CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT + : CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + const uint8_t button_output_count = + has_control_mapping ? output_count + : CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + const uint8_t shift_output_count = + has_native_layout ? output_count + : CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + for (uint8_t mapping : profile.button_map) { + if (!valid_control_output(mapping, button_output_count)) { + return false; + } + } + for (uint8_t mapping : profile.extra_button_map) { + if (!valid_control_output(mapping, output_count)) { + return false; + } + } + for (const ControllerProfileTriggerConfiguration& trigger : + profile.triggers) { + if (!valid_control_output(trigger.output, output_count)) { + return false; + } + } + for (uint8_t mapping : profile.shift.button_map) { + if (!valid_control_output(mapping, shift_output_count)) { + return false; + } + } + for (uint8_t mapping : profile.shift.extra_button_map) { + if (!valid_control_output(mapping, shift_output_count)) { + return false; + } + } + } if (!controller_profile_validate(profile)) { return false; } diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index 9165116..6d50243 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -13,12 +13,23 @@ constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5; constexpr uint16_t CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION = 6; constexpr uint16_t CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION = 7; constexpr uint16_t CONTROLLER_PROFILE_SWING_SCHEMA_VERSION = 8; -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 9; +constexpr uint16_t CONTROLLER_PROFILE_COMBINED_SWING_SCHEMA_VERSION = 9; +constexpr uint16_t CONTROLLER_PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION = 10; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 11; constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 25; +constexpr uint8_t CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT = 26; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_SL_OUTPUT = 18; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_SR_OUTPUT = 19; +constexpr uint8_t CONTROLLER_PROFILE_RIGHT_SL_OUTPUT = 20; +constexpr uint8_t CONTROLLER_PROFILE_RIGHT_SR_OUTPUT = 21; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT = 22; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT = 23; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT = 24; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT = 25; constexpr uint8_t CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT = 7; constexpr uint8_t CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL = 18; constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; @@ -90,6 +101,12 @@ static_assert( CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); +enum class ControllerProfileNativeJoyconLayout : uint8_t { + kPaired = 0, + kLeftSolo = 1, + kRightSolo = 2, +}; + enum class ControllerProfileConfirmationPolicy : uint8_t { kNone = 0, kRumble = 1, @@ -233,6 +250,9 @@ struct ControllerProfile { ControllerProfileSwingConfiguration nunchuk_swing{}; ControllerProfileCombinedSwingConfiguration combined_swing{}; uint8_t combination_window_ms = 100; + ControllerProfileNativeJoyconLayout native_joycon_layout = + ControllerProfileNativeJoyconLayout::kPaired; + bool swap_sticks = false; }; struct ControllerProfileDatabaseEntry { diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 839d978..a539277 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -177,26 +177,58 @@ uint16_t transform_trigger( kQ16One); } +void route_digital_output( + uint8_t output, uint16_t* output_button_mask, + uint8_t* output_extra_button_mask, uint8_t* left_stick_directions) { + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + *output_button_mask |= static_cast(1u << output); + } else if (output >= CONTROLLER_PROFILE_LEFT_SL_OUTPUT && + output <= CONTROLLER_PROFILE_RIGHT_SR_OUTPUT) { + *output_extra_button_mask |= static_cast( + 1u << (output - CONTROLLER_PROFILE_LEFT_SL_OUTPUT + 3u)); + } else if (output >= CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT && + output <= CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT) { + *left_stick_directions |= static_cast( + 1u << (output - CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT)); + } +} + void route_trigger_output( uint8_t output, uint16_t value, uint16_t digital_threshold, - uint16_t* output_button_mask, uint16_t output_triggers[2], - uint16_t output_thresholds[2]) { - if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { - if (value >= digital_threshold) { - *output_button_mask |= static_cast(1u << output); - } - return; - } - if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { + uint16_t* output_button_mask, uint8_t* output_extra_button_mask, + uint16_t output_triggers[2], uint16_t output_thresholds[2], + uint8_t* left_stick_directions) { + if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + output < CONTROLLER_PROFILE_LEFT_SL_OUTPUT) { const uint8_t trigger_index = static_cast( output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); if (value > output_triggers[trigger_index]) { output_triggers[trigger_index] = value; } output_thresholds[trigger_index] = digital_threshold; + } else if (value >= digital_threshold && + (value != 0 || output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT)) { + route_digital_output(output, output_button_mask, + output_extra_button_mask, left_stick_directions); } } +void apply_left_stick_directions(uint8_t directions, ControllerState* state) { + // Directions target mapped left after calibration and swapping. Any analog + // movement owns the whole vector, rather than blending one neutral axis. + if (directions == 0 || state->left_stick_x != 0 || state->left_stick_y != 0) { + return; + } + const int16_t x = ((directions & (1u << 3)) != 0) - + ((directions & (1u << 2)) != 0); + const int16_t y = ((directions & (1u << 1)) != 0) - + ((directions & (1u << 0)) != 0); + // floor(32767 / sqrt(2)): diagonals stay inside the unit radius. + const int16_t magnitude = x != 0 && y != 0 ? 23169 : 32767; + state->left_stick_x = static_cast(x * magnitude); + state->left_stick_y = static_cast(y * magnitude); +} + } // namespace uint16_t controller_profile_extract_button_mask(const ControllerState& state) { @@ -368,11 +400,11 @@ ControllerProfileTransformResult controller_profile_transform( ControllerProfileTransformResult result{}; result.state = input; result.state.extra_buttons = 0; + result.native_joycon_layout = profile.native_joycon_layout; const uint16_t input_button_mask = controller_profile_extract_button_mask(input); - uint16_t output_button_mask = - controller_profile_map_button_mask( - input_button_mask, profile, selected_map); + uint16_t output_button_mask = 0; + uint8_t left_stick_directions = 0; uint16_t output_triggers[2]{}; uint16_t output_thresholds[2] = { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, @@ -388,10 +420,14 @@ ControllerProfileTransformResult controller_profile_transform( } const uint8_t output = selected_map[input_button]; if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && - output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { + output < CONTROLLER_PROFILE_LEFT_SL_OUTPUT) { output_triggers[ output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; + } else { + route_digital_output(output, &output_button_mask, + &result.state.extra_buttons, + &left_stick_directions); } } for (uint8_t extra = 0; @@ -400,11 +436,14 @@ ControllerProfileTransformResult controller_profile_transform( continue; } const uint8_t output = selected_extra_map[extra]; - if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { - output_button_mask |= static_cast(1u << output); - } else if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { + if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + output < CONTROLLER_PROFILE_LEFT_SL_OUTPUT) { output_triggers[ output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; + } else { + route_digital_output(output, &output_button_mask, + &result.state.extra_buttons, + &left_stick_directions); } } @@ -417,7 +456,8 @@ ControllerProfileTransformResult controller_profile_transform( profile.triggers[input_trigger].output, transformed_triggers[input_trigger], profile.triggers[input_trigger].digital_threshold, - &output_button_mask, output_triggers, output_thresholds); + &output_button_mask, &result.state.extra_buttons, + output_triggers, output_thresholds, &left_stick_directions); } controller_profile_apply_button_mask(output_button_mask, &result.state); @@ -427,6 +467,18 @@ ControllerProfileTransformResult controller_profile_transform( transform_stick(profile.sticks[1], input.right_stick_x, input.right_stick_y, &result.state.right_stick_x, &result.state.right_stick_y); + if (profile.swap_sticks) { + const int16_t left_x = result.state.left_stick_x; + const int16_t left_y = result.state.left_stick_y; + result.state.left_stick_x = result.state.right_stick_x; + result.state.left_stick_y = result.state.right_stick_y; + result.state.right_stick_x = left_x; + result.state.right_stick_y = left_y; + const bool left_click = result.state.button_left_stick; + result.state.button_left_stick = result.state.button_right_stick; + result.state.button_right_stick = left_click; + } + apply_left_stick_directions(left_stick_directions, &result.state); result.state.left_trigger = output_triggers[0]; result.state.right_trigger = output_triggers[1]; result.left_trigger_digital_threshold = output_thresholds[0]; diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 3a9909f..b9a081e 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -12,6 +12,8 @@ struct ControllerProfileTransformResult { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; uint16_t right_trigger_digital_threshold = CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + ControllerProfileNativeJoyconLayout native_joycon_layout = + ControllerProfileNativeJoyconLayout::kPaired; }; uint16_t controller_profile_extract_button_mask(const ControllerState& state); diff --git a/src/firmware/usb/switch/switch_pro_descriptors.h b/src/firmware/usb/switch/switch_pro_descriptors.h index 64a553e..1794e9b 100644 --- a/src/firmware/usb/switch/switch_pro_descriptors.h +++ b/src/firmware/usb/switch/switch_pro_descriptors.h @@ -278,8 +278,8 @@ typedef struct __attribute((packed, aligned(1))) uint8_t dpadUp : 1; uint8_t dpadRight : 1; uint8_t dpadLeft : 1; - uint8_t buttonLeftSL : 1; uint8_t buttonLeftSR : 1; + uint8_t buttonLeftSL : 1; uint8_t buttonL : 1; uint8_t buttonZL : 1; diff --git a/src/firmware/usb/switch/switch_pro_driver.cpp b/src/firmware/usb/switch/switch_pro_driver.cpp index a48f3d7..db293cc 100644 --- a/src/firmware/usb/switch/switch_pro_driver.cpp +++ b/src/firmware/usb/switch/switch_pro_driver.cpp @@ -753,8 +753,8 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonX = state.button_north; inputs.buttonB = state.button_south; inputs.buttonA = state.button_east; - inputs.buttonRightSR = 0; - inputs.buttonRightSL = 0; + inputs.buttonRightSR = (state.extra_buttons & (1u << 6)) != 0; + inputs.buttonRightSL = (state.extra_buttons & (1u << 5)) != 0; inputs.buttonR = state.button_right_shoulder; inputs.buttonZR = state.right_trigger >= context.right_trigger_threshold; @@ -764,8 +764,8 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonThumbL = state.button_left_stick; inputs.buttonHome = state.button_system; inputs.buttonCapture = state.button_capture; - inputs.buttonLeftSR = 0; - inputs.buttonLeftSL = 0; + inputs.buttonLeftSR = (state.extra_buttons & (1u << 4)) != 0; + inputs.buttonLeftSL = (state.extra_buttons & (1u << 3)) != 0; inputs.buttonL = state.button_left_shoulder; inputs.buttonZL = state.left_trigger >= context.left_trigger_threshold; diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 8e8f118..bc228e1 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -192,11 +192,7 @@ size_t encode_transaction(uint8_t* output, size_t output_size) { size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { #if SWITCH2_PROBE_HUB -#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - 0, 87, 0, 2, -#else - 0, 86, 0, 2, -#endif + 0, 89, 0, 2, kNativeHubActiveMode, USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | USB_OUTPUT_CAPABILITY_MOTION, diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 4993581..ab2ad83 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -154,7 +154,9 @@ PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 PROFILE_EXPANDED_SCHEMA_VERSION = 6 PROFILE_EXTRA_CONTROL_SCHEMA_VERSION = 7 PROFILE_SWING_SCHEMA_VERSION = 8 -PROFILE_SCHEMA_VERSION = 9 +PROFILE_COMBINED_SWING_SCHEMA_VERSION = 9 +PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION = 10 +PROFILE_SCHEMA_VERSION = 11 PROFILE_LEGACY_SIZE = 256 PROFILE_SIZE = 384 PROFILE_CAPACITY = 8 @@ -292,11 +294,18 @@ LOGICAL_BUTTONS = ( "dpad_left", "dpad_right", ) -OUTPUT_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +ANALOG_TRIGGER_CONTROLS = ("left_trigger", "right_trigger") +STANDARD_CONTROLS = LOGICAL_BUTTONS + ANALOG_TRIGGER_CONTROLS EXTRA_BUTTONS = ("c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr") -LOGICAL_CONTROLS = OUTPUT_CONTROLS + EXTRA_BUTTONS +LOGICAL_CONTROLS = STANDARD_CONTROLS + EXTRA_BUTTONS +RAIL_OUTPUTS = ("left_sl", "left_sr", "right_sl", "right_sr") +LEFT_STICK_DIRECTION_OUTPUTS = ( + "left_stick_up", "left_stick_down", "left_stick_left", "left_stick_right" +) +OUTPUT_CONTROLS = STANDARD_CONTROLS + RAIL_OUTPUTS + LEFT_STICK_DIRECTION_OUTPUTS PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") +NATIVE_JOYCON_LAYOUTS = ("paired", "left_solo", "right_solo") TURBO_MODES = ("off", "turbo", "auto_burst", "burst") SHIFT_MODES = ("off", "hold", "toggle") MACRO_PLAYBACK_MODES = ("once", "while_held", "toggle", "repeat") @@ -738,7 +747,7 @@ def _control_index( controls = ( LOGICAL_CONTROLS if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION - else OUTPUT_CONTROLS + else STANDARD_CONTROLS ) if type(value) is not str or value not in controls: choices = ", ".join(controls) @@ -746,12 +755,41 @@ def _control_index( return controls.index(value) +def _output_index( + value: Any, + name: str, + *, + schema_version: int = PROFILE_SCHEMA_VERSION, + shift: bool = False, +) -> int: + if schema_version < PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION: + if shift or schema_version < PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + return _button_index(value, name) + return _control_index( + value, name, schema_version=PROFILE_EXPANDED_SCHEMA_VERSION + ) + if value is None: + return PROFILE_NONE_BUTTON + outputs = ( + OUTPUT_CONTROLS + if schema_version >= PROFILE_SCHEMA_VERSION + else STANDARD_CONTROLS + RAIL_OUTPUTS + ) + return _require_enum(value, outputs, name) + + def _control_name(value: int) -> str | None: if value == PROFILE_NONE_BUTTON: return None return LOGICAL_CONTROLS[value] +def _output_name(value: int) -> str | None: + if value == PROFILE_NONE_BUTTON: + return None + return OUTPUT_CONTROLS[value] + + def _button_mask_from_json(value: Any, name: str) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") @@ -1267,6 +1305,15 @@ class TriggerConfig: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION else len(LOGICAL_BUTTONS) + source_index ) + output_count = ( + len(OUTPUT_CONTROLS) + if schema_version >= PROFILE_SCHEMA_VERSION + else len(STANDARD_CONTROLS) + len(RAIL_OUTPUTS) + if schema_version >= PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION + else len(STANDARD_CONTROLS) + ) + if output != PROFILE_NONE_BUTTON and output >= output_count: + raise ConfigManagerError("invalid legacy trigger output mapping") lower, upper, curve_q8_8, threshold = struct.unpack("= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION else len(LOGICAL_BUTTONS) + source_index ) @@ -1667,20 +1716,20 @@ class ProfileShift: ) for output in (*self.button_map, *self.extra_button_map): if type(output) is not int or ( - output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) + output != PROFILE_NONE_BUTTON and not 0 <= output < len(OUTPUT_CONTROLS) ): - raise ConfigManagerError("Shift outputs must be buttons or null") + raise ConfigManagerError("invalid Shift output mapping") def to_json_object(self) -> dict[str, Any]: return { "mode": SHIFT_MODES[self.mode], "modifier": _control_name(self.modifier), "button_map": { - name: _button_name(self.button_map[index]) + name: _output_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, "extra_button_map": { - name: _button_name(self.extra_button_map[index]) + name: _output_name(self.extra_button_map[index]) for index, name in enumerate(EXTRA_BUTTONS) }, } @@ -1709,11 +1758,17 @@ class ProfileShift: obj["modifier"], "profile.shift.modifier", schema_version=schema_version ), tuple( - _button_index(mappings[name], f"profile.shift.button_map.{name}") + _output_index( + mappings[name], f"profile.shift.button_map.{name}", + schema_version=schema_version, shift=True, + ) for name in LOGICAL_BUTTONS ), tuple( - _button_index(extras[name], f"profile.shift.extra_button_map.{name}") + _output_index( + extras[name], f"profile.shift.extra_button_map.{name}", + schema_version=schema_version, shift=True, + ) for name in EXTRA_BUTTONS ), ) @@ -1900,7 +1955,7 @@ class ProfileSwing: schema_version: int = PROFILE_SCHEMA_VERSION, ) -> ProfileSwing: fields = ["button", "sensitivity", "modifier"] - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION: fields.append("macro") obj = _require_object(value, fields, name) return cls( @@ -1912,7 +1967,7 @@ class ProfileSwing: obj["modifier"], f"{name}.modifier", schema_version=schema_version ), _swing_macro_index(obj["macro"], f"{name}.macro") - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION else PROFILE_NONE_BUTTON, ) @@ -1967,6 +2022,8 @@ class ControllerProfile: nunchuk_swing: ProfileSwing = ProfileSwing() combined_swing: ProfileCombinedSwing = ProfileCombinedSwing() combination_window_ms: int = PROFILE_COMBINATION_WINDOW_DEFAULT + native_joycon_layout: int = 0 + swap_sticks: bool = False def __post_init__(self) -> None: if type(self.button_map) is not tuple or len(self.button_map) != len( @@ -1994,7 +2051,7 @@ class ControllerProfile: routed_triggers = [ trigger.output for trigger in (self.left_trigger, self.right_trigger) - if len(LOGICAL_BUTTONS) <= trigger.output < len(OUTPUT_CONTROLS) + if len(LOGICAL_BUTTONS) <= trigger.output < len(STANDARD_CONTROLS) ] if len(routed_triggers) != len(set(routed_triggers)): raise ConfigManagerError( @@ -2076,6 +2133,13 @@ class ControllerProfile: PROFILE_COMBINATION_WINDOW_MIN, PROFILE_COMBINATION_WINDOW_MAX, ) + _require_int( + self.native_joycon_layout, + "profile.native_joycon_layout", + 0, + len(NATIVE_JOYCON_LAYOUTS) - 1, + ) + _require_bool(self.swap_sticks, "profile.swap_sticks") if not isinstance(self.turbo_defaults, TurboSettings): raise ConfigManagerError("Turbo defaults must be TurboSettings") if ( @@ -2145,9 +2209,10 @@ class ControllerProfile: sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION has_extra_buttons = version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION has_swing = version >= PROFILE_SWING_SCHEMA_VERSION - has_combined_swing = version >= PROFILE_SCHEMA_VERSION + has_combined_swing = version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION + has_native_layout = version >= PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION control_count = ( - len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) + len(LOGICAL_CONTROLS) if has_extra_buttons else len(STANDARD_CONTROLS) ) if sparse_macros: if payload[75] & 0xCC: @@ -2163,6 +2228,36 @@ class ControllerProfile: payload[81] != 0 or payload[98:100] != b"\x00\x00" ): raise ConfigManagerError("legacy profile reserved fields must be zero") + if version < PROFILE_SCHEMA_VERSION: + output_count = ( + len(STANDARD_CONTROLS) + len(RAIL_OUTPUTS) + if has_native_layout + else len(STANDARD_CONTROLS) + if has_control_mapping + else len(LOGICAL_BUTTONS) + ) + mappings = payload[4:20] + if has_extra_buttons: + mappings += payload[344:351] + if any( + output != PROFILE_NONE_BUTTON and output >= output_count + for output in mappings + ): + raise ConfigManagerError("invalid legacy output mapping") + if version >= PROFILE_EXPANDED_SCHEMA_VERSION: + shift_output_count = ( + output_count if has_native_layout else len(LOGICAL_BUTTONS) + ) + shift_mappings = payload[267:283] + if has_extra_buttons: + shift_mappings += payload[351:358] + if any( + output != PROFILE_NONE_BUTTON and output >= shift_output_count + for output in shift_mappings + ): + raise ConfigManagerError("invalid legacy Shift output mapping") + if has_native_layout and payload[377] & ~1: + raise ConfigManagerError("invalid profile stick-swap flags") left_trigger = TriggerConfig.from_bytes( payload[52:62], schema_version=version, source_index=0 @@ -2327,7 +2422,9 @@ class ControllerProfile: if payload[settings_offset:336] != bytes(336 - settings_offset): raise ConfigManagerError("nonzero Turbo override padding") reserved_offset = ( - 376 + 378 + if has_native_layout + else 376 if has_combined_swing else 367 if has_swing @@ -2376,6 +2473,8 @@ class ControllerProfile: combination_window_ms=payload[375] if has_combined_swing else PROFILE_COMBINATION_WINDOW_DEFAULT, + native_joycon_layout=payload[376] if has_native_layout else 0, + swap_sticks=bool(payload[377] & 1) if has_native_layout else False, ) def to_bytes(self) -> bytes: @@ -2479,6 +2578,8 @@ class ControllerProfile: self.combination_window_ms, ) ) + payload[376] = self.native_joycon_layout + payload[377] = int(self.swap_sticks) return bytes(payload) def to_json_object(self) -> dict[str, Any]: @@ -2486,11 +2587,11 @@ class ControllerProfile: "schema_version": PROFILE_SCHEMA_VERSION, "size": PROFILE_SIZE, "button_map": { - name: _control_name(self.button_map[index]) + name: _output_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, "extra_button_map": { - name: _control_name(self.extra_button_map[index]) + name: _output_name(self.extra_button_map[index]) for index, name in enumerate(EXTRA_BUTTONS) }, "sticks": { @@ -2519,6 +2620,8 @@ class ControllerProfile: "nunchuk_swing": self.nunchuk_swing.to_json_object(), "combined_swing": self.combined_swing.to_json_object(), "combination_window_ms": self.combination_window_ms, + "native_joycon_layout": NATIVE_JOYCON_LAYOUTS[self.native_joycon_layout], + "swap_sticks": self.swap_sticks, "turbo_settings": { "defaults": self.turbo_defaults.to_json_object(), "overrides": { @@ -2570,8 +2673,12 @@ class ControllerProfile: fields.append("extra_button_map") if schema_version >= PROFILE_SWING_SCHEMA_VERSION: fields.append("swing") - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION: fields.extend(("nunchuk_swing", "combined_swing", "combination_window_ms")) + if schema_version >= PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION: + fields.extend( + name for name in ("native_joycon_layout", "swap_sticks") if name in value + ) obj = _require_object(value, fields, "profile") expected_size = ( PROFILE_SIZE @@ -2723,11 +2830,10 @@ class ControllerProfile: return cls( button_map=tuple( - ( - _control_index - if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION - else _button_index - )(button_map[name], f"profile.button_map.{name}") + _output_index( + button_map[name], f"profile.button_map.{name}", + schema_version=schema_version, + ) for name in LOGICAL_BUTTONS ), left_stick=StickConfig.from_json_object( @@ -2767,7 +2873,10 @@ class ControllerProfile: turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), extra_button_map=tuple( - _control_index(extras[name], f"profile.extra_button_map.{name}") + _output_index( + extras[name], f"profile.extra_button_map.{name}", + schema_version=schema_version, + ) for name in EXTRA_BUTTONS ), swing=( @@ -2779,21 +2888,30 @@ class ControllerProfile: ), nunchuk_swing=( ProfileSwing.from_json_object( - obj["nunchuk_swing"], "profile.nunchuk_swing" + obj["nunchuk_swing"], "profile.nunchuk_swing", + schema_version=schema_version, ) - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION else ProfileSwing() ), combined_swing=( ProfileCombinedSwing.from_json_object(obj["combined_swing"]) - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION else ProfileCombinedSwing() ), combination_window_ms=( obj["combination_window_ms"] - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION else PROFILE_COMBINATION_WINDOW_DEFAULT ), + native_joycon_layout=_require_enum( + obj.get("native_joycon_layout", "paired"), + NATIVE_JOYCON_LAYOUTS, + "profile.native_joycon_layout", + ), + swap_sticks=_require_bool( + obj.get("swap_sticks", False), "profile.swap_sticks" + ), ) @classmethod @@ -3925,6 +4043,8 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_EXTRA_CONTROL_SCHEMA_VERSION, PROFILE_SWING_SCHEMA_VERSION, + PROFILE_COMBINED_SWING_SCHEMA_VERSION, + PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -4005,6 +4125,8 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_EXTRA_CONTROL_SCHEMA_VERSION, PROFILE_SWING_SCHEMA_VERSION, + PROFILE_COMBINED_SWING_SCHEMA_VERSION, + PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 2f763ab..bbb8280 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -70,8 +70,12 @@ _CONTROL_LABELS = { "start": "Menu", "capture": "Share", "system": "Xbox", - "left_stick": "Left Stick", - "right_stick": "Right Stick", + "left_stick": "Left Stick Click", + "right_stick": "Right Stick Click", + "left_stick_up": "Left Stick Up", + "left_stick_down": "Left Stick Down", + "left_stick_left": "Left Stick Left", + "left_stick_right": "Left Stick Right", }, "xbox": { "north": "Y", @@ -86,8 +90,8 @@ _CONTROL_LABELS = { "start": "Menu", "capture": "Share", "system": "Xbox", - "left_stick": "Left Stick", - "right_stick": "Right Stick", + "left_stick": "Left Stick Click", + "right_stick": "Right Stick Click", }, "switch": { "north": "X", @@ -102,8 +106,12 @@ _CONTROL_LABELS = { "start": "Plus", "capture": "Capture", "system": "Home", - "left_stick": "Left Stick", - "right_stick": "Right Stick", + "left_stick": "Left Stick Click", + "right_stick": "Right Stick Click", + "left_stick_up": "Left Stick Up", + "left_stick_down": "Left Stick Down", + "left_stick_left": "Left Stick Left", + "left_stick_right": "Left Stick Right", "c": "C", "gl": "GL", "gr": "GR", @@ -125,8 +133,8 @@ _CONTROL_LABELS = { "start": "Options", "capture": "Touchpad", "system": "PS", - "left_stick": "L3", - "right_stick": "R3", + "left_stick": "L3 Click", + "right_stick": "R3 Click", }, "wii": { "north": "B", @@ -196,9 +204,9 @@ def _controller_presentation( def _source_controls(controller: dict[str, str]) -> list[str]: layout = controller["layout"] if layout == "switch2-pro": - controls = config_manager.OUTPUT_CONTROLS + ("c", "gl", "gr") + controls = config_manager.STANDARD_CONTROLS + ("c", "gl", "gr") elif layout == "joycon2-pair": - controls = config_manager.OUTPUT_CONTROLS + ( + controls = config_manager.STANDARD_CONTROLS + ( "c", "left_sl", "left_sr", "right_sl", "right_sr", ) elif layout in {"joycon2-left", "joycon2-right"}: @@ -218,7 +226,7 @@ def _source_controls(controller: dict[str, str]) -> list[str]: ("left_shoulder", "right_shoulder") if layout == "wii-nunchuk" else () ) elif layout in {"switch-pro", "dualsense", "xbox"} or controller["style"] == "playstation": - controls = config_manager.OUTPUT_CONTROLS + controls = config_manager.STANDARD_CONTROLS else: controls = config_manager.LOGICAL_CONTROLS return list(controls) @@ -315,6 +323,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "turbo_modes": list(config_manager.TURBO_MODES), "shift_modes": list(config_manager.SHIFT_MODES), "swing_sensitivities": list(config_manager.SWING_SENSITIVITIES), + "native_joycon_layouts": list(config_manager.NATIVE_JOYCON_LAYOUTS), "combination_window_bounds": { "min": config_manager.PROFILE_COMBINATION_WINDOW_MIN, "max": config_manager.PROFILE_COMBINATION_WINDOW_MAX, diff --git a/src/switch_pico_bridge/web/controller_layouts.js b/src/switch_pico_bridge/web/controller_layouts.js index 6572c4e..2493412 100644 --- a/src/switch_pico_bridge/web/controller_layouts.js +++ b/src/switch_pico_bridge/web/controller_layouts.js @@ -47,7 +47,7 @@ const ControllerLayouts = (() => { const translate = (controls, x, y = 0) => Object.fromEntries( Object.entries(controls).map(([id, value]) => [id, { ...value, x: value.x + x, y: value.y + y }]) ); - const railNote = "Rail SL/SR are source-only extras and also emit the left/right shoulder source in solo mode. The physical L or R button shares that shoulder source. Each source ID is editable once; changing an extra does not disable its shoulder alias."; + const railNote = "Rail SL/SR are extra inputs and also emit the left/right shoulder source in physical solo mode. The physical L or R button shares that shoulder source. Each source ID is editable once; changing an extra does not disable its shoulder alias. Rail output destinations are available for native Joy-Con and Pro emulation."; const wiiCommon = { select: point(60, 294, "Minus", "−"), system: point(100, 294, "Home", "Home"), start: point(140, 294, "Plus", "+"), @@ -116,7 +116,7 @@ const ControllerLayouts = (() => { left_sl: back("Left SL · rail", "L SL"), left_sr: back("Left SR · rail", "L SR"), right_sl: back("Right SL · rail", "R SL"), right_sr: back("Right SR · rail", "R SR"), }, - note: "Select the L+R profile owner for paired settings; its eight profiles are independent of both solo banks. If no L+R owner is listed, update firmware and connect both members. The grip is illustrative, not detected. Rear triggers and rails are not visible in this front view. C and rail inputs are source-only; GL/GR belong to Switch 2 Pro, not Joy-Con 2.", + note: "Select the L+R profile owner for paired settings; its eight profiles are independent of both solo banks. If no L+R owner is listed, update firmware and connect both members. The grip is illustrative, not detected. Rear triggers and rails are not visible in this front view. C is a source-only extra; rails can also be mapped outputs. GL/GR belong to Switch 2 Pro, not Joy-Con 2.", }, "joycon2-left": { name: "Joy-Con 2 left · sideways solo", style: "switch", asset: "switch-2-joycon-left.svg", @@ -145,7 +145,7 @@ const ControllerLayouts = (() => { right_trigger: back("ZR · rear", "ZR"), right_sl: back("SL · rail extra", "SL extra"), right_sr: back("SR · rail extra", "SR extra"), }, - note: "Solo firmware rotates ABXY sources; the right stick becomes LEFT axes and left-stick click. Output labels do not rotate. Select the separate L+R profile owner for paired settings, not this solo bank. " + railNote, + note: "Physical solo input rotates ABXY sources; the right stick becomes LEFT axes and left-stick click. The emulated output layout is configured separately in the profile. Select the separate L+R profile owner for paired settings, not this solo bank. " + railNote, }, "wii-remote": { name: "Wii Remote · orientation unknown", style: "switch", asset: "wii-remote-simple.svg", diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index c514d12..35aa0a1 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -320,6 +320,16 @@ h4 { font-size: 1rem; } .controller-live[data-state="live"] { color: var(--mint); } .layout-picker { margin-block: 16px 8px; } .layout-picker .select { width: 100%; margin-top: 6px; } +.native-joycon-control { margin-block: 14px 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(14, 23, 42, 0.6); } +.native-joycon-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; } +.native-joycon-actions .select { flex: 1 1 220px; } +.native-joycon-actions .button { flex: 0 0 auto; } +.stick-swap-panel { margin-top: 14px; } +.stick-swap-panel .checkbox-pill { display: inline-block; margin-top: 4px; } +.stick-swap-panel .field-help { margin-top: 10px; } +.native-joycon-actions .select { min-width: 0; max-width: 100%; } +.native-joycon-actions .button { min-height: 44px; max-width: 100%; white-space: normal; } +.stick-swap-panel .checkbox-pill span { min-height: 44px; } .wii-orientation-control { margin-block: 14px 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(14, 23, 42, 0.6); } .wii-orientation-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; } .wii-orientation-actions .select { flex: 1 1 220px; } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 63aaae9..45dac53 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -131,6 +131,20 @@

Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.

+
+ +
+ + + +
+

Native-hub output only; this does not change Pro emulation's controller identity. Unlike physical Joy-Con grouping in Adapter settings or Wii orientation, this is a per-profile draft setting: choose Save to Pico to store it. Both USB devices remain enumerated; solo keeps the other side neutral. Assign solo on Switch Change Grip/Order by pressing the sources mapped to the selected side's SL+SR.

+

Changing layout never remaps buttons. “Map shoulders to SL/SR” changes only the two base shoulder destinations in this draft. “Map D-pad to left stick” changes only the four base D-pad destinations (Up, Down, Left, Right) to left-stick movement in this draft. You can also map any source, including D-pad directions, to the horizontal face positions. Solo uses the mapped left stick; enable stick swap below to use the physical right stick. Digital directions apply only when the mapped left analog stick is neutral within its deadzone (any nonzero analog stick input takes priority). In native sideways solo mode, wire rotation happens after mapped left directions. SL/SR mappings work in native and Pro emulation; C/GL/GR are physical input sources, not output destinations.

+
@@ -2461,6 +2560,7 @@ function renderEditor() { : "Used when no dedicated controller profile exists."; renderIdentities(); renderProfileList(); + renderNativeLayout(); renderButtonMap(); renderShortcuts(); renderShift(); @@ -2553,6 +2653,8 @@ function handleFormChange(event) { state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = Number(target.value); } else if (kind === "analog-bool") { state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = target.checked; + } else if (kind === "swap-sticks") { + state.profile.swap_sticks = target.checked; } else if (kind === "curve-preset") { if (target.value !== "custom") { state.profile[target.dataset.group][target.dataset.side].curve_q8_8 = @@ -3148,11 +3250,13 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => { const defaults = state.schema.default_profile; const section = button.dataset.resetSection; if (section === "mapping") { + state.profile.native_joycon_layout = defaults.native_joycon_layout; state.profile.button_map = clone(defaults.button_map); state.profile.extra_button_map = clone(defaults.extra_button_map); state.profile.triggers.left.output = defaults.triggers.left.output; state.profile.triggers.right.output = defaults.triggers.right.output; } else if (section === "analog") { + state.profile.swap_sticks = defaults.swap_sticks; state.profile.sticks = clone(defaults.sticks); const leftOutput = state.profile.triggers.left.output; const rightOutput = state.profile.triggers.right.output; diff --git a/src/switch_pico_bridge/web/profile_playtest.js b/src/switch_pico_bridge/web/profile_playtest.js index cb7293b..99a4750 100644 --- a/src/switch_pico_bridge/web/profile_playtest.js +++ b/src/switch_pico_bridge/web/profile_playtest.js @@ -88,16 +88,42 @@ ); } + const railOutputs = ["left_sl", "left_sr", "right_sl", "right_sr"]; + const leftStickDirectionOutputs = [ + "left_stick_up", + "left_stick_down", + "left_stick_left", + "left_stick_right", + ]; + function transformMappings(sample, profile, shiftActive = false) { const buttons = new Set(); const triggers = { left: 0, right: 0 }; + const directions = { + up: false, + down: false, + left: false, + right: false, + }; const consumed = profile.shift.mode !== "off" ? profile.shift.modifier : null; const map = shiftActive ? profile.shift : profile; + const knownButtons = new Set([ + ...Object.keys(profile.button_map || {}), + ...railOutputs, + ]); const route = (output, value = 65535) => { if (output === "left_trigger" || output === "right_trigger") { const side = output === "left_trigger" ? "left" : "right"; triggers[side] = Math.max(triggers[side], value); - } else if (output && Object.hasOwn(profile.button_map, output)) { + } else if (output === "left_stick_up") { + directions.up = true; + } else if (output === "left_stick_down") { + directions.down = true; + } else if (output === "left_stick_left") { + directions.left = true; + } else if (output === "left_stick_right") { + directions.right = true; + } else if (output && knownButtons.has(output)) { buttons.add(output); } }; @@ -113,13 +139,64 @@ const value = transformTrigger(sample.triggers[side], config); if (config.output === "left_trigger" || config.output === "right_trigger") { route(config.output, value); - } else if (value >= config.digital_threshold) { + } else if ( + value >= config.digital_threshold && + (value > 0 || (!railOutputs.includes(config.output) && !leftStickDirectionOutputs.includes(config.output))) + ) { route(config.output); } } + if (profile.swap_sticks) { + const leftClick = buttons.has("left_stick"); + const rightClick = buttons.has("right_stick"); + if (leftClick) buttons.delete("left_stick"); + if (rightClick) buttons.delete("right_stick"); + if (leftClick) buttons.add("right_stick"); + if (rightClick) buttons.add("left_stick"); + } return { - buttons: Object.keys(profile.button_map).filter((button) => buttons.has(button)), + buttons: [...knownButtons].filter((button) => buttons.has(button)), triggers, + directions, + }; + } + + function resolveDirectionsVector(directions) { + const horizontal = (directions.right ? 1 : 0) - (directions.left ? 1 : 0); + const vertical = (directions.down ? 1 : 0) - (directions.up ? 1 : 0); + if (horizontal === 0 && vertical === 0) { + return { x: 0, y: 0 }; + } + if (horizontal !== 0 && vertical !== 0) { + return { + x: horizontal * 23169, + y: vertical * 23169, + }; + } + return { + x: horizontal * 32767, + y: vertical * 32767, + }; + } + + function transformSticks(sample, profile, mapped = null) { + const leftCalibrated = transformStick(sample.left_stick, profile.sticks.left); + const rightCalibrated = transformStick(sample.right_stick, profile.sticks.right); + const swapped = profile.swap_sticks + ? { left: rightCalibrated, right: leftCalibrated } + : { left: leftCalibrated, right: rightCalibrated }; + + let outputLeft = swapped.left; + if (outputLeft.x === 0 && outputLeft.y === 0) { + const directions = mapped === null + ? transformMappings(sample, profile).directions : mapped.directions; + const directionVector = resolveDirectionsVector(directions); + outputLeft = directionVector; + } + + return { + left: outputLeft, + right: swapped.right, }; } @@ -140,6 +217,7 @@ root.ProfilePlaytestMath = Object.freeze({ transformStick, + transformSticks, transformTrigger, transformMappings, stickCoordinates, diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h index 83318ea..60c2c8b 100644 --- a/tests/controller_profile_legacy_fixtures.h +++ b/tests/controller_profile_legacy_fixtures.h @@ -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, +}; diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index 5998032..896ae77 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -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(); diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index e05e9fe..c23d795 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -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(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(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(kLegacySchema9Profile), + sizeof(kLegacySchema9Profile)); + return std::cout ? 0 : 1; + } + if (argc == 2 && std::strcmp(argv[1], "--legacy10") == 0) { + std::cout.write(reinterpret_cast(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(encoded), sizeof(encoded)); + const size_t size = static_cast(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(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; diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index 7607070..6d11121 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -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; diff --git a/tests/profile_playtest_test.js b/tests/profile_playtest_test.js index e861f36..a8d33f1 100644 --- a/tests/profile_playtest_test.js +++ b/tests/profile_playtest_test.js @@ -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"); diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 843fa58..63adbee 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -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, diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index 2a32fcd..c806ac7 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -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 #include @@ -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(); diff --git a/tests/switch2_native_gamepad_bridge_test.cpp b/tests/switch2_native_gamepad_bridge_test.cpp index 01bd512..888ba2a 100644 --- a/tests/switch2_native_gamepad_bridge_test.cpp +++ b/tests/switch2_native_gamepad_bridge_test.cpp @@ -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(ControllerProfileLogicalButton::kDpadRight)] = + static_cast(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(ControllerProfileLogicalButton::kLeftShoulder); + const unsigned shoulder_r = static_cast(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(ControllerProfileLogicalButton::kSouth)] = + static_cast(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(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; } diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index cfd7ef1..937cf68 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -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 #include @@ -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(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(¤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(&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(); diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index c5ed3e5..98cb0c4 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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(" 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), diff --git a/tests/test_controller_profile_native.py b/tests/test_controller_profile_native.py index aad656e..313e9e1 100644 --- a/tests/test_controller_profile_native.py +++ b/tests/test_controller_profile_native.py @@ -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 diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index a83b401..9167f45 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -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 diff --git a/tests/test_switch_pro_driver_context_native.py b/tests/test_switch_pro_driver_context_native.py index 9010f8b..c9f9207 100644 --- a/tests/test_switch_pro_driver_context_native.py +++ b/tests/test_switch_pro_driver_context_native.py @@ -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), diff --git a/tools/switch2_usb_probe/native_gamepad_input.cpp b/tools/switch2_usb_probe/native_gamepad_input.cpp index f23660e..ebed5f0 100644 --- a/tools/switch2_usb_probe/native_gamepad_input.cpp +++ b/tools/switch2_usb_probe/native_gamepad_input.cpp @@ -9,6 +9,7 @@ #include "native_imu.h" #include "pico/time.h" #include "profile/controller_profile_runtime.h" +#include "profile/profile_service.h" #if !SWITCH2_PROBE_HUB || SWITCH2_BRIDGE_WII_INPUT #error "A full gamepad source requires the native R/L USB hub" @@ -56,6 +57,7 @@ ProbeNativeMotion g_motion; bool g_active; bool g_evaluated; uint32_t g_evaluated_ms; +uint32_t g_profile_generation; uint32_t g_report_token; int g_sensor_status = -1; bool g_clock_started; @@ -77,6 +79,7 @@ void advance_clock(uint32_t now_us) { void discard_output(Child& child) { child.pending_token = 0; + child.pending_motion = false; child.have_committed_motion = false; } @@ -104,20 +107,51 @@ uint16_t calibrated_axis(const Child& child, int16_t value, unsigned axis, bool (output_positive ? displacement : -displacement)); } +int16_t negate_axis(int16_t value) { + return value == INT16_MIN ? INT16_MAX : static_cast(-value); +} + +void native_motion_axes(const int32_t source[3], float scale, float output[3]) { + // Undo rotate_solo_joycon's horizontal SDL normalization, then apply the + // existing upright native mount [X,-Z,Y]. Rotate accel and gyro together. + output[1] = -static_cast(source[2]) * scale; + switch (g_mapped.native_joycon_layout) { + case ControllerProfileNativeJoyconLayout::kLeftSolo: + output[0] = static_cast(source[1]) * scale; + output[2] = -static_cast(source[0]) * scale; + break; + case ControllerProfileNativeJoyconLayout::kRightSolo: + output[0] = -static_cast(source[1]) * scale; + output[2] = static_cast(source[0]) * scale; + break; + case ControllerProfileNativeJoyconLayout::kPaired: + output[0] = static_cast(source[0]) * scale; + output[2] = static_cast(source[1]) * scale; + break; + } +} + void pack_controls(uint8_t instance) { Child& child = g_children[instance]; child.input = {}; child.input.serial = g_source.state_generation; if (!g_active || !child.calibrated) return; + const bool left = probe_model_is_left(instance); + const auto layout = g_mapped.native_joycon_layout; + const bool solo = layout != ControllerProfileNativeJoyconLayout::kPaired; + // Leave both USB identities in place. The existing inactive-input protocol + // path emits neutral reports for the unselected child. + if (solo && left != (layout == ControllerProfileNativeJoyconLayout::kLeftSolo)) return; child.input.active = true; child.input.native_status = 0x30; // Host feature status is gated per model in main. child.input.mouse_surface = 0xff; // No optical sensor, clicks, or invented movement. const ControllerState& state = g_mapped.state; - const bool left = probe_model_is_left(instance); if (left) { child.input.buttons[0] = static_cast( - (state.dpad_down ? 0x01 : 0) | (state.dpad_right ? 0x02 : 0) | - (state.dpad_left ? 0x04 : 0) | (state.dpad_up ? 0x08 : 0) | + ((solo ? state.button_east : state.dpad_down) ? 0x01 : 0) | + ((solo ? state.button_north : state.dpad_right) ? 0x02 : 0) | + ((solo ? state.button_south : state.dpad_left) ? 0x04 : 0) | + ((solo ? state.button_west : state.dpad_up) ? 0x08 : 0) | (state.button_left_shoulder ? 0x10 : 0) | (state.left_trigger != 0 && state.left_trigger >= g_mapped.left_trigger_digital_threshold ? 0x20 : 0) | (state.button_select ? 0x40 : 0) | (state.button_left_stick ? 0x80 : 0)); @@ -127,18 +161,29 @@ void pack_controls(uint8_t instance) { ((state.extra_buttons & (1u << 4)) ? 0x40 : 0)); } else { child.input.buttons[0] = static_cast( - (state.button_south ? 0x01 : 0) | (state.button_east ? 0x02 : 0) | - (state.button_west ? 0x04 : 0) | (state.button_north ? 0x08 : 0) | + ((solo ? state.button_west : state.button_south) ? 0x01 : 0) | + ((solo ? state.button_south : state.button_east) ? 0x02 : 0) | + ((solo ? state.button_north : state.button_west) ? 0x04 : 0) | + ((solo ? state.button_east : state.button_north) ? 0x08 : 0) | (state.button_right_shoulder ? 0x10 : 0) | (state.right_trigger != 0 && state.right_trigger >= g_mapped.right_trigger_digital_threshold ? 0x20 : 0) | - (state.button_start ? 0x40 : 0) | (state.button_right_stick ? 0x80 : 0)); + (state.button_start ? 0x40 : 0) | + ((solo ? state.button_left_stick : state.button_right_stick) ? 0x80 : 0)); child.input.buttons[1] = static_cast( (state.button_system ? 0x01 : 0) | ((state.extra_buttons & 1) ? 0x10 : 0) | ((state.extra_buttons & (1u << 5)) ? 0x80 : 0) | ((state.extra_buttons & (1u << 6)) ? 0x40 : 0)); } - const uint16_t x = calibrated_axis(child, left ? state.left_stick_x : state.right_stick_x, 0, false); - const uint16_t y = calibrated_axis(child, left ? state.left_stick_y : state.right_stick_y, 1, true); + int16_t stick_x = left ? state.left_stick_x : state.right_stick_x; + int16_t stick_y = left ? state.left_stick_y : state.right_stick_y; + if (solo) { + // Solo consumes the mapped LEFT stick and click, including any profile + // stick swap already performed upstream. + stick_x = left ? negate_axis(state.left_stick_y) : state.left_stick_y; + stick_y = left ? state.left_stick_x : negate_axis(state.left_stick_x); + } + const uint16_t x = calibrated_axis(child, stick_x, 0, false); + const uint16_t y = calibrated_axis(child, stick_y, 1, true); child.input.stick[0] = static_cast(x); child.input.stick[1] = static_cast((x >> 8) | (y << 4)); child.input.stick[2] = static_cast(y >> 4); @@ -174,9 +219,11 @@ void refresh(uint32_t now_ms) { } const bool changed_connection = !g_active || source.slot != g_source.slot || source.controller.connection_generation != g_source.controller.connection_generation; + const uint32_t profile_generation = profile_service_database_generation(); // Both polls and both peeks in a paired output round share one profile and // motion evaluation. A real publication in the same millisecond still wins. if (!changed_connection && g_evaluated && g_evaluated_ms == now_ms && + profile_generation == g_profile_generation && source.state_generation == g_source.state_generation && source.received_us == g_source.received_us && source.accel_sequence == g_source.accel_sequence && source.gyro_sequence == g_source.gyro_sequence && source.accel_received_us == g_source.accel_received_us && source.gyro_received_us == g_source.gyro_received_us && @@ -192,7 +239,16 @@ void refresh(uint32_t now_ms) { g_active = true; g_evaluated = true; g_evaluated_ms = now_ms; + // Store the generation observed before transforming: a concurrent storage + // publication must invalidate this result rather than bless an older profile. + g_profile_generation = profile_generation; + const auto previous_layout = g_mapped.native_joycon_layout; g_mapped = controller_profile_runtime_transform(source.slot, source.controller, now_ms, AdapterUsbMode::kSwitch); + if (g_mapped.native_joycon_layout != previous_layout) { + g_motion.reset(); + for (Child& child : g_children) discard_output(child); + g_sensor_status = -1; + } ControllerProfileRuntimeProfileChangeEvent feedback{}; if (controller_profile_runtime_take_initial_profile_indication(source.slot, &feedback) || controller_profile_runtime_take_profile_change(source.slot, &feedback)) { @@ -206,15 +262,8 @@ void refresh(uint32_t now_ms) { sample.gyro_sequence = source.gyro_sequence; sample.accel_us = source.accel_received_us; sample.gyro_us = source.gyro_received_us; - // SDL -> upright native body [X,-Z,Y], the same physical transform used by - // the Wii adapter before its mouse-mount rotation. Both halves represent - // one rigid, full controller: no solo-Joy-Con or mouse mounting rotation. - sample.accel_g[0] = static_cast(source.accel_q13[0]) / 8192.0f; - sample.accel_g[1] = -static_cast(source.accel_q13[2]) / 8192.0f; - sample.accel_g[2] = static_cast(source.accel_q13[1]) / 8192.0f; - sample.gyro_dps[0] = static_cast(source.gyro_q10[0]) / 1024.0f; - sample.gyro_dps[1] = -static_cast(source.gyro_q10[2]) / 1024.0f; - sample.gyro_dps[2] = static_cast(source.gyro_q10[1]) / 1024.0f; + native_motion_axes(source.accel_q13, 1.0f / 8192.0f, sample.accel_g); + native_motion_axes(source.gyro_q10, 1.0f / 1024.0f, sample.gyro_dps); g_motion.update(now_us, source.controller.connection_generation, sample, source.track_stationary_bias ? ProbeNativeMotionBias::kTrackStationary : ProbeNativeMotionBias::kAlreadyCalibrated); @@ -317,12 +366,14 @@ uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_ bool probe_native_gamepad_input_commit_native_report(uint8_t instance, uint32_t token) { if (instance >= PROBE_CONTROLLER_COUNT || !token) return false; Child& child = g_children[instance]; + // Profile edits/activation need no physical publication to retire a token. + if (!child.enabled || !child.input.active || !g_active || child.pending_token != token || + profile_service_database_generation() != g_profile_generation) return false; // Check the live source even when the caller did not poll after a disconnect. Bluepad32NativeGamepadSnapshot source; bluepad32_input_backend_native_snapshot(&source); const uint32_t now_us = time_us_32(); - if (!child.enabled || !g_active || child.pending_token != token || - !source.controller.active || source.slot != g_source.slot || + if (!source.controller.active || source.slot != g_source.slot || source.controller.connection_generation != g_source.controller.connection_generation || source.state_generation != g_source.state_generation || source.accel_sequence != g_source.accel_sequence || source.gyro_sequence != g_source.gyro_sequence || diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index d6d8472..22bf3b6 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -369,15 +369,15 @@ function(switch2_usb_probe_configure target) endif() if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.87-native-enumeration-flight") + pico_set_program_version(${target} "0.89-native-digital-stick-trace") else() - pico_set_program_version(${target} "0.86-native-flash-service") + pico_set_program_version(${target} "0.89-native-digital-stick") endif() elseif(SWITCH2_PROBE_HUB) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.87-native-hub-enumeration-flight") + pico_set_program_version(${target} "0.89-native-hub-profiles-trace") else() - pico_set_program_version(${target} "0.86-native-hub-flash-service") + pico_set_program_version(${target} "0.89-native-hub-profiles") endif() elseif(SWITCH2_PROBE_JOIN_CHORD_GATE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)