feat(profiles): add solo Joy-Con layouts and digital stick mappings

Add per-profile native solo routing, stick/click swapping, SL/SR output mappings, and digital left-stick directions with analog priority. Extend both codecs to schema 11 while preserving legacy profiles, expose the controls and preview in Studio, and carry rail outputs through Pro emulation. Verified with 541 tests, firmware builds, editor checks, and saved-profile preservation during native 0.89 deployment.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-15 17:33:31 -06:00
commit d15b0ec711
31 changed files with 2803 additions and 183 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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<uint8_t>(profile.native_joycon_layout) >
static_cast<uint8_t>(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<uint16_t>(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<uint8_t>(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<ControllerProfileNativeJoyconLayout>(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;
}

View file

@ -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 {

View file

@ -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<uint16_t>(1u << output);
} else if (output >= CONTROLLER_PROFILE_LEFT_SL_OUTPUT &&
output <= CONTROLLER_PROFILE_RIGHT_SR_OUTPUT) {
*output_extra_button_mask |= static_cast<uint8_t>(
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<uint8_t>(
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<uint16_t>(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<uint8_t>(
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<int16_t>(x * magnitude);
state->left_stick_y = static_cast<int16_t>(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<uint16_t>(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];

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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("<HHHH", payload[:8])
return cls(lower, upper, curve_q8_8, threshold, output)
@ -1287,7 +1334,7 @@ class TriggerConfig:
"upper_saturation": self.upper_saturation,
"curve_q8_8": self.curve_q8_8,
"digital_threshold": self.digital_threshold,
"output": _control_name(self.output),
"output": _output_name(self.output),
}
@classmethod
@ -1309,7 +1356,9 @@ class TriggerConfig:
fields += ("output",)
obj = _require_object(value, fields, name)
output = (
_control_index(obj["output"], f"{name}.output")
_output_index(
obj["output"], f"{name}.output", schema_version=schema_version
)
if schema_version >= 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")

View file

@ -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,

View file

@ -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",

View file

@ -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; }

View file

@ -131,6 +131,20 @@
</select>
<p class="field-help" id="controllerLayoutHelp">Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.</p>
</div>
<div class="native-joycon-control" id="nativeJoyconControl">
<label class="field-label" for="nativeJoyconLayoutSelect">Native Joy-Con layout</label>
<div class="native-joycon-actions">
<select class="select" id="nativeJoyconLayoutSelect" aria-describedby="nativeJoyconHelp">
<option value="paired">Paired · standard dual Joy-Con orientation</option>
<option value="left_solo">Left sideways · solo Joy-Con (L)</option>
<option value="right_solo">Right sideways · solo Joy-Con (R)</option>
</select>
<button class="button button-secondary" id="mapShouldersButton" type="button" hidden>Map shoulders to SL/SR</button>
<button class="button button-secondary" id="mapDpadButton" type="button">Map D-pad to left stick</button>
</div>
<p class="field-help" id="nativeJoyconHelp">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.</p>
<p class="field-help">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.</p>
</div>
<div class="wii-orientation-control" id="wiiOrientationControl" hidden>
<label class="field-label" for="wiiOrientationSelect">Wii orientation</label>
<div class="wii-orientation-actions">
@ -276,10 +290,21 @@
<div class="mapping-playtest">
<p><strong>Raw extra inputs:</strong> <span id="playtestExtraInputs">None</span></p>
<p><strong>Draft button output:</strong> <span id="playtestMappedButtons">None</span></p>
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated.</p>
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated. Digital stick directions apply only when the mapped left analog vector is neutral within inner deadzones.</p>
</div>
</div>
<div class="analog-grid" id="analogFields"></div>
<div class="subpanel stick-swap-panel" id="stickSwapPanel">
<div class="subpanel-heading">
<h4>Stick swap</h4>
<span>Output routing</span>
</div>
<label class="checkbox-pill">
<input type="checkbox" id="swapSticksCheckbox" data-kind="swap-sticks" aria-describedby="swapSticksHelp">
<span>Swap left &amp; right sticks (including clicks)</span>
</label>
<p class="field-help" id="swapSticksHelp">Swaps output axes and mapped stick clicks in both native and Pro emulation. Save to Pico to store this draft setting. Calibrations, deadzones, response curves and invert settings remain tied to their original physical sticks.</p>
</div>
</section>
<section class="panel" id="feedback">

View file

@ -114,6 +114,10 @@ const elements = {
controllerLayoutPreview: document.querySelector("#controllerLayoutPreview"),
controllerLayoutHelp: document.querySelector("#controllerLayoutHelp"),
controllerLayoutNote: document.querySelector("#controllerLayoutNote"),
nativeJoyconLayout: document.querySelector("#nativeJoyconLayoutSelect"),
mapShoulders: document.querySelector("#mapShouldersButton"),
mapDpad: document.querySelector("#mapDpadButton"),
swapSticks: document.querySelector("#swapSticksCheckbox"),
wiiOrientationControl: document.querySelector("#wiiOrientationControl"),
wiiOrientation: document.querySelector("#wiiOrientationSelect"),
applyWiiOrientation: document.querySelector("#applyWiiOrientationButton"),
@ -220,6 +224,12 @@ const directionalLabels = {
dpad_right: "D-pad Right",
dpad_down: "D-pad Down",
dpad_left: "D-pad Left",
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",
@ -291,6 +301,53 @@ function controlLabel(control, style = currentControllerStyle()) {
label(control);
}
const nativeLayoutLabels = {
paired: "Paired",
left_solo: "Left sideways",
right_solo: "Right sideways",
};
const soloFaceLabels = {
left_solo: {
south: "Bottom / Left Joy-Con Left arrow",
east: "Right / Left Joy-Con Down arrow",
west: "Left / Left Joy-Con Up arrow",
north: "Top / Left Joy-Con Right arrow",
},
right_solo: {
south: "Bottom / Right Joy-Con A",
east: "Right / Right Joy-Con X",
west: "Left / Right Joy-Con B",
north: "Top / Right Joy-Con Y",
},
};
function outputLabel(control, style = currentControllerStyle()) {
const layout = state.profile?.native_joycon_layout || "paired";
if (soloFaceLabels[layout]) {
if (soloFaceLabels[layout][control]) return soloFaceLabels[layout][control];
if (control.startsWith("dpad_")) return `${controlLabel(control, "switch")} · no separate solo D-pad`;
if (control === "left_stick") return "Solo stick click · mapped left output";
if (control === "right_stick") return "Right stick click · unused in solo";
if (control.startsWith("left_stick_")) return `${controlLabel(control, "switch")} · mapped left movement`;
return controlLabel(control, "switch");
}
return controlLabel(control, style);
}
function renderNativeLayout() {
const layout = state.profile?.native_joycon_layout || "paired";
const choices = state.schema?.native_joycon_layouts || [];
elements.nativeJoyconLayout.innerHTML = choices.map(value =>
`<option value="${value}"${layout === value ? " selected" : ""}>${nativeLayoutLabels[value]}</option>`
).join("");
elements.nativeJoyconLayout.disabled = state.busy || captureBlocking() || !state.profile;
elements.mapShoulders.hidden = layout === "paired";
elements.mapShoulders.disabled = elements.nativeJoyconLayout.disabled;
if (elements.mapDpad) elements.mapDpad.disabled = elements.nativeJoyconLayout.disabled;
elements.swapSticks.checked = Boolean(state.profile?.swap_sticks);
elements.swapSticks.disabled = elements.nativeJoyconLayout.disabled;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
@ -300,7 +357,6 @@ function canonical(value) {
}
const {
transformStick,
transformTrigger,
stickCoordinates,
triggerPercent,
@ -436,8 +492,7 @@ function renderPlaytest(sample) {
const mapped = ProfilePlaytestMath.transformMappings(sample, state.profile, state.playtestShift.active);
const rawLeft = sample.left_stick;
const rawRight = sample.right_stick;
const outputLeft = transformStick(rawLeft, state.profile.sticks.left);
const outputRight = transformStick(rawRight, state.profile.sticks.right);
const { left: outputLeft, right: outputRight } = ProfilePlaytestMath.transformSticks(sample, state.profile, mapped);
const outputLeftTrigger = mapped.triggers.left;
const outputRightTrigger = mapped.triggers.right;
updateStickPlaytest(
@ -481,9 +536,9 @@ function renderPlaytest(sample) {
elements.playtestExtraInputs.textContent =
(sample.extra_buttons || []).map(button => sourceLabel(button)).join(", ") || "None";
elements.playtestMappedButtons.textContent =
mapped.buttons.map((button) => controlLabel(button, style)).join(", ") || "None";
mapped.buttons.map((button) => outputLabel(button, style)).join(", ") || "None";
elements.playtestMappingHelp.textContent =
`${state.playtestShift.active ? "Shift" : "Base"} layer preview; shortcuts, macros and Turbo are not simulated.${state.presentation.liveDiagram ? "" : " Diagram is a reference/preview; physical highlighting is off."}`;
`${state.playtestShift.active ? "Shift" : "Base"} layer preview; shortcuts, macros and Turbo are not simulated.${state.profile.swap_sticks ? " Stick axes and mapped clicks are swapped; amber dots and calibration rings remain physical inputs, blue dots are output channels." : ""}${state.profile.native_joycon_layout !== "paired" ? " Native solo uses mapped left axes/click and the selected side's rails; no separate D-pad. Axes show logical output before native wire rotation." : ""}${state.presentation.liveDiagram ? "" : " Diagram is a reference/preview; physical highlighting is off."} Digital stick directions apply only when the mapped left analog stick is neutral within its deadzone (any nonzero analog vector takes priority).`;
elements.playtestPanel.dataset.state = "live";
elements.playtestStatus.textContent = "Live";
const owner = currentOwner();
@ -822,6 +877,7 @@ function setBusy(busy) {
});
renderCapture();
renderWiiOrientation();
renderNativeLayout();
}
function updateDirtyState() {
@ -975,7 +1031,7 @@ function buttonOptions(
? `<option value=""${selected === null ? " selected" : ""}>${escapeHtml(noneLabel)}</option>`
: "";
return none + choices.map((button) => (
`<option value="${button}"${selected === button ? " selected" : ""}>${escapeHtml(controlLabel(button, style))}</option>`
`<option value="${button}"${selected === button ? " selected" : ""}>${escapeHtml(outputLabel(button, style))}</option>`
)).join("");
}
@ -1001,7 +1057,7 @@ function gestureActionOptions(
const buttonGroup = choices.length ? `
<optgroup label="Output buttons">
${choices.map((button) => (
`<option value="${button}"${selectedButton === button ? " selected" : ""}>${escapeHtml(controlLabel(button, style))}</option>`
`<option value="${button}"${selectedButton === button ? " selected" : ""}>${escapeHtml(outputLabel(button, style))}</option>`
)).join("")}
</optgroup>` : "";
@ -1118,7 +1174,7 @@ function renderShift() {
return `
<div class="control-card" data-source-card="${button}"${sourceAvailable(button) ? "" : " hidden"}>
<label for="shift-map-${button}"><span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span> → alternate output</label>
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(map[button], true, state.schema.buttons)}</select>
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(map[button])}</select>
</div>`;
}).join("");
updateShiftValidity();
@ -1165,6 +1221,10 @@ const fixedControlGlyphs = {
dpad_left: "◀",
left_stick: "L3",
right_stick: "R3",
left_stick_up: "L▲",
left_stick_down: "L▼",
left_stick_left: "L◀",
left_stick_right: "L▶",
select: "−",
start: "+",
capture: "▣",
@ -1286,7 +1346,7 @@ function renderButtonMap() {
hotspot.classList.toggle("disabled-map", output == null);
if (!liveDiagram) hotspot.classList.remove("pressed");
hotspot.setAttribute("aria-pressed", String(button === selected));
hotspot.title = `${sourceLabel(button)} [${button}] → ${output == null ? "Disabled" : controlLabel(output)}`;
hotspot.title = `${sourceLabel(button)} [${button}] → ${output == null ? "Disabled" : outputLabel(output)}`;
hotspot.setAttribute("aria-label", hotspot.title);
});
elements.selectedSource.innerHTML = sourceOptions(selected);
@ -1294,7 +1354,7 @@ function renderButtonMap() {
elements.selectedControlGlyph.textContent = sourceGlyph(selected);
elements.selectedControlName.textContent = sourceLabel(selected);
elements.selectedControlDescription.textContent =
`${sourceAvailable(selected) ? "Source" : "Stored / unavailable source"} ${sourceLabel(selected)} [${selected}] produces ${mappedOutput == null ? "no output" : controlLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " Its mapping is retained; change the preview to locate it."}`;
`${sourceAvailable(selected) ? "Source" : "Stored / unavailable source"} ${sourceLabel(selected)} [${selected}] produces ${mappedOutput == null ? "no output" : outputLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " Its mapping is retained; change the preview to locate it."}`;
elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.output_controls, style);
renderWiiOrientation();
}
@ -1324,6 +1384,44 @@ elements.controllerLayoutPreview.addEventListener("change", () => {
refreshSourceControls();
});
elements.nativeJoyconLayout.addEventListener("change", () => {
if (state.busy || captureBlocking() || !state.profile) return;
state.profile.native_joycon_layout = elements.nativeJoyconLayout.value;
stopMacroPreview("Preview stopped: draft edited.");
renderNativeLayout();
renderButtonMap();
refreshSourceControls();
updateDirtyState();
if (state.liveSample) renderPlaytest(state.liveSample);
});
elements.mapShoulders.addEventListener("click", () => {
if (state.busy || captureBlocking() || !state.profile) return;
const layout = state.profile.native_joycon_layout;
if (layout !== "left_solo" && layout !== "right_solo") return;
const side = layout === "left_solo" ? "left" : "right";
state.profile.button_map.left_shoulder = `${side}_sl`;
state.profile.button_map.right_shoulder = `${side}_sr`;
stopMacroPreview("Preview stopped: draft edited.");
renderButtonMap();
updateDirtyState();
if (state.liveSample) renderPlaytest(state.liveSample);
toast(`Base shoulders mapped to ${label(side)} SL/SR in the unsaved draft. Other mappings are unchanged.`);
});
if (elements.mapDpad) {
elements.mapDpad.addEventListener("click", () => {
if (state.busy || captureBlocking() || !state.profile) return;
state.profile.button_map.dpad_up = "left_stick_up";
state.profile.button_map.dpad_down = "left_stick_down";
state.profile.button_map.dpad_left = "left_stick_left";
state.profile.button_map.dpad_right = "left_stick_right";
stopMacroPreview("Preview stopped: draft edited.");
renderButtonMap();
updateDirtyState();
if (state.liveSample) renderPlaytest(state.liveSample);
toast("Base D-pad mapped to left stick movement (Up, Down, Left, Right) in the unsaved draft. Other mappings are unchanged.");
});
}
// Patch source controls in place on topology changes. Never rerender the editor,
// stop macro playback, replace focused inputs, or mutate hidden draft values.
function refreshSourceControls() {
@ -1366,10 +1464,10 @@ function refreshSourceControls() {
elements.shiftMap.querySelectorAll("select").forEach(select => {
const map = state.schema.extra_buttons.includes(select.dataset.name)
? state.profile.shift.extra_button_map : state.profile.shift.button_map;
select.innerHTML = buttonOptions(map[select.dataset.name], true, state.schema.buttons);
select.innerHTML = buttonOptions(map[select.dataset.name]);
});
document.querySelectorAll("[data-output-label]").forEach(node => {
node.textContent = controlLabel(node.dataset.outputLabel);
node.textContent = outputLabel(node.dataset.outputLabel);
});
updateGestureActionSelects();
}
@ -1431,6 +1529,7 @@ function curvePath(curve) {
}
function renderAnalog() {
elements.swapSticks.checked = Boolean(state.profile.swap_sticks);
elements.analog.innerHTML = analogDefinitions.map(([group, side, title, fields]) => {
const config = state.profile[group][side];
const toggles = group === "sticks" ? `
@ -2374,7 +2473,7 @@ function renderMacro() {
elements.macroPreviewTitle.textContent = `Macro ${state.selectedMacro + 1} draft preview`;
elements.macroPreviewButtons.innerHTML = state.schema.buttons.map((button) => `
<span class="preview-button" data-preview-button="${button}">
<b data-output-label="${button}">${escapeHtml(controlLabel(button, controllerStyle))}</b><small>Passthrough</small>
<b data-output-label="${button}">${escapeHtml(outputLabel(button, controllerStyle))}</b><small>Passthrough</small>
</span>`).join("");
renderMacroSteps();
updateMacroPlayback();
@ -2431,7 +2530,7 @@ function renderMacroSteps() {
${state.schema.buttons.map((button) => `
<label class="checkbox-pill">
<input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}>
<span data-output-label="${button}">${escapeHtml(controlLabel(button, controllerStyle))}</span>
<span data-output-label="${button}">${escapeHtml(outputLabel(button, controllerStyle))}</span>
</label>`).join("")}
</div>
</div>
@ -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;

View file

@ -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,

View file

@ -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,
};

View file

@ -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();

View file

@ -136,9 +136,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 9 && encoded[1] == 0 &&
require(encoded[0] == 11 && encoded[1] == 0 &&
encoded[2] == 0x80 && encoded[3] == 1,
"profile header is not little-endian v9/384");
"profile header is not little-endian v11/384");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -226,7 +226,7 @@ void test_profile_wire_schema() {
"nonzero reserved profile byte was accepted");
ControllerProfile invalid = profile;
invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
invalid.button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
require(!controller_profile_validate(invalid),
"invalid direct output was accepted");
invalid = profile;
@ -582,8 +582,8 @@ void test_set_b_sparse_extension_and_migration() {
profile.shortcuts.selectors[7] = 0;
require(!controller_profile_validate(profile), "duplicate shortcut was accepted");
profile.shortcuts.selectors[7] = 15;
profile.shift.button_map[0] = 16;
require(!controller_profile_validate(profile), "analog Shift output was accepted");
profile.shift.button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
require(!controller_profile_validate(profile), "out-of-range Shift output was accepted");
profile.shift.button_map[0] = 0;
profile.turbo_overrides[2].rate_hz = 31;
require(!controller_profile_validate(profile), "out-of-range Turbo rate was accepted");
@ -730,17 +730,17 @@ void test_extra_control_schema_round_trip_and_output_limits() {
"out-of-range extra-control mask was accepted");
encoded[offset] &= 0x7f;
}
profile.extra_button_map[0] = 18;
profile.extra_button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
require(!controller_profile_validate(profile),
"extra input was accepted as a console output destination");
"out-of-range extra output was accepted");
profile.extra_button_map[0] = 0;
profile.triggers[0].output = 18;
profile.triggers[0].output = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
require(!controller_profile_validate(profile),
"analog trigger was allowed to route into a source-only control");
"analog trigger was allowed to route into an invalid control");
profile.triggers[0].output = 0;
profile.shift.extra_button_map[0] = 16;
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_OUTPUT_CONTROL_COUNT;
require(!controller_profile_validate(profile),
"Shift extra mapping admitted an analog destination");
"Shift extra mapping admitted an invalid destination");
}
void test_schema7_migration_preserves_extra_controls_and_macros() {
@ -813,6 +813,195 @@ void test_schema8_migration_preserves_remote_swing() {
}
}
void test_schema9_migration_preserves_all_settings() {
ControllerProfile migrated{};
migrated.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
migrated.swap_sticks = true;
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_decode(kLegacySchema9Profile,
sizeof(kLegacySchema9Profile), &migrated) &&
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
memcmp(kLegacySchema9Profile + 2, upgraded + 2,
sizeof(upgraded) - 2) == 0 &&
migrated.native_joycon_layout ==
ControllerProfileNativeJoyconLayout::kPaired &&
!migrated.swap_sticks &&
migrated.swing.macro == 0 &&
migrated.nunchuk_swing.button == 15 &&
migrated.combined_swing.macro == 3 &&
migrated.combination_window_ms == 200,
"schema9 migration lost settings or enabled native layout/stick swapping");
const uint16_t output_offsets[] = {4, 60, 70, 344, 267, 351};
for (uint16_t offset : output_offsets) {
uint8_t malformed[sizeof(kLegacySchema9Profile)]{};
memcpy(malformed, kLegacySchema9Profile, sizeof(malformed));
malformed[offset] = offset == 267 || offset == 351 ? 16 : 18;
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
"schema9 accepted a formerly invalid output destination");
}
for (size_t offset = 376; offset < sizeof(kLegacySchema9Profile); ++offset) {
uint8_t malformed[sizeof(kLegacySchema9Profile)]{};
memcpy(malformed, kLegacySchema9Profile, sizeof(malformed));
malformed[offset] = 1;
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
"schema9 reinterpreted reserved bytes as layout/stick swapping");
}
}
void test_schema10_migration_preserves_native_settings() {
ControllerProfile migrated{};
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_decode(kLegacySchema10Profile,
sizeof(kLegacySchema10Profile), &migrated) &&
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
upgraded[0] == 11 &&
memcmp(kLegacySchema10Profile + 2, upgraded + 2,
sizeof(upgraded) - 2) == 0 &&
migrated.native_joycon_layout ==
ControllerProfileNativeJoyconLayout::kRightSolo &&
migrated.swap_sticks &&
migrated.button_map[0] == CONTROLLER_PROFILE_LEFT_SL_OUTPUT &&
migrated.extra_button_map[0] == CONTROLLER_PROFILE_LEFT_SR_OUTPUT &&
migrated.shift.button_map[0] == CONTROLLER_PROFILE_RIGHT_SL_OUTPUT &&
migrated.shift.extra_button_map[0] == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT &&
migrated.triggers[0].output == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT &&
migrated.triggers[1].output == CONTROLLER_PROFILE_RIGHT_SR_OUTPUT,
"schema10 migration changed rail destinations, layout, or swapping");
const uint16_t output_offsets[] = {4, 60, 70, 344, 267, 351};
for (uint16_t offset : output_offsets) {
uint8_t malformed[sizeof(kLegacySchema10Profile)]{};
memcpy(malformed, kLegacySchema10Profile, sizeof(malformed));
malformed[offset] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
"schema10 accepted a schema11 direction destination");
}
for (size_t offset = 378; offset < sizeof(kLegacySchema10Profile); ++offset) {
uint8_t malformed[sizeof(kLegacySchema10Profile)]{};
memcpy(malformed, kLegacySchema10Profile, sizeof(malformed));
malformed[offset] = 1;
require(!controller_profile_decode(malformed, sizeof(malformed), &migrated),
"schema10 accepted nonzero reserved padding");
}
}
void test_direction_outputs_preserve_input_namespace() {
ControllerProfile profile{};
require(controller_profile_decode(kLegacySchema10Profile,
sizeof(kLegacySchema10Profile), &profile),
"direction fixture did not decode");
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.button_map[13] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
profile.button_map[14] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
profile.shift.button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
profile.triggers[0].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.triggers[1].output = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.shortcuts.modifier = 24;
profile.shift.modifier = 24;
profile.macros[0].trigger_mask = 1u << 24;
profile.macros[0].cancel_control = 24;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
encoded[0] == 11 && encoded[16] == 22 && encoded[17] == 23 &&
encoded[18] == 24 && encoded[19] == 25 &&
decoded.button_map[10] == 10 &&
decoded.extra_button_map[0] == 24 &&
decoded.shift.button_map[0] == 23 &&
decoded.shift.extra_button_map[0] == 25 &&
decoded.triggers[0].output == 22 && decoded.triggers[1].output == 22 &&
decoded.shortcuts.modifier == 24 && decoded.shift.modifier == 24 &&
decoded.macros[0].trigger_mask == 1u << 24 &&
decoded.macros[0].cancel_control == 24,
"direction destinations changed encoding, clicks, or source indices");
uint8_t round_trip[sizeof(encoded)]{};
require(controller_profile_encode(decoded, round_trip, sizeof(round_trip)) &&
memcmp(encoded, round_trip, sizeof(encoded)) == 0,
"direction destinations did not survive re-encoding");
for (size_t offset = 378; offset < sizeof(encoded); ++offset) {
encoded[offset] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"schema11 consumed reserved padding");
encoded[offset] = 0;
}
profile.shift.modifier = 25;
require(!controller_profile_validate(profile),
"new output destination expanded source-control indices");
profile.shift.modifier = 24;
profile.swing.button = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.swing.macro = CONTROLLER_PROFILE_NO_BUTTON;
require(!controller_profile_validate(profile),
"direction destination expanded swing output buttons");
}
void test_legacy_button_only_destinations() {
for (uint8_t schema = 1; schema <= 2; ++schema) {
uint8_t encoded[sizeof(kLegacyDefaultProfile)]{};
memcpy(encoded, kLegacyDefaultProfile, sizeof(encoded));
encoded[0] = schema;
ControllerProfile decoded{};
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
"legacy button-only fixture did not decode");
encoded[4] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"schema1/2 accepted a schema3 output destination");
}
}
void test_native_layout_and_mapping_boundaries() {
ControllerProfile profile{};
require(controller_profile_decode(kLegacySchema9Profile,
sizeof(kLegacySchema9Profile), &profile),
"native layout fixture did not decode");
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
profile.extra_button_map[0] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
profile.shift.button_map[0] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
profile.shift.extra_button_map[0] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
profile.triggers[0].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
profile.triggers[1].output = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
for (uint8_t layout = 0; layout <= 2; ++layout) {
profile.native_joycon_layout =
static_cast<ControllerProfileNativeJoyconLayout>(layout);
for (uint8_t swap = 0; swap <= 1; ++swap) {
profile.swap_sticks = swap != 0;
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
encoded[376] == layout && encoded[377] == swap &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.native_joycon_layout == profile.native_joycon_layout &&
decoded.swap_sticks == profile.swap_sticks &&
decoded.button_map[0] == 18 &&
decoded.extra_button_map[0] == 19 &&
decoded.shift.button_map[0] == 20 &&
decoded.shift.extra_button_map[0] == 21 &&
decoded.triggers[0].output == 21 &&
decoded.triggers[1].output == 21,
"native fields or shared rail destinations did not round-trip");
}
}
const uint8_t invalid_layouts[] = {3, 255};
for (uint8_t layout : invalid_layouts) {
profile.native_joycon_layout =
static_cast<ControllerProfileNativeJoyconLayout>(layout);
require(!controller_profile_validate(profile),
"out-of-range native layout was accepted");
encoded[376] = layout;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"out-of-range wire native layout was accepted");
}
encoded[376] = 2;
const uint8_t invalid_flags[] = {2, 128, 255};
for (uint8_t flags : invalid_flags) {
encoded[377] = flags;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"reserved stick-swap flags were accepted");
}
}
void test_swing_wire_settings_and_rejection() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
@ -895,8 +1084,8 @@ void test_swing_wire_settings_and_rejection() {
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
"out-of-range combination window was accepted from wire");
}
for (size_t offset = 376; offset < sizeof(encoded); ++offset) {
require(encoded[offset] == 0, "schema9 reserved tail was not zero");
for (size_t offset = 378; offset < sizeof(encoded); ++offset) {
require(encoded[offset] == 0, "profile reserved tail was not zero");
encoded[offset] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero swing extension reservation was accepted");
@ -986,7 +1175,28 @@ void test_swing_macro_targets_require_playable_steps() {
}
} // namespace
int main() {
int main(int argc, char** argv) {
if (argc == 2 && std::strcmp(argv[1], "--legacy9") == 0) {
std::cout.write(reinterpret_cast<const char*>(kLegacySchema9Profile),
sizeof(kLegacySchema9Profile));
return std::cout ? 0 : 1;
}
if (argc == 2 && std::strcmp(argv[1], "--legacy10") == 0) {
std::cout.write(reinterpret_cast<const char*>(kLegacySchema10Profile),
sizeof(kLegacySchema10Profile));
return std::cout ? 0 : 1;
}
if (argc == 2 && std::strcmp(argv[1], "--codec") == 0) {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
std::cin.read(reinterpret_cast<char*>(encoded), sizeof(encoded));
const size_t size = static_cast<size_t>(std::cin.gcount());
ControllerProfile profile{};
require(controller_profile_decode(encoded, size, &profile) &&
controller_profile_encode(profile, encoded, sizeof(encoded)),
"cross-language profile failed to decode/encode");
std::cout.write(reinterpret_cast<const char*>(encoded), sizeof(encoded));
return std::cout ? 0 : 1;
}
test_pair_identity_wire_and_member_validation();
test_profile_wire_schema();
test_legacy_profile_migration();
@ -997,6 +1207,11 @@ int main() {
test_extra_control_schema_round_trip_and_output_limits();
test_schema7_migration_preserves_extra_controls_and_macros();
test_schema8_migration_preserves_remote_swing();
test_schema9_migration_preserves_all_settings();
test_schema10_migration_preserves_native_settings();
test_direction_outputs_preserve_input_namespace();
test_legacy_button_only_destinations();
test_native_layout_and_mapping_boundaries();
test_swing_wire_settings_and_rejection();
test_swing_macro_targets_require_playable_steps();
return 0;

View file

@ -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;

View file

@ -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");

View file

@ -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,

View file

@ -1,6 +1,7 @@
#include "core/controller_identity.h"
#include "profile/controller_profile.h"
#include "profile/profile_storage.h"
#include "controller_profile_legacy_fixtures.h"
#include <cstdlib>
#include <cstring>
@ -938,6 +939,85 @@ void test_schema8_swing_migration_is_lazy_and_preserves_existing_data() {
"editing migrated gestures lost Remote, macros, extras, or metadata");
}
void test_schema9_native_layout_migration_preserves_profiles() {
erase_all();
install_populated_catalog(3);
const auto id = controller_identity_global();
uint8_t legacy[CONTROLLER_PROFILE_ENCODED_SIZE]{};
static_assert(sizeof(kLegacySchema9Profile) == sizeof(legacy));
memcpy(legacy, kLegacySchema9Profile, sizeof(legacy));
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
legacy, sizeof(legacy));
ProfileStorage storage;
ControllerProfile migrated{};
uint8_t migrated_bytes[sizeof(legacy)]{};
require(storage.initialize(fake_io()) &&
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
migrated.native_joycon_layout == ControllerProfileNativeJoyconLayout::kPaired &&
!migrated.swap_sticks &&
controller_profile_encode(migrated, migrated_bytes, sizeof(migrated_bytes)) &&
memcmp(legacy + 2, migrated_bytes + 2, sizeof(legacy) - 2) == 0 &&
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
flash.programs == 0 && flash.erases == 0,
"reading schema9 changed bindings/gestures or rewrote persistent storage");
migrated.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
migrated.swap_sticks = true;
migrated.button_map[5] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
"migrated profile could not save solo layout and rail output");
ProfileStorage reloaded;
ControllerProfile recovered{};
uint8_t expected[sizeof(legacy)]{}, actual[sizeof(legacy)]{};
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
controller_profile_encode(migrated, expected, sizeof(expected)) &&
controller_profile_encode(recovered, actual, sizeof(actual)) &&
memcmp(expected, actual, sizeof(expected)) == 0 &&
reloaded.find(id)->active_profile == 0 &&
reloaded.get_alias(id, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
strcmp(metadata, "A") == 0 &&
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
strcmp(metadata, "A0") == 0,
"solo-layout save/reload lost existing profile settings or metadata");
}
void test_schema10_direction_migration_preserves_layout_and_swap() {
erase_all();
install_populated_catalog(3);
const auto id = controller_identity_global();
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
kLegacySchema10Profile, sizeof(kLegacySchema10Profile));
ProfileStorage storage;
ControllerProfile migrated{};
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(storage.initialize(fake_io()) &&
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
migrated.native_joycon_layout != ControllerProfileNativeJoyconLayout::kPaired &&
migrated.swap_sticks &&
controller_profile_encode(migrated, encoded, sizeof(encoded)) &&
memcmp(encoded + 2, kLegacySchema10Profile + 2, sizeof(encoded) - 2) == 0 &&
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
flash.programs == 0 && flash.erases == 0,
"schema10 read changed solo layout/swap/rails or rewrote storage");
migrated.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
"schema10 profile could not add a digital stick direction");
ProfileStorage reloaded;
ControllerProfile recovered{};
uint8_t expected[sizeof(encoded)]{};
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
controller_profile_encode(migrated, expected, sizeof(expected)) &&
controller_profile_encode(recovered, encoded, sizeof(encoded)) &&
memcmp(encoded, expected, sizeof(encoded)) == 0 &&
reloaded.find(id)->active_profile == 0 &&
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) == ProfileStorageResult::kOk &&
strcmp(metadata, "A0") == 0,
"digital direction save lost other settings, active selection or metadata");
}
void require_pair_bank(const ProfileStorage &storage,
const ControllerIdentity &owner, uint8_t base,
uint8_t active, char name_prefix) {
@ -1168,6 +1248,8 @@ int main() {
test_unreadable_legacy_data_is_not_erased();
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
test_schema8_swing_migration_is_lazy_and_preserves_existing_data();
test_schema9_native_layout_migration_preserves_profiles();
test_schema10_direction_migration_preserves_layout_and_swap();
test_pair_seed_independence_reconnect_and_compaction();
test_pair_capacity_and_unseeded_mutations();
test_pair_seed_interruption_and_ambiguous_readback();

View file

@ -9,12 +9,14 @@
#include "pico/stdlib.h"
#include "platform/pico/bootsel_pairing_button.h"
#include "profile/controller_profile_runtime.h"
#include "profile/profile_service.h"
namespace {
uint64_t now_us = 1000000;
uint32_t stage;
Bluepad32NativeGamepadSnapshot source;
ControllerProfile profile;
uint32_t profile_generation = 1;
bool alternating_shortcut;
bool shortcut_phase;
probe_controller_input controls[2];
@ -39,6 +41,7 @@ int bluepad32_input_backend_native_sample_result(uint8_t, uint64_t) { return -1;
void bluepad32_input_backend_native_sample_cancel(uint8_t) {}
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); }
uint32_t profile_service_database_generation() { return profile_generation; }
bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
ControllerProfileTransformResult controller_profile_runtime_transform(
@ -392,6 +395,284 @@ void nunchuk_buttons_map_to_native_left_shoulders() {
no_mouse_or_rails();
}
void inactive_child(uint8_t instance) {
memset(reports[instance], 0x5a, sizeof(reports[instance]));
assert(!peek(instance) && !controls[instance].active);
assert(controls[instance].buttons[0] == 0 && controls[instance].buttons[1] == 0);
for (uint8_t byte : controls[instance].stick) assert(byte == 0);
for (uint8_t byte : reports[instance]) assert(byte == 0x5a);
}
void solo_controls_and_explicit_rails() {
source.accel_valid = source.gyro_valid = false;
calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700);
calibrate(1, 1800, 1900, 1700, 1800, 1400, 1500);
bool ControllerState::* const faces[] = {
&ControllerState::button_south, &ControllerState::button_east,
&ControllerState::button_west, &ControllerState::button_north};
const uint8_t face_bits[2][4] = {{0x02, 0x08, 0x01, 0x04}, {0x04, 0x01, 0x08, 0x02}};
ControllerState& state = source.controller.state;
for (uint8_t instance = 0; instance < 2; ++instance) {
const bool left = instance == 1;
profile = controller_profile_default(controller_identity_global(), 0);
profile.native_joycon_layout = left ? ControllerProfileNativeJoyconLayout::kLeftSolo :
ControllerProfileNativeJoyconLayout::kRightSolo;
++profile_generation;
for (unsigned face = 0; face < 4; ++face) {
state = {};
state.*faces[face] = true;
publish(false); consume(instance);
assert(reports[instance][2] == face_bits[instance][face]);
inactive_child(instance ^ 1);
}
// Dpad is not silently merged into the four solo face actions.
state = {};
state.dpad_up = state.dpad_down = state.dpad_left = state.dpad_right = true;
publish(false); consume(instance);
assert(reports[instance][2] == 0);
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kDpadRight)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
++profile_generation;
consume(instance);
assert(reports[instance][2] == face_bits[instance][0]);
state = {};
state.button_select = state.button_start = state.button_system = state.button_capture = true;
state.button_left_stick = true;
publish(false); consume(instance);
assert(reports[instance][2] == 0xc0 && reports[instance][3] == 1);
state = {};
state.button_right_stick = true;
state.right_stick_x = INT16_MIN;
publish(false); consume(instance);
assert(reports[instance][2] == 0);
assert(stick_x(instance) == (left ? 1800 : 2000));
assert(stick_y(instance) == (left ? 1900 : 2100));
// A live swap selects the physical right stick AND click, without a
// physical publication or a second swap in the native routing layer.
profile.swap_sticks = true;
++profile_generation;
consume(instance);
assert(reports[instance][2] == 0x80);
assert(stick_x(instance) == (left ? 1800 : 2000));
assert(stick_y(instance) == (left ? 3700 : 400));
state.button_right_stick = false;
state.button_left_stick = true;
state.right_stick_x = 0;
state.right_stick_y = INT16_MIN;
publish(false); consume(instance);
assert(reports[instance][2] == 0);
assert(stick_x(instance) == (left ? 3500 : 400));
assert(stick_y(instance) == (left ? 1900 : 2100));
state = {};
state.button_left_shoulder = true;
state.left_trigger = UINT16_MAX;
publish(false); consume(instance);
assert(reports[instance][2] == (left ? 0x30 : 0));
assert(reports[instance][3] == 0);
state = {};
state.button_right_shoulder = true;
state.right_trigger = UINT16_MAX;
publish(false); consume(instance);
assert(reports[instance][2] == (left ? 0 : 0x30));
assert(reports[instance][3] == 0);
const uint8_t sl = left ? CONTROLLER_PROFILE_LEFT_SL_OUTPUT : CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
const unsigned shoulder_l = static_cast<unsigned>(ControllerProfileLogicalButton::kLeftShoulder);
const unsigned shoulder_r = static_cast<unsigned>(ControllerProfileLogicalButton::kRightShoulder);
profile.button_map[shoulder_l] = sl;
profile.button_map[shoulder_r] = sl + 1;
++profile_generation;
state = {};
state.button_left_shoulder = true;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0x80);
state.button_right_shoulder = true;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0xc0);
state.button_left_shoulder = false;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0x40);
// Mapping to the other child's rails never creates a selected-side chord.
profile.button_map[shoulder_l] = left ? CONTROLLER_PROFILE_RIGHT_SL_OUTPUT : CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
profile.button_map[shoulder_r] = left ? CONTROLLER_PROFILE_RIGHT_SR_OUTPUT : CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
++profile_generation;
state.button_left_shoulder = true;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0);
inactive_child(instance ^ 1);
// A mapped analog source reaches the same rail wire bit only at its
// transformed digital threshold; unmapped physical extras cannot leak.
profile.triggers[0].output = sl;
profile.triggers[0].digital_threshold = 20000;
++profile_generation;
state = {};
state.extra_buttons = 0x7f;
state.left_trigger = 19999;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0);
state.left_trigger = 20000;
publish(false); consume(instance);
assert(reports[instance][2] == 0 && reports[instance][3] == 0x80);
}
}
void profile_changes_retire_tokens_without_source_publication() {
profile = controller_profile_default(controller_identity_global(), 0);
++profile_generation;
source.controller.state = {};
source.controller.state.button_south = source.controller.state.dpad_left = true;
source.accel_valid = source.gyro_valid = false;
publish(false);
const uint32_t paired_right = peek(0), paired_left = peek(1);
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
++profile_generation;
assert(!probe_controller_input_commit_native_report(0, paired_right));
assert(!probe_controller_input_commit_native_report(1, paired_left));
consume(0);
assert(reports[0][2] == 0x02);
inactive_child(1);
const uint32_t solo_right = peek(0);
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
++profile_generation;
assert(!probe_controller_input_commit_native_report(0, solo_right));
consume(1);
assert(reports[1][2] == 0x04);
inactive_child(0);
const uint32_t solo_left = peek(1);
// Even an ordinary mapping edit in the same layout retires old reports.
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kSouth)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
++profile_generation;
assert(!probe_controller_input_commit_native_report(1, solo_left));
consume(1);
assert(reports[1][2] == 0x02);
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kPaired;
++profile_generation;
pair();
assert(reports[0][2] == 0x08 && reports[1][2] == 0x04);
}
void digital_dpad_reaches_the_mapped_left_stick() {
source.accel_valid = source.gyro_valid = false;
source.controller.active = true;
calibrate(0, 2048, 2048, 1000, 1000, 1000, 1000);
calibrate(1, 2048, 2048, 1000, 1000, 1000, 1000);
profile = controller_profile_default(controller_identity_global(), 0);
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.button_map[13] = CONTROLLER_PROFILE_LEFT_STICK_DOWN_OUTPUT;
profile.button_map[14] = CONTROLLER_PROFILE_LEFT_STICK_LEFT_OUTPUT;
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
++profile_generation;
ControllerState& state = source.controller.state;
state = {};
state.dpad_up = true;
publish(false); pair();
assert(stick_x(1) == 2048 && stick_y(1) == 3048);
assert(stick_x(0) == 2048 && stick_y(0) == 2048);
assert(reports[0][2] == 0 && reports[1][2] == 0);
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
++profile_generation;
consume(0);
assert(stick_x(0) == 1048 && stick_y(0) == 2048);
inactive_child(1);
state.dpad_right = true;
publish(false); consume(0);
assert(stick_x(0) == 1341 && stick_y(0) == 2755);
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
++profile_generation;
consume(1);
assert(stick_x(1) == 2755 && stick_y(1) == 1341);
inactive_child(0);
// Digital directions still target mapped LEFT after swapping. Physical
// left movement now belongs to mapped right and must not block them.
profile.swap_sticks = true;
++profile_generation;
state.dpad_up = false;
state.left_stick_x = INT16_MAX;
publish(false); consume(1);
assert(stick_x(1) == 2048 && stick_y(1) == 1048);
state.right_stick_y = INT16_MAX;
publish(false); consume(1);
assert(stick_x(1) == 1048 && stick_y(1) == 2048);
// Releasing all inputs cannot leave a generated stick or click held.
state = {};
publish(false); consume(1);
assert(stick_x(1) == 2048 && stick_y(1) == 2048 && reports[1][2] == 0);
}
void solo_motion_rotates_coherently_and_resets_frame() {
profile = controller_profile_default(controller_identity_global(), 0);
source.controller.state = {};
++source.controller.connection_generation;
source.track_stationary_bias = false;
source.accel_valid = source.gyro_valid = true;
source.accel_q13[0] = 2048;
source.accel_q13[1] = 4096;
source.accel_q13[2] = -4096;
// Parallel acceleration/rate vectors turn about reference gravity. A
// one-sided or sign-inconsistent rotation cannot preserve this motion.
source.gyro_q10[0] = 30 * 1024;
source.gyro_q10[1] = 60 * 1024;
source.gyro_q10[2] = -60 * 1024;
const ControllerProfileNativeJoyconLayout layouts[] = {
ControllerProfileNativeJoyconLayout::kPaired,
ControllerProfileNativeJoyconLayout::kLeftSolo,
ControllerProfileNativeJoyconLayout::kRightSolo,
ControllerProfileNativeJoyconLayout::kPaired};
const int32_t body_accel[4][3] = {
{2048, 4096, 4096}, {4096, 4096, -2048},
{-4096, 4096, 2048}, {2048, 4096, 4096}};
uint32_t previous_token = 0;
uint8_t previous_instance = 0;
publish();
for (unsigned layout = 0; layout < 4; ++layout) {
profile.native_joycon_layout = layouts[layout];
++profile_generation;
if (previous_token)
assert(!probe_controller_input_commit_native_report(previous_instance, previous_token));
const uint8_t instance = layout == 1 ? 1 : layout == 2 ? 0 :
(SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 0 : 1;
consume(instance);
if (layout == 1 || layout == 2) inactive_child(instance ^ 1);
if (!(SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance))) {
assert(imu_length(instance) == 0);
continue;
}
assert(imu_length(instance) == 30);
const uint8_t* imu = reports[instance] + probe_model_imu_data_offset(instance);
assert(bits(imu, 12, 12) == 1); // No committed timestamp from the old frame.
for (unsigned axis = 0; axis < 3; ++axis)
assert(bits(imu, 128 + axis * 32, 32) == static_cast<uint32_t>(body_accel[layout][axis] * 32768));
double initial[4]; quaternion(instance, initial);
const double w = sqrt((1.0 + body_accel[layout][2] / 6144.0) / 2.0);
const double expected[4] = {
w, body_accel[layout][1] / (12288.0 * w),
-body_accel[layout][0] / (12288.0 * w), 0};
double dot = 0;
for (unsigned i = 0; i < 4; ++i) dot += initial[i] * expected[i];
assert(fabs(fabs(dot) - 1.0) < 1e-6);
for (unsigned sample = 0; sample < 250; ++sample) { publish(); consume(instance); }
double turned[4]; quaternion(instance, turned);
const double half = sqrt(.5);
const double expected_turn[4] = {
half * initial[0], half * (initial[1] - initial[2]),
half * (initial[1] + initial[2]), half * initial[0]};
dot = 0;
for (unsigned i = 0; i < 4; ++i) dot += turned[i] * expected_turn[i];
assert(fabs(fabs(dot) - 1.0) < 1e-5);
consume(instance);
assert(imu_length(instance) == 0); // No repeated sensor provenance.
publish();
previous_token = peek(instance);
previous_instance = instance;
assert(previous_token && imu_length(instance) == 30);
// Next layout uses this exact fresh source sample, not a new publication.
}
}
} // namespace
int main() {
@ -407,5 +688,9 @@ int main() {
selected_motion_target_keeps_both_control_halves();
wii_bias_and_independent_sensor_freshness();
nunchuk_buttons_map_to_native_left_shoulders();
solo_controls_and_explicit_rails();
profile_changes_retire_tokens_without_source_publication();
digital_dpad_reaches_the_mapped_left_stick();
solo_motion_rotates_coherently_and_resets_frame();
return 0;
}

View file

@ -3,6 +3,7 @@
#include "platform/pico/controller_color_config.h"
#include "tusb.h"
#include "pico/time.h"
#include "profile/controller_profile_transform.h"
#include <array>
#include <cstddef>
@ -810,6 +811,112 @@ void test_motion_cadence_survives_usb_poll_quantization() {
expect(!switch_pro_task(0), "motion replayed a stale catch-up burst");
}
void test_profile_rails_and_stick_swap_reach_pro_reports() {
initialize_contexts();
ControllerProfile profile = controller_profile_default(controller_identity_global(), 0);
profile.button_map[0] = CONTROLLER_PROFILE_LEFT_SL_OUTPUT;
profile.button_map[1] = CONTROLLER_PROFILE_LEFT_SR_OUTPUT;
profile.button_map[2] = CONTROLLER_PROFILE_RIGHT_SL_OUTPUT;
profile.button_map[3] = CONTROLLER_PROFILE_RIGHT_SR_OUTPUT;
const uint8_t expected_right[] = {0, 0, 0x20, 0x10};
const uint8_t expected_left[] = {0x20, 0x10, 0, 0};
for (unsigned i = 0; i < 4; ++i) {
ControllerState input{};
controller_profile_apply_button_mask(static_cast<uint16_t>(1u << i), &input);
const auto mapped = controller_profile_transform(input, profile);
usb_output_driver_set_input(0, mapped.state,
mapped.left_trigger_digital_threshold,
mapped.right_trigger_digital_threshold);
now_ms += 15;
expect(switch_pro_task(0), "mapped rail did not produce a Pro report");
const SentReport* packet = latest_regular_report(0);
// Compare raw bytes: sharing the packed struct here would hide reversed
// Left SL/SR declarations. Both button bytes use SR bit4 / SL bit5.
expect(packet && packet->data[3] == expected_right[i] &&
packet->data[5] == expected_left[i],
"profile rail mapping reached the wrong Pro report bit or leaked its face button");
const SwitchProReport current = get_current_report(0, "rail GET_REPORT failed");
const auto* bytes = reinterpret_cast<const uint8_t*>(&current);
expect(bytes[3] == expected_right[i] && bytes[5] == expected_left[i],
"Pro GET_REPORT disagreed with streamed rail state");
}
ControllerState input{};
auto mapped = controller_profile_transform(input, profile);
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
mapped.right_trigger_digital_threshold);
now_ms += 15;
expect(switch_pro_task(0), "rail release did not produce a Pro report");
const SentReport* released = latest_regular_report(0);
expect(released && released->data[3] == 0 && released->data[5] == 0,
"released rail mapping stayed pressed");
profile.swap_sticks = true;
profile.native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
input.left_stick_x = 1600;
input.left_stick_y = 3200;
input.right_stick_x = -1600;
input.right_stick_y = -3200;
input.button_left_stick = true;
mapped = controller_profile_transform(input, profile);
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
mapped.right_trigger_digital_threshold);
now_ms += 15;
expect(switch_pro_task(0), "swapped sticks did not produce a Pro report");
SwitchProReport swapped = copy_switch_report(latest_regular_report(0));
expect(swapped.inputs.leftStick.getX() == (controller_axis_to_unsigned(input.right_stick_x) >> 4) &&
swapped.inputs.leftStick.getY() ==
((-(controller_axis_to_unsigned(input.right_stick_y) >> 4)) & 0xfff) &&
swapped.inputs.rightStick.getX() == (controller_axis_to_unsigned(input.left_stick_x) >> 4) &&
swapped.inputs.rightStick.getY() ==
((-(controller_axis_to_unsigned(input.left_stick_y) >> 4)) & 0xfff) &&
swapped.inputs.buttonThumbR && !swapped.inputs.buttonThumbL,
"Pro output lost swapped axes/clicks or applied native-only solo routing");
SwitchProReport untouched = get_current_report(1, "other Pro instance GET_REPORT failed");
const auto* untouched_bytes = reinterpret_cast<const uint8_t*>(&untouched);
expect((untouched_bytes[3] & 0x30) == 0 && (untouched_bytes[5] & 0x30) == 0 &&
!untouched.inputs.buttonThumbL && !untouched.inputs.buttonThumbR,
"mapped rails or stick clicks crossed Pro instances");
}
void test_digital_dpad_reaches_pro_stick_without_dpad_leakage() {
initialize_contexts();
ControllerProfile profile = controller_profile_default(controller_identity_global(), 0);
profile.button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profile.button_map[15] = CONTROLLER_PROFILE_LEFT_STICK_RIGHT_OUTPUT;
const auto send = [&](const ControllerState& input) {
const auto mapped = controller_profile_transform(input, profile);
usb_output_driver_set_input(0, mapped.state, mapped.left_trigger_digital_threshold,
mapped.right_trigger_digital_threshold);
now_ms += 15;
expect(switch_pro_task(0), "digital-stick Pro input did not send");
return copy_switch_report(latest_regular_report(0));
};
ControllerState input{};
input.dpad_up = input.dpad_right = true;
SwitchProReport digital = send(input);
input = {};
input.left_stick_x = 23169;
input.left_stick_y = -23169;
SwitchProReport analog = send(input);
expect(digital.inputs.leftStick.getX() == analog.inputs.leftStick.getX() &&
digital.inputs.leftStick.getY() == analog.inputs.leftStick.getY() &&
!digital.inputs.dpadUp && !digital.inputs.dpadRight &&
!digital.inputs.buttonThumbL,
"digital diagonal did not reach the Pro left stick or leaked Dpad/L3 buttons");
input.dpad_up = true;
input.left_stick_x = -1600;
input.left_stick_y = 3200;
SwitchProReport priority = send(input);
input.dpad_up = false;
SwitchProReport reference = send(input);
expect(priority.inputs.leftStick.getX() == reference.inputs.leftStick.getX() &&
priority.inputs.leftStick.getY() == reference.inputs.leftStick.getY(),
"digital direction overrode active Pro analog movement");
input = {};
SwitchProReport released = send(input);
expect_neutral_sticks(released, "released digital directions left the Pro stick held");
}
} // namespace
extern "C" absolute_time_t get_absolute_time(void) {
@ -854,6 +961,8 @@ int main() {
test_startup_identify_preserves_first_reply_counter();
test_failed_startup_identify_retries_preserve_counter();
test_input_reports_and_timers_are_isolated();
test_profile_rails_and_stick_swap_reach_pro_reports();
test_digital_dpad_reaches_pro_stick_without_dpad_leakage();
test_callback_send_and_imu_modes_are_isolated();
test_rumble_callbacks_and_decoders_are_isolated();
test_grip_colors_are_isolated();

View file

@ -1964,6 +1964,8 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
"nunchuk_swing",
"combined_swing",
"combination_window_ms",
"native_joycon_layout",
"swap_sticks",
):
del legacy_json_object[field]
del legacy_json_object["motion_toggle_chord"]
@ -2036,6 +2038,8 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None:
"nunchuk_swing",
"combined_swing",
"combination_window_ms",
"native_joycon_layout",
"swap_sticks",
):
del obj[field]
for macro in obj["macros"]:
@ -2087,7 +2091,10 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
legacy_json["schema_version"] = 6
del legacy_json["extra_button_map"]
del legacy_json["shift"]["extra_button_map"]
for field in ("swing", "nunchuk_swing", "combined_swing", "combination_window_ms"):
for field in (
"swing", "nunchuk_swing", "combined_swing", "combination_window_ms",
"native_joycon_layout", "swap_sticks",
):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
@ -2108,7 +2115,7 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
)
@pytest.mark.parametrize("version", [7, 8, 9])
@pytest.mark.parametrize("version", [7, 8, 9, 10, 11])
def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
version: int,
) -> None:
@ -2145,6 +2152,9 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
obj["macros"][index]["trigger"] = names
obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3]
obj["schema_version"] = version
if version < 10:
del obj["native_joycon_layout"]
del obj["swap_sticks"]
if version == 7:
del obj["swing"]
elif version == 8:
@ -2196,11 +2206,15 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(
("button_map", "south"),
("extra_button_map", "c"),
("triggers", "left", "output"),
("shift", "button_map", "south"),
("shift", "extra_button_map", "c"),
],
)
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> None:
def test_schema9_rejects_extra_output_destinations(path: tuple[str, ...]) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
obj["schema_version"] = 9
del obj["native_joycon_layout"]
del obj["swap_sticks"]
target = obj
for key in path[:-1]:
target = target[key]
@ -2209,6 +2223,288 @@ def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) ->
config_manager.ControllerProfile.from_json_object(obj)
@pytest.mark.parametrize(
("layout", "layout_byte", "swap"),
[("paired", 0, False), ("left_solo", 1, True), ("right_solo", 2, False)],
)
def test_native_profile_fields_and_rail_outputs_round_trip(
layout: str, layout_byte: int, swap: bool
) -> None:
obj = custom_profile().to_json_object()
obj["native_joycon_layout"] = layout
obj["swap_sticks"] = swap
obj["switching_chord"] = ["right_sr"]
obj["macros"][0]["trigger"] = ["right_sr"]
obj["macros"][0]["cancel"] = "right_sr"
obj["shift"]["modifier"] = "left_sl"
obj["button_map"]["south"] = "left_sl"
obj["extra_button_map"]["c"] = "left_sr"
obj["shift"]["button_map"]["east"] = "right_sl"
obj["shift"]["button_map"]["north"] = "left_trigger"
obj["shift"]["extra_button_map"]["right_sr"] = "right_sr"
obj["triggers"]["left"]["output"] = "right_sr"
obj["triggers"]["right"]["output"] = "right_sr"
profile = config_manager.ControllerProfile.from_json_object(obj)
payload = profile.to_bytes()
assert payload[376:384] == bytes((layout_byte, int(swap), 0, 0, 0, 0, 0, 0))
assert payload[4] == 18
assert payload[344] == 19
assert payload[268] == 20
assert payload[270] == 16
assert payload[357] == payload[60] == payload[70] == 21
assert payload[358] == payload[362] == 1 << 6
assert payload[266] == 21
assert profile.macros[0].cancel_control == 24
assert config_manager.ControllerProfile.from_bytes(payload) == profile
assert profile.to_json_object() == obj
obj["triggers"]["left"]["output"] = "left_trigger"
obj["triggers"]["right"]["output"] = "left_trigger"
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
@pytest.mark.parametrize(
("path", "source"),
[
(("button_map", "south"), "c"),
(("extra_button_map", "c"), "gl"),
(("triggers", "left", "output"), "gr"),
(("shift", "button_map", "south"), "c"),
(("shift", "extra_button_map", "c"), "gl"),
],
)
def test_source_only_controls_are_not_output_destinations(
path: tuple[str, ...], source: str
) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
target = obj
for key in path[:-1]:
target = target[key]
target[path[-1]] = source
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
def test_left_stick_direction_destinations_round_trip_without_new_sources() -> None:
obj = custom_profile().to_json_object()
obj["button_map"].update(
dpad_up="left_stick_up", dpad_down="left_stick_down",
dpad_left="left_stick_left", dpad_right="left_stick_right",
)
obj["extra_button_map"]["c"] = "left_stick_left"
obj["shift"]["button_map"]["south"] = "left_stick_down"
obj["shift"]["extra_button_map"]["right_sr"] = "left_stick_right"
obj["triggers"]["left"]["output"] = "left_stick_up"
obj["triggers"]["right"]["output"] = "left_stick_up"
obj["shift"]["modifier"] = "right_sr"
obj["shortcuts"]["modifier"] = "right_sr"
obj["macros"][0]["trigger"] = ["right_sr"]
obj["macros"][0]["cancel"] = "right_sr"
profile = config_manager.ControllerProfile.from_json_object(obj)
encoded = profile.to_bytes()
assert encoded[:4] == struct.pack("<HH", 11, 384)
assert encoded[16:20] == bytes((22, 23, 24, 25))
assert encoded[344] == 24
assert encoded[267] == 23
assert encoded[357] == 25
assert encoded[60] == encoded[70] == 22
assert encoded[378:384] == bytes(6)
assert profile.shift.modifier == profile.shortcuts.modifier == 24
assert profile.macros[0].trigger_mask == 1 << 24
assert profile.macros[0].cancel_control == 24
assert profile.button_map[10] == 10
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
assert profile.to_json_object() == obj
@pytest.mark.parametrize(
("path", "offset"),
[
(("button_map", "south"), 4),
(("extra_button_map", "c"), 344),
(("triggers", "left", "output"), 60),
(("triggers", "right", "output"), 70),
(("shift", "button_map", "south"), 267),
(("shift", "extra_button_map", "c"), 351),
],
)
def test_schema10_rejects_direction_destinations(
path: tuple[str, ...], offset: int
) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
obj["schema_version"] = 10
target = obj
for key in path[:-1]:
target = target[key]
target[path[-1]] = "left_stick_up"
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
payload[0] = 10
payload[offset] = 22
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize(
("path", "value"),
[
(("switching_chord",), ["left_stick_up"]),
(("motion_toggle_chord",), ["left_stick_up"]),
(("shift", "modifier"), "left_stick_up"),
(("shortcuts", "modifier"), "left_stick_up"),
(("macros", 0, "trigger"), ["left_stick_up"]),
(("macros", 0, "cancel"), "left_stick_up"),
(("swing", "modifier"), "left_stick_up"),
(("swing", "button"), "left_stick_up"),
(("macros", 0, "steps", 0, "output_buttons"), ["left_stick_up"]),
],
)
def test_direction_outputs_do_not_expand_sources_or_macro_swing_buttons(
path: tuple[str | int, ...], value: object
) -> None:
obj = custom_profile().to_json_object()
target = obj
for key in path[:-1]:
target = target[key]
target[path[-1]] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
def test_schema10_native_settings_migrate_without_persistent_writes() -> None:
base = custom_profile()
expected = replace(
base,
native_joycon_layout=2,
swap_sticks=True,
button_map=(18, *base.button_map[1:]),
extra_button_map=(19, *base.extra_button_map[1:]),
shift=replace(
base.shift,
button_map=(20, *base.shift.button_map[1:]),
extra_button_map=(21, *base.shift.extra_button_map[1:]),
),
left_trigger=replace(base.left_trigger, output=21),
right_trigger=replace(base.right_trigger, output=21),
)
legacy_wire = bytearray(expected.to_bytes())
legacy_wire[0] = 10
legacy_json = expected.to_json_object()
legacy_json["schema_version"] = 10
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == expected
assert config_manager.ControllerProfile.from_json_object(legacy_json) == expected
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
profiles_before = device.profiles.copy()
active_before = device.active_profiles.copy()
generation_before = device.profile_generation
assert config_manager.read_profile(device, device.stable_identity, 1) == expected
assert device.profiles == profiles_before
assert device.active_profiles == active_before
assert device.profile_generation == generation_before
assert expected.to_bytes()[2:] == legacy_wire[2:]
listing = config_manager.parse_profile_list(
config_manager.parse_response(
make_response(
config_manager.OP_PROFILE_LIST,
device._profile_list_payload(),
schema=10,
),
config_manager.OP_PROFILE_LIST,
)
)
assert listing[1].identity == device.stable_identity
@pytest.mark.parametrize("version", [1, 2])
def test_legacy_button_maps_reject_analog_destinations(version: int) -> None:
payload = legacy_profile_wire(version)
assert config_manager.ControllerProfile.from_bytes(payload).button_map == tuple(range(16))
payload[4] = 16
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
def test_schema9_gestures_migrate_without_persistent_writes() -> None:
expected = replace(
custom_profile(),
swing=config_manager.ProfileSwing(255, 2, 24, 0),
nunchuk_swing=config_manager.ProfileSwing(15, 0, 18),
combined_swing=config_manager.ProfileCombinedSwing(255, 1, 16),
combination_window_ms=200,
)
legacy_wire = bytearray(expected.to_bytes())
legacy_wire[0] = 9
legacy_json = expected.to_json_object()
legacy_json["schema_version"] = 9
del legacy_json["native_joycon_layout"]
del legacy_json["swap_sticks"]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == expected
assert config_manager.ControllerProfile.from_json_object(legacy_json) == expected
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
profiles_before = device.profiles.copy()
active_before = device.active_profiles.copy()
generation_before = device.profile_generation
assert config_manager.read_profile(device, device.stable_identity, 1) == expected
assert device.profiles == profiles_before
assert device.active_profiles == active_before
assert device.profile_generation == generation_before
migrated = config_manager.read_selected_profile(device)
assert migrated.native_joycon_layout == 0
assert migrated.swap_sticks is False
assert migrated.to_bytes()[2:] == legacy_wire[2:]
@pytest.mark.parametrize(
("offset", "value"),
[(4, 18), (60, 18), (70, 24), (344, 18), (267, 16), (351, 16),
(376, 1), (377, 1), (383, 1)],
)
def test_schema9_keeps_legacy_output_and_reserved_byte_bounds(
offset: int, value: int
) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
payload[0] = 9
payload[offset] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize(
("offset", "value"),
[(376, 3), (376, 255), (377, 2), (377, 128), (377, 255), (383, 1)],
)
def test_native_fields_reject_invalid_wire_values(offset: int, value: int) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
payload[offset] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize(
("field", "value"),
[("native_joycon_layout", "solo"), ("native_joycon_layout", 1),
("native_joycon_layout", None), ("swap_sticks", 1),
("swap_sticks", "false"), ("swap_sticks", None)],
)
def test_native_fields_reject_invalid_json_types(field: str, value: object) -> None:
obj = custom_profile().to_json_object()
obj[field] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
def test_missing_native_fields_preserve_legacy_behavior() -> None:
expected = custom_profile()
obj = expected.to_json_object()
del obj["native_joycon_layout"]
del obj["swap_sticks"]
assert config_manager.ControllerProfile.from_json_object(obj) == expected
@pytest.mark.parametrize(
"offset,value",
[
@ -2289,6 +2585,8 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
"nunchuk_swing",
"combined_swing",
"combination_window_ms",
"native_joycon_layout",
"swap_sticks",
):
del obj[key]
assert config_manager.ControllerProfile.from_json_object(obj) == profile
@ -2389,7 +2687,7 @@ def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) ->
(374, 25),
(375, 29),
(375, 201),
(376, 1),
(378, 1),
],
)
def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
@ -2422,6 +2720,8 @@ def test_swing_json_fields_are_strict(mutation: str) -> None:
else:
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
del obj[field]
del obj["native_joycon_layout"]
del obj["swap_sticks"]
obj["schema_version"] = 7
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
@ -2435,7 +2735,10 @@ def test_schema8_swing_migration_preserves_remote_binding() -> None:
legacy_json = profile.to_json_object()
legacy_json["schema_version"] = 8
del legacy_json["swing"]["macro"]
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
for field in (
"nunchuk_swing", "combined_swing", "combination_window_ms",
"native_joycon_layout", "swap_sticks",
):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
@ -2566,7 +2869,7 @@ def test_combination_window_rejects_out_of_range_or_noninteger_values(
(("shortcuts", "profiles", 1), "south"),
(("shortcuts", "profiles", 0), "left_shoulder"),
(("shift", "modifier"), None),
(("shift", "button_map", "south"), "left_trigger"),
(("shift", "button_map", "south"), "unknown"),
(("turbo_settings", "defaults", "rate_hz"), 0),
(("turbo_settings", "defaults", "rate_hz"), 31),
(("turbo_settings", "defaults", "duty_percent"), 0),
@ -2598,7 +2901,7 @@ def test_set_b_rejects_invalid_json_settings(path: tuple, value: object) -> None
(288, 1),
(383, 1),
(265, 3),
(267, 16),
(267, 26),
(283, 31),
(284, 100),
(285, 0),

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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<int16_t>(-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<float>(source[2]) * scale;
switch (g_mapped.native_joycon_layout) {
case ControllerProfileNativeJoyconLayout::kLeftSolo:
output[0] = static_cast<float>(source[1]) * scale;
output[2] = -static_cast<float>(source[0]) * scale;
break;
case ControllerProfileNativeJoyconLayout::kRightSolo:
output[0] = -static_cast<float>(source[1]) * scale;
output[2] = static_cast<float>(source[0]) * scale;
break;
case ControllerProfileNativeJoyconLayout::kPaired:
output[0] = static_cast<float>(source[0]) * scale;
output[2] = static_cast<float>(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<uint8_t>(
(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<uint8_t>(
(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<uint8_t>(
(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<uint8_t>(x);
child.input.stick[1] = static_cast<uint8_t>((x >> 8) | (y << 4));
child.input.stick[2] = static_cast<uint8_t>(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<float>(source.accel_q13[0]) / 8192.0f;
sample.accel_g[1] = -static_cast<float>(source.accel_q13[2]) / 8192.0f;
sample.accel_g[2] = static_cast<float>(source.accel_q13[1]) / 8192.0f;
sample.gyro_dps[0] = static_cast<float>(source.gyro_q10[0]) / 1024.0f;
sample.gyro_dps[1] = -static_cast<float>(source.gyro_q10[2]) / 1024.0f;
sample.gyro_dps[2] = static_cast<float>(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 ||

View file

@ -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)