Add sparse multi-macro profiles

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 16:22:20 -06:00
commit ce93f34d94
13 changed files with 1250 additions and 593 deletions

View file

@ -137,7 +137,7 @@ 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. 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 the complete bounded custom macro sequence are editable. Select a controller identity and one of its four 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 localhost 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 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. Select a controller identity and one of its four 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 localhost port.
The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets.
@ -164,7 +164,7 @@ The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run
- On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number. - On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number.
- Each controller identity and each of the four active USB slots remain isolated. - Each controller identity and each of the four active USB slots remain isolated.
Profile input processing is deterministic: physical buttons and analog triggers are mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Turbo runs at 15 activations per second while held. Auto Burst starts on one press and stops on a second press or the configured macro-cancel control. A custom macro can be triggered or cancelled by a button or analog trigger, and its trigger can be a multi-control chord. Partial chords remain available as ordinary input. Macros contain at most seven state steps plus the required explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset. Profile input processing is deterministic: physical buttons and analog triggers are mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Each profile has four independently triggered macros. A macro supports up to eight state steps, while all four share a fixed sixteen-step decoded pool and a 136-byte sparse wire stream. A wait-only step uses 3 bytes, a button-only step 5 bytes, and a full-precision all-field step 17 bytes; therefore the stream holds 16 typical button steps or exactly 8 all-field steps. Stick coordinates remain signed 16-bit values. Macro triggers can be multi-control chords, partial chords remain ordinary input, and each macro has its own cancel control.
### Per-controller motion toggle ### Per-controller motion toggle

View file

@ -57,18 +57,8 @@ bool valid_control_output(uint8_t output) {
output == CONTROLLER_PROFILE_NO_BUTTON; output == CONTROLLER_PROFILE_NO_BUTTON;
} }
bool valid_macro_step(const ControllerProfileMacroStep& step, bool valid_macro_step(const ControllerProfileMacroStep& step) {
bool must_end) { if ((step.override_flags & ~kMacroOverrideMask) != 0 ||
if (must_end) {
return step.type == ControllerProfileMacroStepType::kEnd &&
step.override_flags == 0 && step.duration_ms == 0 &&
step.output_button_mask == 0 && step.left_stick_x == 0 &&
step.left_stick_y == 0 && step.right_stick_x == 0 &&
step.right_stick_y == 0 && step.left_trigger == 0 &&
step.right_trigger == 0;
}
if (step.type != ControllerProfileMacroStepType::kState ||
(step.override_flags & ~kMacroOverrideMask) != 0 ||
step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) { step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) {
return false; return false;
} }
@ -95,6 +85,112 @@ bool valid_macro_step(const ControllerProfileMacroStep& step,
return true; return true;
} }
size_t sparse_macro_step_size(const ControllerProfileMacroStep& step) {
size_t size = 3;
size += (step.override_flags & kControllerProfileOverrideButtons) != 0
? 2
: 0;
size += (step.override_flags & kControllerProfileOverrideLeftStick) != 0
? 4
: 0;
size += (step.override_flags & kControllerProfileOverrideRightStick) != 0
? 4
: 0;
size += (step.override_flags & kControllerProfileOverrideLeftTrigger) != 0
? 2
: 0;
size += (step.override_flags & kControllerProfileOverrideRightTrigger) != 0
? 2
: 0;
return size;
}
bool encode_sparse_macro_step(
const ControllerProfileMacroStep& step, uint8_t* output,
size_t output_size, size_t* encoded_size) {
const size_t required = sparse_macro_step_size(step);
if (!valid_macro_step(step) || output == nullptr ||
encoded_size == nullptr || required > output_size) {
return false;
}
size_t offset = 0;
output[offset++] = step.override_flags;
profile_write_u16(&output[offset], step.duration_ms);
offset += 2;
if ((step.override_flags & kControllerProfileOverrideButtons) != 0) {
profile_write_u16(&output[offset], step.output_button_mask);
offset += 2;
}
if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) {
profile_write_i16(&output[offset], step.left_stick_x);
profile_write_i16(&output[offset + 2], step.left_stick_y);
offset += 4;
}
if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) {
profile_write_i16(&output[offset], step.right_stick_x);
profile_write_i16(&output[offset + 2], step.right_stick_y);
offset += 4;
}
if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) {
profile_write_u16(&output[offset], step.left_trigger);
offset += 2;
}
if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) {
profile_write_u16(&output[offset], step.right_trigger);
offset += 2;
}
*encoded_size = offset;
return true;
}
bool decode_sparse_macro_step(
const uint8_t* input, size_t input_size,
ControllerProfileMacroStep* output, size_t* decoded_size) {
if (input == nullptr || output == nullptr || decoded_size == nullptr ||
input_size < 3) {
return false;
}
ControllerProfileMacroStep step{};
step.override_flags = input[0];
step.duration_ms = profile_read_u16(&input[1]);
if (!valid_macro_step(step)) {
return false;
}
const size_t required = sparse_macro_step_size(step);
if (required > input_size) {
return false;
}
size_t offset = 3;
if ((step.override_flags & kControllerProfileOverrideButtons) != 0) {
step.output_button_mask = profile_read_u16(&input[offset]);
offset += 2;
}
if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) {
step.left_stick_x = profile_read_i16(&input[offset]);
step.left_stick_y = profile_read_i16(&input[offset + 2]);
offset += 4;
}
if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) {
step.right_stick_x = profile_read_i16(&input[offset]);
step.right_stick_y = profile_read_i16(&input[offset + 2]);
offset += 4;
}
if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) {
step.left_trigger = profile_read_u16(&input[offset]);
offset += 2;
}
if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) {
step.right_trigger = profile_read_u16(&input[offset]);
offset += 2;
}
if (!valid_macro_step(step)) {
return false;
}
*output = step;
*decoded_size = offset;
return true;
}
void copy_overlap(size_t range_offset, uint8_t* output, void copy_overlap(size_t range_offset, uint8_t* output,
size_t output_size, size_t field_offset, size_t output_size, size_t field_offset,
const uint8_t* field, size_t field_size) { const uint8_t* field, size_t field_size) {
@ -164,12 +260,13 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
ControllerProfileConfirmationPolicy::kRumbleAndLed; ControllerProfileConfirmationPolicy::kRumbleAndLed;
profile.switching_chord = 0; profile.switching_chord = 0;
profile.motion_toggle_chord = 0; profile.motion_toggle_chord = 0;
profile.macro_trigger_mask = 0; for (ControllerProfileMacro& macro : profile.macros) {
profile.macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; macro = {};
profile.macro_step_count = 1; macro.cancel_control = CONTROLLER_PROFILE_NO_BUTTON;
}
profile.macro_step_count = 0;
for (ControllerProfileMacroStep& step : profile.macro_steps) { for (ControllerProfileMacroStep& step : profile.macro_steps) {
step = {}; step = {};
step.type = ControllerProfileMacroStepType::kEnd;
} }
return profile; return profile;
} }
@ -207,27 +304,65 @@ bool controller_profile_validate(const ControllerProfile& profile) {
if (static_cast<uint8_t>(profile.confirmation_policy) > if (static_cast<uint8_t>(profile.confirmation_policy) >
static_cast<uint8_t>( static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::kRumbleAndLed) || ControllerProfileConfirmationPolicy::kRumbleAndLed) ||
!valid_control_output(profile.macro_cancel) ||
(profile.switching_chord & ~kLogicalControlMask) != 0 || (profile.switching_chord & ~kLogicalControlMask) != 0 ||
(profile.motion_toggle_chord & ~kLogicalControlMask) != 0 || (profile.motion_toggle_chord & ~kLogicalControlMask) != 0 ||
(profile.macro_trigger_mask & ~kLogicalControlMask) != 0 ||
profile.macro_step_count == 0 ||
profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) { profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
return false; return false;
} }
uint8_t expected_first_step = 0;
size_t encoded_macro_size = 0;
uint32_t trigger_masks[CONTROLLER_PROFILE_MACRO_COUNT]{};
for (uint8_t macro_index = 0;
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
const ControllerProfileMacro& macro =
profile.macros[macro_index];
if ((macro.trigger_mask & ~kLogicalControlMask) != 0 ||
!valid_control_output(macro.cancel_control) ||
macro.first_step != expected_first_step ||
macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
macro.step_count >
profile.macro_step_count - expected_first_step) {
return false;
}
for (uint8_t previous = 0; previous < macro_index; ++previous) {
if (macro.trigger_mask != 0 &&
trigger_masks[previous] == macro.trigger_mask) {
return false;
}
}
trigger_masks[macro_index] = macro.trigger_mask;
for (uint8_t step = 0; step < macro.step_count; ++step) {
const ControllerProfileMacroStep& value =
profile.macro_steps[expected_first_step + step];
if (!valid_macro_step(value)) {
return false;
}
encoded_macro_size += sparse_macro_step_size(value);
}
expected_first_step =
static_cast<uint8_t>(expected_first_step + macro.step_count);
}
if (expected_first_step != profile.macro_step_count ||
encoded_macro_size > CONTROLLER_PROFILE_MACRO_STREAM_SIZE) {
return false;
}
for (uint8_t index = profile.macro_step_count;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
const ControllerProfileMacroStep& step = profile.macro_steps[index];
if (step.override_flags != 0 || step.duration_ms != 0 ||
step.output_button_mask != 0 || step.left_stick_x != 0 ||
step.left_stick_y != 0 || step.right_stick_x != 0 ||
step.right_stick_y != 0 || step.left_trigger != 0 ||
step.right_trigger != 0) {
return false;
}
}
for (ControllerProfileTurboMode mode : profile.turbo_modes) { for (ControllerProfileTurboMode mode : profile.turbo_modes) {
if (static_cast<uint8_t>(mode) > if (static_cast<uint8_t>(mode) >
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) { static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
return false; return false;
} }
} }
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
const bool must_end = index >= profile.macro_step_count - 1;
if (!valid_macro_step(profile.macro_steps[index], must_end)) {
return false;
}
}
return true; return true;
} }
@ -269,37 +404,53 @@ bool controller_profile_encode(const ControllerProfile& profile,
output[74] = static_cast<uint8_t>(profile.confirmation_policy); output[74] = static_cast<uint8_t>(profile.confirmation_policy);
output[75] = static_cast<uint8_t>( output[75] = static_cast<uint8_t>(
((profile.switching_chord >> 16) & 0x03u) | ((profile.switching_chord >> 16) & 0x03u) |
(((profile.macro_trigger_mask >> 16) & 0x03u) << 2) |
(((profile.motion_toggle_chord >> 16) & 0x03u) << 4)); (((profile.motion_toggle_chord >> 16) & 0x03u) << 4));
profile_write_u16( profile_write_u16(
&output[76], static_cast<uint16_t>(profile.switching_chord)); &output[76], static_cast<uint16_t>(profile.switching_chord));
profile_write_u16( profile_write_u16(
&output[78], static_cast<uint16_t>(profile.macro_trigger_mask)); &output[78], static_cast<uint16_t>(profile.motion_toggle_chord));
output[80] = profile.macro_step_count;
output[81] = profile.macro_cancel;
profile_write_u16(
&output[98], static_cast<uint16_t>(profile.motion_toggle_chord));
for (uint8_t index = 0; for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
output[82 + index] = output[80 + index] =
static_cast<uint8_t>(profile.turbo_modes[index]); static_cast<uint8_t>(profile.turbo_modes[index]);
} }
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) { size_t stream_offset = 0;
const ControllerProfileMacroStep& step = profile.macro_steps[index]; for (uint8_t macro_index = 0;
uint8_t* encoded = &output[100 + index * 19]; macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
encoded[0] = static_cast<uint8_t>(step.type); const ControllerProfileMacro& macro =
encoded[1] = step.override_flags; profile.macros[macro_index];
profile_write_u16(&encoded[2], step.duration_ms); uint8_t* descriptor =
profile_write_u16(&encoded[4], step.output_button_mask); &output[96 + macro_index *
profile_write_i16(&encoded[6], step.left_stick_x); CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE];
profile_write_i16(&encoded[8], step.left_stick_y); profile_write_u16(
profile_write_i16(&encoded[10], step.right_stick_x); descriptor, static_cast<uint16_t>(macro.trigger_mask));
profile_write_i16(&encoded[12], step.right_stick_y); const uint8_t cancel =
profile_write_u16(&encoded[14], step.left_trigger); macro.cancel_control == CONTROLLER_PROFILE_NO_BUTTON
profile_write_u16(&encoded[16], step.right_trigger); ? 0x1fu
: macro.cancel_control;
descriptor[2] = static_cast<uint8_t>(
((macro.trigger_mask >> 16) & 0x03u) |
(cancel << 2));
descriptor[3] = static_cast<uint8_t>(stream_offset);
descriptor[4] = macro.step_count;
const size_t macro_start = stream_offset;
for (uint8_t step_index = 0;
step_index < macro.step_count; ++step_index) {
size_t encoded_size = 0;
if (!encode_sparse_macro_step(
profile.macro_steps[macro.first_step + step_index],
&output[120 + stream_offset],
CONTROLLER_PROFILE_MACRO_STREAM_SIZE - stream_offset,
&encoded_size)) {
return false;
}
stream_offset += encoded_size;
}
descriptor[5] =
static_cast<uint8_t>(stream_offset - macro_start);
} }
return true; return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE;
} }
bool controller_profile_decode(const uint8_t* input, size_t input_size, bool controller_profile_decode(const uint8_t* input, size_t input_size,
@ -309,27 +460,32 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
return false; return false;
} }
const uint16_t schema_version = profile_read_u16(&input[0]); const uint16_t schema_version = profile_read_u16(&input[0]);
const bool has_control_mapping =
schema_version >= CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION;
const bool has_action_trigger_bits =
schema_version == CONTROLLER_PROFILE_SCHEMA_VERSION;
const bool trigger_extension_valid =
has_control_mapping
? input[61] == 0 && input[71] == 0
: profile_bytes_are_zero(&input[60], 2) &&
profile_bytes_are_zero(&input[70], 2);
if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION || if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION ||
schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION || schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION ||
profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE || profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
!profile_bytes_are_zero(&input[31], 5) || !profile_bytes_are_zero(&input[31], 5) ||
!profile_bytes_are_zero(&input[47], 5) || !profile_bytes_are_zero(&input[47], 5)) {
!trigger_extension_valid || return false;
(has_action_trigger_bits ? (input[75] & 0xc0u) != 0 }
: input[75] != 0) || const bool has_control_mapping =
(!has_control_mapping && input[81] != 0) || schema_version >= CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION;
const bool has_action_controls =
schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION;
const bool sparse_macros =
schema_version == CONTROLLER_PROFILE_SCHEMA_VERSION;
if ((has_control_mapping
? input[61] != 0 || input[71] != 0
: !profile_bytes_are_zero(&input[60], 2) ||
!profile_bytes_are_zero(&input[70], 2)) ||
(sparse_macros
? (input[75] & 0xccu) != 0
: (has_action_controls ? (input[75] & 0xc0u) != 0
: input[75] != 0)) ||
(!has_control_mapping && (!has_control_mapping &&
!profile_bytes_are_zero(&input[98], 2)) || (input[81] != 0 ||
!profile_bytes_are_zero(&input[252], 4)) { !profile_bytes_are_zero(&input[98], 2))) ||
(!sparse_macros &&
!profile_bytes_are_zero(&input[252], 4))) {
return false; return false;
} }
@ -339,7 +495,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
for (uint8_t index = 0; index < 2; ++index) { for (uint8_t index = 0; index < 2; ++index) {
ControllerProfileStickConfiguration& stick = profile.sticks[index]; ControllerProfileStickConfiguration& stick = profile.sticks[index];
const uint8_t* encoded = &input[20 + index * 16]; const uint8_t* encoded = &input[20 + index * 16];
if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0) { if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0 ||
!profile_bytes_are_zero(&encoded[11], 5)) {
return false; return false;
} }
stick.center_x = profile_read_i16(&encoded[0]); stick.center_x = profile_read_i16(&encoded[0]);
@ -373,53 +530,147 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
profile.strong_rumble_scale = input[73]; profile.strong_rumble_scale = input[73];
profile.confirmation_policy = profile.confirmation_policy =
static_cast<ControllerProfileConfirmationPolicy>(input[74]); static_cast<ControllerProfileConfirmationPolicy>(input[74]);
profile.switching_chord = profile_read_u16(&input[76]);
profile.motion_toggle_chord = if (sparse_macros) {
has_control_mapping ? profile_read_u16(&input[98]) : 0; profile.switching_chord =
if (has_control_mapping) { profile_read_u16(&input[76]) |
profile.macro_trigger_mask = profile_read_u16(&input[78]); (static_cast<uint32_t>(input[75] & 0x03u) << 16);
profile.macro_cancel = input[81]; profile.motion_toggle_chord =
profile_read_u16(&input[78]) |
(static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16);
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
profile.turbo_modes[index] =
static_cast<ControllerProfileTurboMode>(input[80 + index]);
}
size_t expected_stream_offset = 0;
uint8_t decoded_step_count = 0;
for (uint8_t macro_index = 0;
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
const uint8_t* descriptor =
&input[96 + macro_index *
CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE];
if ((descriptor[2] & 0x80u) != 0 ||
descriptor[3] != expected_stream_offset ||
descriptor[4] >
CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
expected_stream_offset + descriptor[5] >
CONTROLLER_PROFILE_MACRO_STREAM_SIZE ||
decoded_step_count + descriptor[4] >
CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
return false;
}
ControllerProfileMacro& macro = profile.macros[macro_index];
macro.trigger_mask =
profile_read_u16(descriptor) |
(static_cast<uint32_t>(descriptor[2] & 0x03u) << 16);
const uint8_t cancel =
static_cast<uint8_t>((descriptor[2] >> 2) & 0x1fu);
if (cancel > CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
cancel != 0x1fu) {
return false;
}
macro.cancel_control =
cancel == 0x1fu ? CONTROLLER_PROFILE_NO_BUTTON : cancel;
macro.first_step = decoded_step_count;
macro.step_count = descriptor[4];
size_t consumed = 0;
for (uint8_t step_index = 0;
step_index < macro.step_count; ++step_index) {
size_t step_size = 0;
if (!decode_sparse_macro_step(
&input[120 + expected_stream_offset + consumed],
descriptor[5] - consumed,
&profile.macro_steps[decoded_step_count],
&step_size)) {
return false;
}
consumed += step_size;
++decoded_step_count;
}
if (consumed != descriptor[5]) {
return false;
}
expected_stream_offset += consumed;
}
if (!profile_bytes_are_zero(
&input[120 + expected_stream_offset],
CONTROLLER_PROFILE_MACRO_STREAM_SIZE -
expected_stream_offset)) {
return false;
}
profile.macro_step_count = decoded_step_count;
} else { } else {
if (!valid_button(input[78])) { profile.switching_chord = profile_read_u16(&input[76]);
profile.motion_toggle_chord =
has_control_mapping ? profile_read_u16(&input[98]) : 0;
ControllerProfileMacro& macro = profile.macros[0];
if (has_control_mapping) {
macro.trigger_mask = profile_read_u16(&input[78]);
macro.cancel_control = input[81];
} else {
if (!valid_button(input[78])) {
return false;
}
macro.trigger_mask =
input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
? static_cast<uint32_t>(1u << input[78])
: 0;
macro.cancel_control = input[79];
}
if (has_action_controls) {
profile.switching_chord |=
static_cast<uint32_t>(input[75] & 0x03u) << 16;
macro.trigger_mask |=
static_cast<uint32_t>((input[75] >> 2) & 0x03u) << 16;
profile.motion_toggle_chord |=
static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16;
}
const uint8_t legacy_step_count = input[80];
if (legacy_step_count == 0 ||
legacy_step_count >
CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY) {
return false; return false;
} }
profile.macro_trigger_mask = for (uint8_t index = 0;
input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT index < CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY;
? static_cast<uint32_t>(1u << input[78]) ++index) {
: 0; const uint8_t* encoded = &input[100 + index * 19];
profile.macro_cancel = input[79]; const bool must_end = index >= legacy_step_count - 1;
} if (must_end) {
if (has_action_trigger_bits) { if (encoded[0] != 1 ||
profile.switching_chord |= !profile_bytes_are_zero(&encoded[1], 18)) {
static_cast<uint32_t>(input[75] & 0x03u) << 16; return false;
profile.macro_trigger_mask |= }
static_cast<uint32_t>((input[75] >> 2) & 0x03u) << 16; continue;
profile.motion_toggle_chord |= }
static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16; if (encoded[0] != 0 || encoded[18] != 0) {
} return false;
profile.macro_step_count = input[80]; }
for (uint8_t index = 0; ControllerProfileMacroStep& step = profile.macro_steps[index];
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { step.override_flags = encoded[1];
profile.turbo_modes[index] = step.duration_ms = profile_read_u16(&encoded[2]);
static_cast<ControllerProfileTurboMode>(input[82 + index]); step.output_button_mask = profile_read_u16(&encoded[4]);
} step.left_stick_x = profile_read_i16(&encoded[6]);
for (uint8_t index = 0; step.left_stick_y = profile_read_i16(&encoded[8]);
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) { step.right_stick_x = profile_read_i16(&encoded[10]);
ControllerProfileMacroStep& step = profile.macro_steps[index]; step.right_stick_y = profile_read_i16(&encoded[12]);
const uint8_t* encoded = &input[100 + index * 19]; step.left_trigger = profile_read_u16(&encoded[14]);
if (encoded[18] != 0) { step.right_trigger = profile_read_u16(&encoded[16]);
return false; if (!valid_macro_step(step)) {
return false;
}
}
macro.first_step = 0;
macro.step_count = static_cast<uint8_t>(legacy_step_count - 1u);
profile.macro_step_count = macro.step_count;
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
profile.turbo_modes[index] =
static_cast<ControllerProfileTurboMode>(input[82 + index]);
} }
step.type = static_cast<ControllerProfileMacroStepType>(encoded[0]);
step.override_flags = encoded[1];
step.duration_ms = profile_read_u16(&encoded[2]);
step.output_button_mask = profile_read_u16(&encoded[4]);
step.left_stick_x = profile_read_i16(&encoded[6]);
step.left_stick_y = profile_read_i16(&encoded[8]);
step.right_stick_x = profile_read_i16(&encoded[10]);
step.right_stick_y = profile_read_i16(&encoded[12]);
step.left_trigger = profile_read_u16(&encoded[14]);
step.right_trigger = profile_read_u16(&encoded[16]);
} }
if (!controller_profile_validate(profile)) { if (!controller_profile_validate(profile)) {
return false; return false;

View file

@ -8,14 +8,20 @@
constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1; constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2; constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2;
constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3;
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 4; constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4;
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 5;
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256;
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18;
constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16;
constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17; constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17;
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8; constexpr uint8_t CONTROLLER_PROFILE_MACRO_COUNT = 4;
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 16;
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO = 8;
constexpr uint8_t CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY = 8;
constexpr size_t CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE = 6;
constexpr size_t CONTROLLER_PROFILE_MACRO_STREAM_SIZE = 136;
constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000; constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000;
constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff; constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff; constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff;
@ -74,10 +80,6 @@ enum class ControllerProfileTurboMode : uint8_t {
kAutoBurst = 2, kAutoBurst = 2,
}; };
enum class ControllerProfileMacroStepType : uint8_t {
kState = 0,
kEnd = 1,
};
enum ControllerProfileMacroOverride : uint8_t { enum ControllerProfileMacroOverride : uint8_t {
kControllerProfileOverrideButtons = 1u << 0, kControllerProfileOverrideButtons = 1u << 0,
@ -109,8 +111,6 @@ struct ControllerProfileTriggerConfiguration {
}; };
struct ControllerProfileMacroStep { struct ControllerProfileMacroStep {
ControllerProfileMacroStepType type =
ControllerProfileMacroStepType::kEnd;
uint8_t override_flags = 0; uint8_t override_flags = 0;
uint16_t duration_ms = 0; uint16_t duration_ms = 0;
uint16_t output_button_mask = 0; uint16_t output_button_mask = 0;
@ -122,6 +122,13 @@ struct ControllerProfileMacroStep {
uint16_t right_trigger = 0; uint16_t right_trigger = 0;
}; };
struct ControllerProfileMacro {
uint32_t trigger_mask = 0;
uint8_t cancel_control = CONTROLLER_PROFILE_NO_BUTTON;
uint8_t first_step = 0;
uint8_t step_count = 0;
};
struct ControllerProfile { struct ControllerProfile {
uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
ControllerProfileStickConfiguration sticks[2]{}; ControllerProfileStickConfiguration sticks[2]{};
@ -132,9 +139,8 @@ struct ControllerProfile {
ControllerProfileConfirmationPolicy::kRumbleAndLed; ControllerProfileConfirmationPolicy::kRumbleAndLed;
uint32_t switching_chord = 0; uint32_t switching_chord = 0;
uint32_t motion_toggle_chord = 0; uint32_t motion_toggle_chord = 0;
uint32_t macro_trigger_mask = 0; ControllerProfileMacro macros[CONTROLLER_PROFILE_MACRO_COUNT]{};
uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; uint8_t macro_step_count = 0;
uint8_t macro_step_count = 1;
ControllerProfileTurboMode ControllerProfileTurboMode
turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
ControllerProfileMacroStep ControllerProfileMacroStep

View file

@ -52,22 +52,27 @@ bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) {
void stop_macro(ControllerSyntheticInputContext* context) { void stop_macro(ControllerSyntheticInputContext* context) {
context->macro_active = false; context->macro_active = false;
context->macro_index = 0;
context->macro_step_index = 0; context->macro_step_index = 0;
context->macro_deadline_ms = 0; context->macro_deadline_ms = 0;
} }
bool start_macro(ControllerSyntheticInputContext* context, bool start_macro(ControllerSyntheticInputContext* context,
const ControllerProfile& profile, uint32_t now_ms) { const ControllerProfile& profile, uint8_t macro_index,
if (profile.macro_step_count == 0 || uint32_t now_ms) {
profile.macro_steps[0].type != if (macro_index >= CONTROLLER_PROFILE_MACRO_COUNT) {
ControllerProfileMacroStepType::kState) { return false;
stop_macro(context); }
const ControllerProfileMacro& macro = profile.macros[macro_index];
if (macro.step_count == 0 ||
macro.first_step >= profile.macro_step_count) {
return false; return false;
} }
context->macro_active = true; context->macro_active = true;
context->macro_index = macro_index;
context->macro_step_index = 0; context->macro_step_index = 0;
context->macro_deadline_ms = context->macro_deadline_ms =
now_ms + profile.macro_steps[0].duration_ms; now_ms + profile.macro_steps[macro.first_step].duration_ms;
return true; return true;
} }
@ -77,31 +82,28 @@ void advance_macro(ControllerSyntheticInputContext* context,
transition < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; transition < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY;
++transition) { ++transition) {
if (!context->macro_active || if (!context->macro_active ||
context->macro_step_index >= profile.macro_step_count) { context->macro_index >= CONTROLLER_PROFILE_MACRO_COUNT) {
stop_macro(context); stop_macro(context);
return; return;
} }
const ControllerProfileMacroStep& current = const ControllerProfileMacro& macro =
profile.macro_steps[context->macro_step_index]; profile.macros[context->macro_index];
if (current.type != ControllerProfileMacroStepType::kState) { if (context->macro_step_index >= macro.step_count) {
stop_macro(context); stop_macro(context);
return; return;
} }
if (!deadline_reached(now_ms, context->macro_deadline_ms)) { if (!deadline_reached(now_ms, context->macro_deadline_ms)) {
return; return;
} }
const uint8_t next_index = const uint8_t next_index =
static_cast<uint8_t>(context->macro_step_index + 1u); static_cast<uint8_t>(context->macro_step_index + 1u);
if (next_index >= profile.macro_step_count || if (next_index >= macro.step_count) {
profile.macro_steps[next_index].type ==
ControllerProfileMacroStepType::kEnd) {
stop_macro(context); stop_macro(context);
return; return;
} }
context->macro_step_index = next_index; context->macro_step_index = next_index;
context->macro_deadline_ms += context->macro_deadline_ms +=
profile.macro_steps[next_index].duration_ms; profile.macro_steps[macro.first_step + next_index].duration_ms;
} }
} }
@ -150,31 +152,48 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
controller_profile_extract_control_mask(input, profile); controller_profile_extract_control_mask(input, profile);
uint32_t rising_control_mask = uint32_t rising_control_mask =
input_control_mask & ~context->previous_input_control_mask; input_control_mask & ~context->previous_input_control_mask;
const bool cancel_pressed = bool cancel_pressed = false;
is_bound_control(profile.macro_cancel) && uint32_t consumed_controls = 0;
(input_control_mask & control_bit(profile.macro_cancel)) != 0; for (const ControllerProfileMacro& macro : profile.macros) {
if (is_bound_control(macro.cancel_control) &&
(input_control_mask & control_bit(macro.cancel_control)) != 0) {
cancel_pressed = true;
consumed_controls |= control_bit(macro.cancel_control);
break;
}
}
if (cancel_pressed) { if (cancel_pressed) {
controller_synthetic_input_cancel(context, input_control_mask); controller_synthetic_input_cancel(context, input_control_mask);
rising_control_mask = 0; rising_control_mask = 0;
} }
bool macro_started = false; bool macro_started = false;
const bool trigger_chord_completed = if (!cancel_pressed && !context->macro_active) {
profile.macro_trigger_mask != 0 && for (uint8_t macro_index = 0;
(input_control_mask & profile.macro_trigger_mask) == macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
profile.macro_trigger_mask && const ControllerProfileMacro& macro =
(rising_control_mask & profile.macro_trigger_mask) != 0; profile.macros[macro_index];
if (!cancel_pressed && trigger_chord_completed) { const bool completed =
macro_started = start_macro(context, profile, now_ms); macro.trigger_mask != 0 && macro.step_count != 0 &&
(input_control_mask & macro.trigger_mask) ==
macro.trigger_mask &&
(rising_control_mask & macro.trigger_mask) != 0;
if (completed &&
start_macro(context, profile, macro_index, now_ms)) {
macro_started = true;
consumed_controls |= macro.trigger_mask;
break;
}
}
} }
if (!macro_started) { if (!macro_started) {
advance_macro(context, profile, now_ms); advance_macro(context, profile, now_ms);
} }
const bool consume_macro_trigger = if (context->macro_active &&
context->macro_active || context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) {
(profile.macro_trigger_mask != 0 && consumed_controls |=
(input_control_mask & profile.macro_trigger_mask) == profile.macros[context->macro_index].trigger_mask;
profile.macro_trigger_mask); }
uint16_t gated_input_button_mask = 0; uint16_t gated_input_button_mask = 0;
for (uint8_t input_button = 0; for (uint8_t input_button = 0;
@ -183,9 +202,7 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
ControllerSyntheticBindingState& binding = ControllerSyntheticBindingState& binding =
context->bindings[input_button]; context->bindings[input_button];
const uint16_t bit = button_bit(input_button); const uint16_t bit = button_bit(input_button);
if ((consume_macro_trigger && if ((consumed_controls & bit) != 0) {
(profile.macro_trigger_mask & bit) != 0) ||
input_button == profile.macro_cancel) {
clear_binding(&binding); clear_binding(&binding);
continue; continue;
} }
@ -232,22 +249,20 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
ControllerState gated_input = input; ControllerState gated_input = input;
controller_profile_apply_button_mask(gated_input_button_mask, controller_profile_apply_button_mask(gated_input_button_mask,
&gated_input); &gated_input);
uint32_t consumed_controls =
consume_macro_trigger ? profile.macro_trigger_mask : 0;
if (cancel_pressed) {
consumed_controls |= control_bit(profile.macro_cancel);
}
controller_profile_remove_control_mask( controller_profile_remove_control_mask(
consumed_controls, &gated_input); consumed_controls, &gated_input);
ControllerProfileTransformResult result = ControllerProfileTransformResult result =
controller_profile_transform(gated_input, profile); controller_profile_transform(gated_input, profile);
if (context->macro_active && if (context->macro_active &&
context->macro_step_index < profile.macro_step_count) { context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) {
const ControllerProfileMacroStep& step = const ControllerProfileMacro& macro =
profile.macro_steps[context->macro_step_index]; profile.macros[context->macro_index];
if (step.type == ControllerProfileMacroStepType::kState) { if (context->macro_step_index < macro.step_count) {
apply_macro_override(step, &result.state); apply_macro_override(
profile.macro_steps[
macro.first_step + context->macro_step_index],
&result.state);
} else { } else {
stop_macro(context); stop_macro(context);
} }

View file

@ -13,6 +13,7 @@ struct ControllerSyntheticBindingState {
struct ControllerSyntheticInputContext { struct ControllerSyntheticInputContext {
bool macro_active = false; bool macro_active = false;
uint8_t macro_index = 0;
uint8_t macro_step_index = 0; uint8_t macro_step_index = 0;
uint32_t macro_deadline_ms = 0; uint32_t macro_deadline_ms = 0;
uint32_t previous_input_control_mask = 0; uint32_t previous_input_control_mask = 0;

View file

@ -102,7 +102,8 @@ TRANSPORT_BLE = 2
PROFILE_LEGACY_SCHEMA_VERSION = 1 PROFILE_LEGACY_SCHEMA_VERSION = 1
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3
PROFILE_SCHEMA_VERSION = 4 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4
PROFILE_SCHEMA_VERSION = 5
PROFILE_SIZE = 256 PROFILE_SIZE = 256
PROFILE_CAPACITY = 4 PROFILE_CAPACITY = 4
PROFILE_IDENTITY_CAPACITY = 16 PROFILE_IDENTITY_CAPACITY = 16
@ -110,7 +111,11 @@ PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1
CONTROLLER_IDENTITY_SIZE = 14 CONTROLLER_IDENTITY_SIZE = 14
PROFILE_LIST_ROW_SIZE = 16 PROFILE_LIST_ROW_SIZE = 16
PROFILE_NONE_BUTTON = 0xFF PROFILE_NONE_BUTTON = 0xFF
PROFILE_MACRO_STEP_CAPACITY = 8 PROFILE_MACRO_COUNT = 4
PROFILE_MACRO_STEP_CAPACITY = 16
PROFILE_MACRO_STEPS_PER_MACRO = 8
PROFILE_LEGACY_MACRO_STEP_CAPACITY = 8
PROFILE_MACRO_STREAM_SIZE = 136
PROFILE_MACRO_STEP_SIZE = 19 PROFILE_MACRO_STEP_SIZE = 19
PROFILE_MAXIMUM_WAIT_MS = 10000 PROFILE_MAXIMUM_WAIT_MS = 10000
PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000
@ -856,6 +861,63 @@ class MacroStep:
0, 0,
) )
def to_sparse_bytes(self) -> bytes:
if self.step_type != 0:
raise ConfigManagerError("only state steps use sparse encoding")
payload = bytearray(struct.pack("<BH", self.override_flags, self.duration_ms))
if self.override_flags & 1:
payload.extend(struct.pack("<H", self.output_button_mask))
if self.override_flags & 2:
payload.extend(struct.pack("<hh", self.left_stick_x, self.left_stick_y))
if self.override_flags & 4:
payload.extend(struct.pack("<hh", self.right_stick_x, self.right_stick_y))
if self.override_flags & 8:
payload.extend(struct.pack("<H", self.left_trigger))
if self.override_flags & 16:
payload.extend(struct.pack("<H", self.right_trigger))
return bytes(payload)
@classmethod
def from_sparse_bytes(
cls, payload: bytes, name: str
) -> tuple[MacroStep, int]:
if len(payload) < 3:
raise ConfigManagerError(f"{name} is truncated")
flags, duration = struct.unpack_from("<BH", payload)
if flags & ~MACRO_OVERRIDE_MASK:
raise ConfigManagerError(f"{name} has invalid override flags")
offset = 3
def take(fmt: str) -> tuple[int, ...]:
nonlocal offset
size = struct.calcsize(fmt)
if offset + size > len(payload):
raise ConfigManagerError(f"{name} is truncated")
values = struct.unpack_from(fmt, payload, offset)
offset += size
return values
buttons = take("<H")[0] if flags & 1 else 0
left_x, left_y = take("<hh") if flags & 2 else (0, 0)
right_x, right_y = take("<hh") if flags & 4 else (0, 0)
left_trigger = take("<H")[0] if flags & 8 else 0
right_trigger = take("<H")[0] if flags & 16 else 0
return (
cls(
0,
flags,
duration,
buttons,
left_x,
left_y,
right_x,
right_y,
left_trigger,
right_trigger,
),
offset,
)
def to_json_object(self) -> dict[str, Any]: def to_json_object(self) -> dict[str, Any]:
return { return {
"type": MACRO_STEP_TYPES[self.step_type], "type": MACRO_STEP_TYPES[self.step_type],
@ -956,6 +1018,67 @@ class MacroStep:
) )
@dataclass(frozen=True)
class ControllerMacro:
trigger_mask: int
cancel_control: int
steps: tuple[MacroStep, ...]
def __post_init__(self) -> None:
_require_int(
self.trigger_mask,
"macro trigger chord",
0,
PROFILE_LOGICAL_CONTROL_MASK,
)
if type(self.cancel_control) is not int or (
self.cancel_control != PROFILE_NONE_BUTTON
and not 0 <= self.cancel_control < len(LOGICAL_CONTROLS)
):
raise ConfigManagerError("invalid macro cancel control")
if (
type(self.steps) is not tuple
or len(self.steps) > PROFILE_MACRO_STEPS_PER_MACRO
or not all(
isinstance(step, MacroStep) and step.step_type == 0
for step in self.steps
)
):
raise ConfigManagerError(
"macro must contain zero to eight state steps"
)
@classmethod
def empty(cls) -> ControllerMacro:
return cls(0, PROFILE_NONE_BUTTON, ())
def to_json_object(self) -> dict[str, Any]:
return {
"trigger": _control_mask_to_json(self.trigger_mask),
"cancel": _control_name(self.cancel_control),
"steps": [step.to_json_object() for step in self.steps],
}
@classmethod
def from_json_object(cls, value: Any, name: str) -> ControllerMacro:
obj = _require_object(
value, ("trigger", "cancel", "steps"), name
)
steps = obj["steps"]
if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO:
raise ConfigManagerError(
f"{name}.steps must contain zero to eight steps"
)
return cls(
_control_mask_from_json(obj["trigger"], f"{name}.trigger"),
_control_index(obj["cancel"], f"{name}.cancel"),
tuple(
MacroStep.from_json_object(step, f"{name}.steps[{index}]")
for index, step in enumerate(steps)
),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ControllerProfile: class ControllerProfile:
button_map: tuple[int, ...] button_map: tuple[int, ...]
@ -968,9 +1091,7 @@ class ControllerProfile:
confirmation_policy: int confirmation_policy: int
switching_chord: int switching_chord: int
motion_toggle_chord: int motion_toggle_chord: int
macro_trigger_mask: int macros: tuple[ControllerMacro, ...]
macro_cancel: int
macro_steps: tuple[MacroStep, ...]
turbo_modes: tuple[int, ...] turbo_modes: tuple[int, ...]
def __post_init__(self) -> None: def __post_init__(self) -> None:
@ -1029,27 +1150,33 @@ class ControllerProfile:
0, 0,
PROFILE_LOGICAL_CONTROL_MASK, PROFILE_LOGICAL_CONTROL_MASK,
) )
_require_int(
self.macro_trigger_mask,
"macro trigger chord",
0,
PROFILE_LOGICAL_CONTROL_MASK,
)
if type(self.macro_cancel) is not int or (
self.macro_cancel != PROFILE_NONE_BUTTON
and not 0 <= self.macro_cancel < len(LOGICAL_CONTROLS)
):
raise ConfigManagerError("invalid macro cancel")
if ( if (
type(self.macro_steps) is not tuple type(self.macros) is not tuple
or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY or len(self.macros) != PROFILE_MACRO_COUNT
or not all(isinstance(step, MacroStep) for step in self.macro_steps) or not all(isinstance(macro, ControllerMacro) for macro in self.macros)
): ):
raise ConfigManagerError("macro must contain one to eight steps") raise ConfigManagerError("profile must contain four macros")
if any(step.step_type != 0 for step in self.macro_steps[:-1]): total_steps = sum(len(macro.steps) for macro in self.macros)
raise ConfigManagerError("only the final macro step may be end") if total_steps > PROFILE_MACRO_STEP_CAPACITY:
if self.macro_steps[-1] != MacroStep.end(): raise ConfigManagerError(
raise ConfigManagerError("final macro step must be canonical end") "profile macros exceed the sixteen-step shared pool"
)
encoded_size = sum(
len(step.to_sparse_bytes())
for macro in self.macros
for step in macro.steps
)
if encoded_size > PROFILE_MACRO_STREAM_SIZE:
raise ConfigManagerError(
"profile macros exceed the 136-byte sparse stream"
)
triggers = [
macro.trigger_mask
for macro in self.macros
if macro.trigger_mask != 0
]
if len(triggers) != len(set(triggers)):
raise ConfigManagerError("macro trigger chords must be unique")
if type(self.turbo_modes) is not tuple or len( if type(self.turbo_modes) is not tuple or len(
self.turbo_modes self.turbo_modes
) != len(LOGICAL_BUTTONS): ) != len(LOGICAL_BUTTONS):
@ -1085,9 +1212,9 @@ class ControllerProfile:
confirmation_policy=3, confirmation_policy=3,
switching_chord=0, switching_chord=0,
motion_toggle_chord=0, motion_toggle_chord=0,
macro_trigger_mask=0, macros=tuple(
macro_cancel=PROFILE_NONE_BUTTON, ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT)
macro_steps=(MacroStep.end(),), ),
turbo_modes=(0,) * len(LOGICAL_BUTTONS), turbo_modes=(0,) * len(LOGICAL_BUTTONS),
) )
@ -1106,35 +1233,25 @@ class ControllerProfile:
has_control_mapping = ( has_control_mapping = (
version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
) )
has_action_trigger_bits = version == PROFILE_SCHEMA_VERSION has_action_controls = (
if payload[252:] != bytes(4): version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION
raise ConfigManagerError("profile reserved fields must be zero") )
if has_action_trigger_bits: sparse_macros = version == PROFILE_SCHEMA_VERSION
if sparse_macros:
if payload[75] & 0xCC:
raise ConfigManagerError("profile action flags are invalid")
elif has_action_controls:
if payload[75] & 0xC0: if payload[75] & 0xC0:
raise ConfigManagerError("profile action flags are invalid") raise ConfigManagerError("profile action flags are invalid")
elif payload[75] != 0: if payload[252:] != bytes(4):
raise ConfigManagerError("profile reserved fields must be zero")
elif payload[75] != 0 or payload[252:] != bytes(4):
raise ConfigManagerError("legacy profile reserved fields must be zero") raise ConfigManagerError("legacy profile reserved fields must be zero")
if not has_control_mapping and ( if not has_control_mapping and (
payload[81] != 0 or payload[98:100] != b"\x00\x00" payload[81] != 0 or payload[98:100] != b"\x00\x00"
): ):
raise ConfigManagerError("legacy profile reserved fields must be zero") raise ConfigManagerError("legacy profile reserved fields must be zero")
macro_count = payload[80]
if not 1 <= macro_count <= PROFILE_MACRO_STEP_CAPACITY:
raise ConfigManagerError("invalid macro step count")
all_steps = tuple(
MacroStep.from_bytes(
payload[
100
+ index * PROFILE_MACRO_STEP_SIZE : 100
+ (index + 1) * PROFILE_MACRO_STEP_SIZE
]
)
for index in range(PROFILE_MACRO_STEP_CAPACITY)
)
if any(
step != MacroStep.end() for step in all_steps[macro_count:]
):
raise ConfigManagerError("unused macro steps must be canonical end")
left_trigger = TriggerConfig.from_bytes( left_trigger = TriggerConfig.from_bytes(
payload[52:62], schema_version=version, source_index=0 payload[52:62], schema_version=version, source_index=0
) )
@ -1144,29 +1261,116 @@ class ControllerProfile:
if version == PROFILE_LEGACY_SCHEMA_VERSION: if version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_trigger) left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
right_trigger = _migrate_legacy_trigger_threshold(right_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
if has_control_mapping:
macro_trigger_mask = struct.unpack_from("<H", payload, 78)[0] if sparse_macros:
macro_cancel = payload[81] switching_chord = (
motion_toggle_chord = struct.unpack_from("<H", payload, 98)[0] struct.unpack_from("<H", payload, 76)[0]
else: | ((payload[75] & 0x03) << 16)
legacy_trigger = payload[78]
if legacy_trigger != PROFILE_NONE_BUTTON and not (
0 <= legacy_trigger < len(LOGICAL_BUTTONS)
):
raise ConfigManagerError("invalid legacy macro trigger")
macro_trigger_mask = (
0
if legacy_trigger == PROFILE_NONE_BUTTON
else 1 << legacy_trigger
) )
macro_cancel = payload[79] motion_toggle_chord = (
motion_toggle_chord = 0 struct.unpack_from("<H", payload, 78)[0]
switching_chord = struct.unpack_from("<H", payload, 76)[0] | (((payload[75] >> 4) & 0x03) << 16)
if has_action_trigger_bits: )
action_bits = payload[75] turbo_modes = tuple(payload[80:96])
switching_chord |= (action_bits & 0x03) << 16 macros: list[ControllerMacro] = []
macro_trigger_mask |= ((action_bits >> 2) & 0x03) << 16 stream_offset = 0
motion_toggle_chord |= ((action_bits >> 4) & 0x03) << 16 total_steps = 0
for macro_index in range(PROFILE_MACRO_COUNT):
offset = 96 + macro_index * 6
descriptor = payload[offset : offset + 6]
if descriptor[2] & 0x80 or descriptor[3] != stream_offset:
raise ConfigManagerError("invalid sparse macro descriptor")
trigger_mask = (
struct.unpack_from("<H", descriptor)[0]
| ((descriptor[2] & 0x03) << 16)
)
cancel = (descriptor[2] >> 2) & 0x1F
if cancel > len(LOGICAL_CONTROLS) - 1 and cancel != 0x1F:
raise ConfigManagerError("invalid sparse macro cancel control")
step_count = descriptor[4]
encoded_size = descriptor[5]
if (
step_count > PROFILE_MACRO_STEPS_PER_MACRO
or total_steps + step_count > PROFILE_MACRO_STEP_CAPACITY
or stream_offset + encoded_size > PROFILE_MACRO_STREAM_SIZE
):
raise ConfigManagerError("invalid sparse macro bounds")
consumed = 0
steps: list[MacroStep] = []
for step_index in range(step_count):
step, step_size = MacroStep.from_sparse_bytes(
payload[
120 + stream_offset + consumed :
120 + stream_offset + encoded_size
],
f"profile.macros[{macro_index}].steps[{step_index}]",
)
steps.append(step)
consumed += step_size
if consumed != encoded_size:
raise ConfigManagerError("invalid sparse macro size")
macros.append(
ControllerMacro(
trigger_mask,
PROFILE_NONE_BUTTON if cancel == 0x1F else cancel,
tuple(steps),
)
)
stream_offset += consumed
total_steps += step_count
if payload[120 + stream_offset :] != bytes(
PROFILE_MACRO_STREAM_SIZE - stream_offset
):
raise ConfigManagerError("nonzero sparse macro padding")
else:
switching_chord = struct.unpack_from("<H", payload, 76)[0]
motion_toggle_chord = (
struct.unpack_from("<H", payload, 98)[0]
if has_control_mapping
else 0
)
if has_control_mapping:
trigger_mask = struct.unpack_from("<H", payload, 78)[0]
cancel_control = payload[81]
else:
legacy_trigger = payload[78]
if legacy_trigger != PROFILE_NONE_BUTTON and not (
0 <= legacy_trigger < len(LOGICAL_BUTTONS)
):
raise ConfigManagerError("invalid legacy macro trigger")
trigger_mask = (
0
if legacy_trigger == PROFILE_NONE_BUTTON
else 1 << legacy_trigger
)
cancel_control = payload[79]
if has_action_controls:
switching_chord |= (payload[75] & 0x03) << 16
trigger_mask |= ((payload[75] >> 2) & 0x03) << 16
motion_toggle_chord |= ((payload[75] >> 4) & 0x03) << 16
legacy_count = payload[80]
if not 1 <= legacy_count <= PROFILE_LEGACY_MACRO_STEP_CAPACITY:
raise ConfigManagerError("invalid legacy macro step count")
legacy_steps = tuple(
MacroStep.from_bytes(
payload[
100 + index * PROFILE_MACRO_STEP_SIZE :
100 + (index + 1) * PROFILE_MACRO_STEP_SIZE
]
)
for index in range(PROFILE_LEGACY_MACRO_STEP_CAPACITY)
)
if any(step != MacroStep.end() for step in legacy_steps[legacy_count - 1 :]):
raise ConfigManagerError("invalid legacy macro end padding")
state_steps = legacy_steps[: legacy_count - 1]
if any(step.step_type != 0 for step in state_steps):
raise ConfigManagerError("invalid legacy macro state step")
macros = [
ControllerMacro(trigger_mask, cancel_control, state_steps),
*(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT - 1)),
]
turbo_modes = tuple(payload[82:98])
return cls( return cls(
button_map=tuple(payload[4:20]), button_map=tuple(payload[4:20]),
left_stick=StickConfig.from_bytes(payload[20:36]), left_stick=StickConfig.from_bytes(payload[20:36]),
@ -1178,12 +1382,9 @@ class ControllerProfile:
confirmation_policy=payload[74], confirmation_policy=payload[74],
switching_chord=switching_chord, switching_chord=switching_chord,
motion_toggle_chord=motion_toggle_chord, motion_toggle_chord=motion_toggle_chord,
macro_trigger_mask=macro_trigger_mask, macros=tuple(macros),
macro_cancel=macro_cancel, turbo_modes=turbo_modes,
macro_steps=all_steps[:macro_count],
turbo_modes=tuple(payload[82:98]),
) )
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
payload = bytearray(PROFILE_SIZE) payload = bytearray(PROFILE_SIZE)
struct.pack_into( struct.pack_into(
@ -1203,23 +1404,38 @@ class ControllerProfile:
) )
payload[75] = ( payload[75] = (
((self.switching_chord >> 16) & 0x03) ((self.switching_chord >> 16) & 0x03)
| (((self.macro_trigger_mask >> 16) & 0x03) << 2)
| (((self.motion_toggle_chord >> 16) & 0x03) << 4) | (((self.motion_toggle_chord >> 16) & 0x03) << 4)
) )
struct.pack_into("<H", payload, 76, self.switching_chord & 0xFFFF) struct.pack_into("<H", payload, 76, self.switching_chord & 0xFFFF)
struct.pack_into("<H", payload, 78, self.macro_trigger_mask & 0xFFFF) struct.pack_into("<H", payload, 78, self.motion_toggle_chord & 0xFFFF)
payload[80] = len(self.macro_steps) payload[80:96] = bytes(self.turbo_modes)
payload[81] = self.macro_cancel
payload[82:98] = bytes(self.turbo_modes) stream = bytearray()
struct.pack_into("<H", payload, 98, self.motion_toggle_chord & 0xFFFF) for macro_index, macro in enumerate(self.macros):
for index in range(PROFILE_MACRO_STEP_CAPACITY): encoded_steps = b"".join(
step = ( step.to_sparse_bytes() for step in macro.steps
self.macro_steps[index]
if index < len(self.macro_steps)
else MacroStep.end()
) )
offset = 100 + index * PROFILE_MACRO_STEP_SIZE descriptor_offset = 96 + macro_index * 6
payload[offset : offset + PROFILE_MACRO_STEP_SIZE] = step.to_bytes() struct.pack_into(
"<H", payload, descriptor_offset, macro.trigger_mask & 0xFFFF
)
cancel = (
0x1F
if macro.cancel_control == PROFILE_NONE_BUTTON
else macro.cancel_control
)
payload[descriptor_offset + 2] = (
((macro.trigger_mask >> 16) & 0x03) | (cancel << 2)
)
payload[descriptor_offset + 3] = len(stream)
payload[descriptor_offset + 4] = len(macro.steps)
payload[descriptor_offset + 5] = len(encoded_steps)
stream.extend(encoded_steps)
if len(stream) > PROFILE_MACRO_STREAM_SIZE:
raise ConfigManagerError(
"profile macros exceed the 136-byte sparse stream"
)
payload[120 : 120 + len(stream)] = stream
return bytes(payload) return bytes(payload)
def to_json_object(self) -> dict[str, Any]: def to_json_object(self) -> dict[str, Any]:
@ -1249,13 +1465,9 @@ class ControllerProfile:
"motion_toggle_chord": _control_mask_to_json( "motion_toggle_chord": _control_mask_to_json(
self.motion_toggle_chord self.motion_toggle_chord
), ),
"macro": { "macros": [
"trigger": _control_mask_to_json(self.macro_trigger_mask), macro.to_json_object() for macro in self.macros
"cancel": _control_name(self.macro_cancel), ],
"steps": [
step.to_json_object() for step in self.macro_steps
],
},
"turbo": { "turbo": {
name: TURBO_MODES[self.turbo_modes[index]] name: TURBO_MODES[self.turbo_modes[index]]
for index, name in enumerate(LOGICAL_BUTTONS) for index, name in enumerate(LOGICAL_BUTTONS)
@ -1280,7 +1492,7 @@ class ControllerProfile:
or schema_version > PROFILE_SCHEMA_VERSION or schema_version > PROFILE_SCHEMA_VERSION
): ):
raise ConfigManagerError("unsupported profile schema") raise ConfigManagerError("unsupported profile schema")
fields = ( fields = [
"schema_version", "schema_version",
"size", "size",
"button_map", "button_map",
@ -1288,11 +1500,13 @@ class ControllerProfile:
"triggers", "triggers",
"rumble", "rumble",
"switching_chord", "switching_chord",
"macro",
"turbo", "turbo",
) ]
if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
fields += ("motion_toggle_chord",) fields.append("motion_toggle_chord")
fields.append(
"macros" if schema_version == PROFILE_SCHEMA_VERSION else "macro"
)
obj = _require_object(value, fields, "profile") obj = _require_object(value, fields, "profile")
if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE: if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE:
raise ConfigManagerError("unsupported profile schema") raise ConfigManagerError("unsupported profile schema")
@ -1310,19 +1524,9 @@ class ControllerProfile:
("weak_scale", "strong_scale", "confirmation_policy"), ("weak_scale", "strong_scale", "confirmation_policy"),
"profile.rumble", "profile.rumble",
) )
macro = _require_object(
obj["macro"], ("trigger", "cancel", "steps"), "profile.macro"
)
turbo = _require_object( turbo = _require_object(
obj["turbo"], LOGICAL_BUTTONS, "profile.turbo" obj["turbo"], LOGICAL_BUTTONS, "profile.turbo"
) )
steps = macro["steps"]
if type(steps) is not list or not (
1 <= len(steps) <= PROFILE_MACRO_STEP_CAPACITY
):
raise ConfigManagerError(
"profile.macro.steps must contain one to eight steps"
)
left_trigger = TriggerConfig.from_json_object( left_trigger = TriggerConfig.from_json_object(
triggers["left"], triggers["left"],
"profile.triggers.left", "profile.triggers.left",
@ -1338,55 +1542,93 @@ class ControllerProfile:
if schema_version == PROFILE_LEGACY_SCHEMA_VERSION: if schema_version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_trigger) left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
right_trigger = _migrate_legacy_trigger_threshold(right_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
mask_parser = (
_control_mask_from_json
if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION
else _button_mask_from_json
)
switching_chord = mask_parser(
obj["switching_chord"], "profile.switching_chord"
)
motion_toggle_chord = (
mask_parser(
obj["motion_toggle_chord"],
"profile.motion_toggle_chord",
)
if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
else 0
)
if schema_version == PROFILE_SCHEMA_VERSION: if schema_version == PROFILE_SCHEMA_VERSION:
switching_chord = _control_mask_from_json( macro_values = obj["macros"]
obj["switching_chord"], "profile.switching_chord" if type(macro_values) is not list or len(macro_values) != PROFILE_MACRO_COUNT:
) raise ConfigManagerError("profile.macros must contain four macros")
motion_toggle_chord = _control_mask_from_json( macros = tuple(
obj["motion_toggle_chord"], ControllerMacro.from_json_object(
"profile.motion_toggle_chord", macro, f"profile.macros[{index}]"
) )
macro_trigger_mask = _control_mask_from_json( for index, macro in enumerate(macro_values)
macro["trigger"], "profile.macro.trigger"
)
macro_cancel = _control_index(
macro["cancel"], "profile.macro.cancel"
)
elif schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
switching_chord = _button_mask_from_json(
obj["switching_chord"], "profile.switching_chord"
)
motion_toggle_chord = _button_mask_from_json(
obj["motion_toggle_chord"],
"profile.motion_toggle_chord",
)
macro_trigger_mask = _button_mask_from_json(
macro["trigger"], "profile.macro.trigger"
)
macro_cancel = _button_index(
macro["cancel"], "profile.macro.cancel"
) )
else: else:
switching_chord = _button_mask_from_json( macro = _require_object(
obj["switching_chord"], "profile.switching_chord" obj["macro"], ("trigger", "cancel", "steps"), "profile.macro"
) )
motion_toggle_chord = 0 steps = macro["steps"]
legacy_trigger = _button_index( if type(steps) is not list or not (
macro["trigger"], "profile.macro.trigger" 1 <= len(steps) <= PROFILE_LEGACY_MACRO_STEP_CAPACITY
):
raise ConfigManagerError(
"profile.macro.steps must contain one to eight steps"
)
decoded_steps = tuple(
MacroStep.from_json_object(
step, f"profile.macro.steps[{index}]"
)
for index, step in enumerate(steps)
) )
macro_trigger_mask = ( if decoded_steps[-1] != MacroStep.end() or any(
0 step.step_type != 0 for step in decoded_steps[:-1]
if legacy_trigger == PROFILE_NONE_BUTTON ):
else 1 << legacy_trigger raise ConfigManagerError(
) "legacy macro must end with one canonical end step"
macro_cancel = _button_index( )
macro["cancel"], "profile.macro.cancel" if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
trigger_mask = mask_parser(
macro["trigger"], "profile.macro.trigger"
)
cancel_control = (
_control_index(
macro["cancel"], "profile.macro.cancel"
)
if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION
else _button_index(
macro["cancel"], "profile.macro.cancel"
)
)
else:
trigger = _button_index(
macro["trigger"], "profile.macro.trigger"
)
trigger_mask = (
0 if trigger == PROFILE_NONE_BUTTON else 1 << trigger
)
cancel_control = _button_index(
macro["cancel"], "profile.macro.cancel"
)
macros = (
ControllerMacro(
trigger_mask, cancel_control, decoded_steps[:-1]
),
*(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT - 1)),
) )
return cls( return cls(
button_map=tuple( button_map=tuple(
_control_index( (
button_map[name], f"profile.button_map.{name}" _control_index
) if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
else _button_index
)(button_map[name], f"profile.button_map.{name}")
for name in LOGICAL_BUTTONS for name in LOGICAL_BUTTONS
), ),
left_stick=StickConfig.from_json_object( left_stick=StickConfig.from_json_object(
@ -1398,16 +1640,10 @@ class ControllerProfile:
left_trigger=left_trigger, left_trigger=left_trigger,
right_trigger=right_trigger, right_trigger=right_trigger,
weak_rumble_scale=_require_int( weak_rumble_scale=_require_int(
rumble["weak_scale"], rumble["weak_scale"], "profile.rumble.weak_scale", 0, 0xFF
"profile.rumble.weak_scale",
0,
0xFF,
), ),
strong_rumble_scale=_require_int( strong_rumble_scale=_require_int(
rumble["strong_scale"], rumble["strong_scale"], "profile.rumble.strong_scale", 0, 0xFF
"profile.rumble.strong_scale",
0,
0xFF,
), ),
confirmation_policy=_require_enum( confirmation_policy=_require_enum(
rumble["confirmation_policy"], rumble["confirmation_policy"],
@ -1416,14 +1652,7 @@ class ControllerProfile:
), ),
switching_chord=switching_chord, switching_chord=switching_chord,
motion_toggle_chord=motion_toggle_chord, motion_toggle_chord=motion_toggle_chord,
macro_trigger_mask=macro_trigger_mask, macros=macros,
macro_cancel=macro_cancel,
macro_steps=tuple(
MacroStep.from_json_object(
step, f"profile.macro.steps[{index}]"
)
for index, step in enumerate(steps)
),
turbo_modes=tuple( turbo_modes=tuple(
_require_enum( _require_enum(
turbo[name], TURBO_MODES, f"profile.turbo.{name}" turbo[name], TURBO_MODES, f"profile.turbo.{name}"
@ -1756,6 +1985,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_LEGACY_SCHEMA_VERSION,
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
PROFILE_ACTION_CONTROL_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION,
): ):
raise ConfigManagerError("unsupported profile-list schema") raise ConfigManagerError("unsupported profile-list schema")
@ -1823,6 +2053,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile:
PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_LEGACY_SCHEMA_VERSION,
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
PROFILE_ACTION_CONTROL_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION,
): ):
raise ConfigManagerError("unsupported profile schema") raise ConfigManagerError("unsupported profile schema")

View file

@ -115,8 +115,15 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"rumble_policies": list(config_manager.RUMBLE_POLICIES), "rumble_policies": list(config_manager.RUMBLE_POLICIES),
"turbo_modes": list(config_manager.TURBO_MODES), "turbo_modes": list(config_manager.TURBO_MODES),
"macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES), "macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES),
"macro_count": config_manager.PROFILE_MACRO_COUNT,
"maximum_macro_state_steps": ( "maximum_macro_state_steps": (
config_manager.PROFILE_MACRO_STEP_CAPACITY - 1 config_manager.PROFILE_MACRO_STEPS_PER_MACRO
),
"maximum_shared_macro_steps": (
config_manager.PROFILE_MACRO_STEP_CAPACITY
),
"maximum_macro_stream_bytes": (
config_manager.PROFILE_MACRO_STREAM_SIZE
), ),
"default_profile": ( "default_profile": (
config_manager.ControllerProfile.default().to_json_object() config_manager.ControllerProfile.default().to_json_object()

View file

@ -377,6 +377,16 @@ input[type="range"] { width: 100%; accent-color: var(--cyan); }
.macro-heading { align-items: center; } .macro-heading { align-items: center; }
.macro-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } .macro-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
.macro-picker { display: flex; grid-column: 1 / -1; align-items: stretch; justify-content: space-between; gap: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: #0d1219; }
.macro-tabs { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)); gap: 7px; flex: 1; }
.macro-tab { padding: 9px 12px; color: var(--muted); border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); font-weight: 800; }
.macro-tab small { display: block; margin-top: 3px; font-size: 0.58rem; font-weight: 600; }
.macro-tab.selected { color: #071417; border-color: var(--cyan); background: var(--cyan); }
.macro-budget { display: flex; flex: 0 0 170px; flex-direction: column; justify-content: center; color: var(--muted); font-size: 0.68rem; }
.macro-budget strong { color: var(--ink); }
.macro-budget progress { width: 100%; height: 5px; margin-top: 8px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--cyan); }
.macro-budget progress::-webkit-progress-bar { border-radius: inherit; background: var(--surface-3); }
.macro-budget progress::-webkit-progress-value { border-radius: inherit; background: linear-gradient(90deg, var(--cyan), var(--coral)); }
.macro-steps { display: grid; gap: 12px; } .macro-steps { display: grid; gap: 12px; }
.macro-step { padding: 17px; border: 1px solid var(--line); border-left: 3px solid var(--coral); border-radius: 14px; background: var(--surface-2); } .macro-step { padding: 17px; border: 1px solid var(--line); border-left: 3px solid var(--coral); border-radius: 14px; background: var(--surface-2); }
.macro-step.end { border-left-color: var(--muted); opacity: 0.72; } .macro-step.end { border-left-color: var(--muted); opacity: 0.72; }
@ -410,6 +420,8 @@ input[type="range"] { width: 100%; accent-color: var(--cyan); }
.builtin-actions { grid-template-columns: 1fr; } .builtin-actions { grid-template-columns: 1fr; }
.panel-heading > p { text-align: left; } .panel-heading > p { text-align: left; }
.analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; }
.macro-picker { flex-direction: column; }
.macro-budget { flex-basis: auto; }
.step-grid { grid-template-columns: 1fr 1fr; } .step-grid { grid-template-columns: 1fr 1fr; }
} }
@media (max-width: 470px) { @media (max-width: 470px) {

View file

@ -9,6 +9,7 @@ const state = {
original: "", original: "",
active: false, active: false,
selectedButton: "south", selectedButton: "south",
selectedMacro: 0,
token: "", token: "",
busy: false, busy: false,
}; };
@ -115,11 +116,18 @@ function setBusy(busy) {
elements.refresh.disabled = busy; elements.refresh.disabled = busy;
elements.identity.disabled = busy; elements.identity.disabled = busy;
if (state.schema && state.profile) { if (state.schema && state.profile) {
const stateStepCount = state.profile.macro.steps.filter( const macro = state.profile.macros[state.selectedMacro];
(step) => step.type === "state" const totalSteps = state.profile.macros.reduce(
).length; (sum, item) => sum + item.steps.length, 0
);
const totalBytes = state.profile.macros.reduce(
(sum, item) => sum + item.steps.reduce(
(stepSum, step) => stepSum + macroStepWireSize(step), 0
), 0
);
elements.addMacroStep.disabled = ( elements.addMacroStep.disabled = (
busy || stateStepCount >= state.schema.maximum_macro_state_steps busy || macro.steps.length >= 8 || totalSteps >= 16 ||
totalBytes + 3 > 136
); );
} }
document.querySelectorAll(".profile-button").forEach((button) => { document.querySelectorAll(".profile-button").forEach((button) => {
@ -431,11 +439,30 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
</div>`; </div>`;
} }
function macroStepWireSize(step) {
const overrides = new Set(step.overrides);
return 3 +
(overrides.has("buttons") ? 2 : 0) +
(overrides.has("left_stick") ? 4 : 0) +
(overrides.has("right_stick") ? 4 : 0) +
(overrides.has("left_trigger") ? 2 : 0) +
(overrides.has("right_trigger") ? 2 : 0);
}
function renderMacro() { function renderMacro() {
const controllerStyle = const controllerStyle =
state.identities[state.identityIndex]?.controller?.style || "generic"; state.identities[state.identityIndex]?.controller?.style || "generic";
elements.builtinActions.dataset.controllerStyle = controllerStyle; elements.builtinActions.dataset.controllerStyle = controllerStyle;
const macro = state.profile.macro; elements.macroControls.dataset.controllerStyle = controllerStyle;
const macro = state.profile.macros[state.selectedMacro];
const totalSteps = state.profile.macros.reduce(
(sum, item) => sum + item.steps.length, 0
);
const totalBytes = state.profile.macros.reduce(
(sum, item) => sum + item.steps.reduce(
(stepSum, step) => stepSum + macroStepWireSize(step), 0
), 0
);
elements.builtinActions.innerHTML = [ elements.builtinActions.innerHTML = [
actionChordCard( actionChordCard(
"profile_switch", "profile_switch",
@ -451,67 +478,89 @@ function renderMacro() {
state.profile.motion_toggle_chord, state.profile.motion_toggle_chord,
state.schema.default_motion_toggle_chord state.schema.default_motion_toggle_chord
), ),
actionChordCard(
"custom_macro",
"Run custom macro",
"Start the editable sequence below.",
macro.trigger,
null
),
].join(""); ].join("");
elements.macroControls.innerHTML = ` elements.macroControls.innerHTML = `
<div class="macro-picker">
<div class="macro-tabs">
${state.profile.macros.map((item, index) => `
<button type="button" class="macro-tab${index === state.selectedMacro ? " selected" : ""}" data-macro-index="${index}">
Macro ${index + 1}<small>${item.steps.length} step${item.steps.length === 1 ? "" : "s"}</small>
</button>`).join("")}
</div>
<div class="macro-budget">
<strong>${totalSteps}/16 steps</strong>
<span>${totalBytes}/136 sparse bytes</span>
<progress max="136" value="${totalBytes}"></progress>
</div>
</div>
${actionChordCard(
"custom_macro",
`Run macro ${state.selectedMacro + 1}`,
"Start this editable sequence.",
macro.trigger,
null
)}
<div class="control-card"> <div class="control-card">
<label for="macro-cancel">Custom macro cancel control</label> <label for="macro-cancel">Macro ${state.selectedMacro + 1} cancel control</label>
<select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel"> <select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel">
${buttonOptions(macro.cancel, true, state.schema.controls)} ${buttonOptions(macro.cancel, true, state.schema.controls)}
</select> </select>
</div>`; </div>`;
elements.macroSteps.innerHTML = macro.steps.map((step, index) => { elements.macroControls.querySelectorAll("[data-macro-index]").forEach((button) => {
if (step.type === "end") { button.onclick = () => {
return `<div class="macro-step end"><div class="step-header"><span class="step-number">End marker · step ${index + 1}</span></div><p class="field-help">The firmware requires this canonical final step.</p></div>`; state.selectedMacro = Number(button.dataset.macroIndex);
} renderMacro();
const overrides = new Set(step.overrides); };
const outputButtons = new Set(step.output_buttons); });
return `
<div class="macro-step"> elements.macroSteps.innerHTML = macro.steps.length === 0
<div class="step-header"> ? '<div class="macro-step end"><p class="field-help">No steps yet. Add a state step to build this macro.</p></div>'
<span class="step-number">State step ${index + 1}</span> : macro.steps.map((step, index) => {
<button class="remove-step" type="button" data-remove-step="${index}">Remove</button> const overrides = new Set(step.overrides);
</div> const outputButtons = new Set(step.output_buttons);
<div class="step-grid"> return `
${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")} <div class="macro-step">
${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))} <div class="step-header">
${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))} <span class="step-number">Macro ${state.selectedMacro + 1} · step ${index + 1} · ${macroStepWireSize(step)} bytes</span>
${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))} <button class="remove-step" type="button" data-remove-step="${index}">Remove</button>
${macroNumber(index, "right_stick.y", step.right_stick.y, -32768, 32767, "Right stick Y", !overrides.has("right_stick"))}
${macroNumber(index, "triggers.left", step.triggers.left, 0, 65535, "Left trigger", !overrides.has("left_trigger"))}
${macroNumber(index, "triggers.right", step.triggers.right, 0, 65535, "Right trigger", !overrides.has("right_trigger"))}
</div>
<div class="step-group">
<span class="step-group-title">Fields this step overrides</span>
<div class="check-grid">
${state.schema.macro_overrides.map((name) => `
<label class="checkbox-pill">
<input type="checkbox" data-kind="macro-override" data-index="${index}" data-name="${name}"${overrides.has(name) ? " checked" : ""}>
<span>${label(name)}</span>
</label>`).join("")}
</div> </div>
</div> <div class="step-grid">
<div class="step-group"> ${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")}
<span class="step-group-title">Output buttons</span> ${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))}
<div class="check-grid"> ${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))}
${state.schema.buttons.map((button) => ` ${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))}
<label class="checkbox-pill"> ${macroNumber(index, "right_stick.y", step.right_stick.y, -32768, 32767, "Right stick Y", !overrides.has("right_stick"))}
<input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}> ${macroNumber(index, "triggers.left", step.triggers.left, 0, 65535, "Left trigger", !overrides.has("left_trigger"))}
<span>${label(button)}</span> ${macroNumber(index, "triggers.right", step.triggers.right, 0, 65535, "Right trigger", !overrides.has("right_trigger"))}
</label>`).join("")}
</div> </div>
</div> <div class="step-group">
</div>`; <span class="step-group-title">Fields this step overrides</span>
}).join(""); <div class="check-grid">
const stateStepCount = macro.steps.filter((step) => step.type === "state").length; ${state.schema.macro_overrides.map((name) => `
elements.addMacroStep.disabled = state.busy || stateStepCount >= state.schema.maximum_macro_state_steps; <label class="checkbox-pill">
<input type="checkbox" data-kind="macro-override" data-index="${index}" data-name="${name}"${overrides.has(name) ? " checked" : ""}>
<span>${label(name)}</span>
</label>`).join("")}
</div>
</div>
<div class="step-group">
<span class="step-group-title">Output buttons</span>
<div class="check-grid">
${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>${label(button)}</span>
</label>`).join("")}
</div>
</div>
</div>`;
}).join("");
elements.addMacroStep.textContent = `Add step to macro ${state.selectedMacro + 1}`;
elements.addMacroStep.disabled = (
state.busy || macro.steps.length >= 8 || totalSteps >= 16 ||
totalBytes + 3 > 136
);
} }
function renderEditor() { function renderEditor() {
@ -595,7 +644,7 @@ function handleFormChange(event) {
const fields = { const fields = {
profile_switch: [state.profile, "switching_chord"], profile_switch: [state.profile, "switching_chord"],
motion_toggle: [state.profile, "motion_toggle_chord"], motion_toggle: [state.profile, "motion_toggle_chord"],
custom_macro: [state.profile.macro, "trigger"], custom_macro: [state.profile.macros[state.selectedMacro], "trigger"],
}; };
const [owner, field] = fields[target.dataset.action]; const [owner, field] = fields[target.dataset.action];
const defaults = { const defaults = {
@ -611,16 +660,16 @@ function handleFormChange(event) {
} else if (kind === "turbo") { } else if (kind === "turbo") {
state.profile.turbo[target.dataset.name] = target.value; state.profile.turbo[target.dataset.name] = target.value;
} else if (kind === "macro-selector") { } else if (kind === "macro-selector") {
state.profile.macro[target.dataset.field] = target.value || null; state.profile.macros[state.selectedMacro][target.dataset.field] = target.value || null;
} else if (kind === "macro-number") { } else if (kind === "macro-number") {
updateNestedStep(state.profile.macro.steps[Number(target.dataset.index)], target.dataset.field, Number(target.value)); updateNestedStep(state.profile.macros[state.selectedMacro].steps[Number(target.dataset.index)], target.dataset.field, Number(target.value));
} else if (kind === "macro-output") { } else if (kind === "macro-output") {
const step = state.profile.macro.steps[Number(target.dataset.index)]; const step = state.profile.macros[state.selectedMacro].steps[Number(target.dataset.index)];
const selected = new Set(step.output_buttons); const selected = new Set(step.output_buttons);
target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name);
step.output_buttons = state.schema.buttons.filter((name) => selected.has(name)); step.output_buttons = state.schema.buttons.filter((name) => selected.has(name));
} else if (kind === "macro-override") { } else if (kind === "macro-override") {
const step = state.profile.macro.steps[Number(target.dataset.index)]; const step = state.profile.macros[state.selectedMacro].steps[Number(target.dataset.index)];
const selected = new Set(step.overrides); const selected = new Set(step.overrides);
target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name);
step.overrides = state.schema.macro_overrides.filter((name) => selected.has(name)); step.overrides = state.schema.macro_overrides.filter((name) => selected.has(name));
@ -662,10 +711,17 @@ elements.resetDraft.addEventListener("click", () => {
}); });
elements.addMacroStep.addEventListener("click", () => { elements.addMacroStep.addEventListener("click", () => {
const steps = state.profile.macro.steps; const steps = state.profile.macros[state.selectedMacro].steps;
const stateCount = steps.filter((step) => step.type === "state").length; const totalSteps = state.profile.macros.reduce(
if (stateCount >= state.schema.maximum_macro_state_steps) return; (sum, macro) => sum + macro.steps.length, 0
steps.splice(steps.length - 1, 0, { );
const totalBytes = state.profile.macros.reduce(
(sum, macro) => sum + macro.steps.reduce(
(stepSum, step) => stepSum + macroStepWireSize(step), 0
), 0
);
if (steps.length >= 8 || totalSteps >= 16 || totalBytes + 3 > 136) return;
steps.push({
type: "state", type: "state",
overrides: ["buttons"], overrides: ["buttons"],
duration_ms: 100, duration_ms: 100,
@ -681,7 +737,7 @@ elements.addMacroStep.addEventListener("click", () => {
elements.macroSteps.addEventListener("click", (event) => { elements.macroSteps.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-step]"); const button = event.target.closest("[data-remove-step]");
if (!button) return; if (!button) return;
state.profile.macro.steps.splice(Number(button.dataset.removeStep), 1); state.profile.macros[state.selectedMacro].steps.splice(Number(button.dataset.removeStep), 1);
renderMacro(); renderMacro();
updateDirtyState(); updateDirtyState();
}); });

View file

@ -396,19 +396,17 @@ void test_initial_profile_indication_once_per_connection() {
void test_default_switching_retry_commit_and_feedback() { void test_default_switching_retry_commit_and_feedback() {
prepare_profiles(); prepare_profiles();
ControllerProfile& initial_profile = rows[0].profiles[0]; ControllerProfile& initial_profile = rows[0].profiles[0];
initial_profile.macro_trigger_mask = initial_profile.macros[0].trigger_mask =
logical_button_bit( logical_button_bit(
ControllerProfileLogicalButton::kLeftShoulder); ControllerProfileLogicalButton::kLeftShoulder);
initial_profile.macro_step_count = 2; initial_profile.macros[0].first_step = 0;
initial_profile.macro_steps[0].type = initial_profile.macros[0].step_count = 1;
ControllerProfileMacroStepType::kState; initial_profile.macro_step_count = 1;
initial_profile.macro_steps[0].override_flags = initial_profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
initial_profile.macro_steps[0].duration_ms = 1000; initial_profile.macro_steps[0].duration_ms = 1000;
initial_profile.macro_steps[0].output_button_mask = initial_profile.macro_steps[0].output_button_mask =
logical_button_bit(ControllerProfileLogicalButton::kNorth); logical_button_bit(ControllerProfileLogicalButton::kNorth);
initial_profile.macro_steps[1].type =
ControllerProfileMacroStepType::kEnd;
Bluepad32SlotSnapshot snapshot = make_snapshot(0); Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0); (void)runtime_transform(0, snapshot, 0);
@ -533,19 +531,17 @@ void test_identity_promotion_preserves_held_switching() {
} }
void configure_motion_suppression_probe(ControllerProfile* profile) { void configure_motion_suppression_probe(ControllerProfile* profile) {
profile->macro_trigger_mask = profile->macros[0].trigger_mask =
logical_button_bit( logical_button_bit(
ControllerProfileLogicalButton::kDpadUp); ControllerProfileLogicalButton::kDpadUp);
profile->macro_step_count = 2; profile->macros[0].first_step = 0;
profile->macro_steps[0].type = profile->macros[0].step_count = 1;
ControllerProfileMacroStepType::kState; profile->macro_step_count = 1;
profile->macro_steps[0].override_flags = profile->macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile->macro_steps[0].duration_ms = 1000; profile->macro_steps[0].duration_ms = 1000;
profile->macro_steps[0].output_button_mask = profile->macro_steps[0].output_button_mask =
logical_button_bit(ControllerProfileLogicalButton::kNorth); logical_button_bit(ControllerProfileLogicalButton::kNorth);
profile->macro_steps[1].type =
ControllerProfileMacroStepType::kEnd;
} }
void test_switching_uses_pre_hotkey_buttons_only() { void test_switching_uses_pre_hotkey_buttons_only() {
@ -723,22 +719,19 @@ void test_profile_motion_toggle_supports_trigger_chords() {
void configure_synthetic_profile(uint8_t slot) { void configure_synthetic_profile(uint8_t slot) {
ControllerProfile& profile = rows[slot].profiles[0]; ControllerProfile& profile = rows[slot].profiles[0];
profile = controller_profile_default(rows[slot].identity, 0); profile = controller_profile_default(rows[slot].identity, 0);
profile.macro_trigger_mask = profile.macros[0].trigger_mask =
logical_button_bit(ControllerProfileLogicalButton::kSouth); logical_button_bit(ControllerProfileLogicalButton::kSouth);
profile.macro_cancel = profile.macros[0].cancel_control =
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture); static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
profile.macro_step_count = 2; profile.macros[0].first_step = 0;
profile.macro_steps[0].type = profile.macros[0].step_count = 1;
ControllerProfileMacroStepType::kState; profile.macro_step_count = 1;
profile.macro_steps[0].override_flags = profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 1000; profile.macro_steps[0].duration_ms = 1000;
profile.macro_steps[0].output_button_mask = static_cast<uint16_t>( profile.macro_steps[0].output_button_mask = static_cast<uint16_t>(
1u << static_cast<uint8_t>( 1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kNorth)); ControllerProfileLogicalButton::kNorth));
profile.macro_steps[1] = {};
profile.macro_steps[1].type =
ControllerProfileMacroStepType::kEnd;
profile.turbo_modes[static_cast<uint8_t>( profile.turbo_modes[static_cast<uint8_t>(
ControllerProfileLogicalButton::kEast)] = ControllerProfileLogicalButton::kEast)] =
ControllerProfileTurboMode::kAutoBurst; ControllerProfileTurboMode::kAutoBurst;

View file

@ -45,9 +45,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)), require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode"); "default profile did not encode");
require(encoded[0] == 4 && encoded[1] == 0 && require(encoded[0] == 5 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1, encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v4/256"); "profile header is not little-endian v5/256");
for (uint8_t index = 0; for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index, require(encoded[4 + index] == index,
@ -70,15 +70,13 @@ void test_profile_wire_schema() {
encoded[71] == 0, encoded[71] == 0,
"default trigger mappings are not identity"); "default trigger mappings are not identity");
require(encoded[72] == 0xff && encoded[73] == 0xff && require(encoded[72] == 0xff && encoded[73] == 0xff &&
encoded[74] == 3 && encoded[78] == 0 && encoded[74] == 3 && encoded[75] == 0 &&
encoded[79] == 0 && encoded[80] == 1 && encoded[78] == 0 && encoded[79] == 0,
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON, "default rumble or action encoding changed");
"default rumble or macro encoding changed"); require(encoded[96] == 0 && encoded[97] == 0 &&
for (uint8_t index = 0; encoded[98] == 0x7c && encoded[99] == 0 &&
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) { encoded[100] == 0 && encoded[101] == 0,
require(encoded[100 + index * 19] == 1, "default sparse macro descriptor changed");
"unused macro step is not canonical end");
}
ControllerProfile decoded{}; ControllerProfile decoded{};
require(controller_profile_decode(encoded, sizeof(encoded), &decoded), require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
@ -88,29 +86,50 @@ void test_profile_wire_schema() {
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) | (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
(1u << static_cast<uint8_t>( (1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth)); ControllerProfileLogicalButton::kSouth));
action_profile.macro_trigger_mask =
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
action_profile.motion_toggle_chord = action_profile.motion_toggle_chord =
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
action_profile.macro_cancel = action_profile.macros[0].trigger_mask =
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
action_profile.macros[0].cancel_control =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
action_profile.macros[0].first_step = 0;
action_profile.macros[0].step_count = 1;
action_profile.macro_step_count = 1;
action_profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
for (uint8_t macro_index = 1;
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
action_profile.macros[macro_index].first_step = 1;
}
action_profile.macro_steps[0].duration_ms = 25;
action_profile.macro_steps[0].output_button_mask = 1;
uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode( require(controller_profile_encode(
action_profile, action_encoded, sizeof(action_encoded)) && action_profile, action_encoded, sizeof(action_encoded)),
action_encoded[75] == 0x2d && "sparse action profile did not encode");
action_encoded[81] == require(action_encoded[75] == 0x21,
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && "sparse action extension encoding changed");
controller_profile_decode( require(action_encoded[96] == 0 &&
action_encoded, sizeof(action_encoded), &decoded) && action_encoded[97] == 0 &&
decoded.switching_chord == action_encoded[98] == 0x43,
"sparse macro binding encoding changed");
require(action_encoded[100] == 1 &&
action_encoded[101] == 5,
"sparse macro step bounds changed");
require(controller_profile_decode(
action_encoded, sizeof(action_encoded), &decoded),
"sparse action profile did not decode");
require(decoded.switching_chord ==
action_profile.switching_chord && action_profile.switching_chord &&
decoded.motion_toggle_chord == decoded.motion_toggle_chord ==
action_profile.motion_toggle_chord && action_profile.motion_toggle_chord &&
decoded.macro_trigger_mask == decoded.macros[0].trigger_mask ==
action_profile.macro_trigger_mask && action_profile.macros[0].trigger_mask &&
decoded.macro_cancel == action_profile.macro_cancel, decoded.macros[0].cancel_control ==
"trigger-backed action masks did not round-trip"); action_profile.macros[0].cancel_control &&
decoded.macros[0].step_count == 1,
"sparse trigger-backed macro did not round-trip");
encoded[252] = 1; encoded[252] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero reserved profile byte was accepted"); "nonzero reserved profile byte was accepted");
@ -159,19 +178,18 @@ void test_profile_wire_schema() {
require(!controller_profile_validate(invalid), require(!controller_profile_validate(invalid),
"invalid Turbo mode was accepted"); "invalid Turbo mode was accepted");
invalid = profile; invalid = profile;
invalid.macro_step_count = 2; invalid.macros[0].first_step = 0;
invalid.macro_steps[0].type = invalid.macros[0].step_count = 1;
ControllerProfileMacroStepType::kState; invalid.macro_step_count = 1;
invalid.macro_steps[0].duration_ms = invalid.macro_steps[0].duration_ms =
CONTROLLER_PROFILE_MAX_WAIT_MS + 1; CONTROLLER_PROFILE_MAX_WAIT_MS + 1;
require(!controller_profile_validate(invalid), require(!controller_profile_validate(invalid),
"unbounded macro wait was accepted"); "unbounded macro wait was accepted");
invalid.macro_steps[0].duration_ms = invalid.macro_steps[0].duration_ms =
CONTROLLER_PROFILE_MAX_WAIT_MS; CONTROLLER_PROFILE_MAX_WAIT_MS;
invalid.macro_steps[1].type = invalid.macros[1].first_step = 0;
ControllerProfileMacroStepType::kState;
require(!controller_profile_validate(invalid), require(!controller_profile_validate(invalid),
"macro without a final end was accepted"); "noncanonical shared macro pool was accepted");
} }
void test_legacy_profile_migration() { void test_legacy_profile_migration() {
@ -189,19 +207,9 @@ void test_legacy_profile_migration() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(migrated, encoded, sizeof(encoded)), require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated default profile did not encode"); "migrated default profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) { require(migrated.macros[0].step_count == 0 &&
const bool migration_byte = migrated.macro_step_count == 0,
index == 0 || "legacy end marker was not removed during migration");
(index >= 58 && index < 60) ||
index == 60 ||
(index >= 68 && index < 70) ||
index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(encoded[index] == kLegacyDefaultProfile[index],
"legacy default profile changed an unrelated byte");
}
}
require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION && require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
encoded[58] == encoded[58] ==
static_cast<uint8_t>( static_cast<uint8_t>(
@ -216,10 +224,8 @@ void test_legacy_profile_migration() {
static_cast<uint8_t>( static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
encoded[78] == 0 && encoded[79] == 0 && "migrated default profile did not encode as v5");
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
"migrated default profile did not encode as v4");
require(controller_profile_decode( require(controller_profile_decode(
kLegacyNarrowRawRangeProfile, kLegacyNarrowRawRangeProfile,
@ -236,20 +242,6 @@ void test_legacy_profile_migration() {
"legacy narrow raw range or inherited threshold was not migrated"); "legacy narrow raw range or inherited threshold was not migrated");
require(controller_profile_encode(migrated, encoded, sizeof(encoded)), require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated narrow-raw-range profile did not encode"); "migrated narrow-raw-range profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) {
const bool migration_byte =
index == 0 ||
(index >= 58 && index < 60) ||
index == 60 ||
(index >= 68 && index < 70) ||
index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(
encoded[index] == kLegacyNarrowRawRangeProfile[index],
"narrow-raw-range migration changed unrelated profile data");
}
}
require(controller_profile_decode( require(controller_profile_decode(
kLegacyCustomThresholdProfile, kLegacyCustomThresholdProfile,
@ -267,58 +259,48 @@ void test_legacy_profile_migration() {
"legacy custom-threshold profile did not re-encode"); "legacy custom-threshold profile did not re-encode");
const uint8_t legacy_macro_trigger = const uint8_t legacy_macro_trigger =
kLegacyCustomThresholdProfile[78]; kLegacyCustomThresholdProfile[78];
const uint16_t expected_macro_trigger = const uint32_t expected_macro_trigger =
legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
? static_cast<uint16_t>(1u << legacy_macro_trigger) ? static_cast<uint32_t>(1u << legacy_macro_trigger)
: 0; : 0;
require(migrated.macro_trigger_mask == expected_macro_trigger && require(migrated.macros[0].trigger_mask ==
migrated.macro_cancel == expected_macro_trigger &&
migrated.macros[0].cancel_control ==
kLegacyCustomThresholdProfile[79], kLegacyCustomThresholdProfile[79],
"legacy macro trigger was not migrated to a chord"); "legacy macro trigger was not migrated to descriptor zero");
for (size_t index = 1; index < sizeof(encoded); ++index) {
const bool migration_byte =
index == 60 || index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(encoded[index] ==
kLegacyCustomThresholdProfile[index],
"legacy custom-threshold profile changed data");
}
}
const ControllerProfile previous_source =
controller_profile_default(controller_identity_global(), 0);
uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode( memcpy(previous_encoded, kLegacyDefaultProfile,
previous_source, previous_encoded, sizeof(previous_encoded));
sizeof(previous_encoded)),
"previous-schema fixture did not encode");
previous_encoded[0] = static_cast<uint8_t>( previous_encoded[0] = static_cast<uint8_t>(
CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION); CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION);
previous_encoded[60] = 0; previous_encoded[58] = static_cast<uint8_t>(
previous_encoded[70] = 0; CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
previous_encoded[59] = static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8);
previous_encoded[68] = static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
previous_encoded[69] = static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8);
previous_encoded[78] = static_cast<uint8_t>( previous_encoded[78] = static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth); ControllerProfileLogicalButton::kSouth);
previous_encoded[79] = static_cast<uint8_t>( previous_encoded[79] = static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture); ControllerProfileLogicalButton::kCapture);
previous_encoded[81] = 0;
previous_encoded[98] = 0;
previous_encoded[99] = 0;
require(controller_profile_decode( require(controller_profile_decode(
previous_encoded, sizeof(previous_encoded), &migrated) && previous_encoded, sizeof(previous_encoded), &migrated) &&
migrated.triggers[0].output == migrated.triggers[0].output ==
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
migrated.triggers[1].output == migrated.triggers[1].output ==
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
migrated.macro_trigger_mask == migrated.macros[0].trigger_mask ==
static_cast<uint16_t>( static_cast<uint32_t>(
1u << static_cast<uint8_t>( 1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth)) && ControllerProfileLogicalButton::kSouth)) &&
migrated.macro_cancel == migrated.macros[0].cancel_control ==
static_cast<uint8_t>( static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture) && ControllerProfileLogicalButton::kCapture) &&
migrated.motion_toggle_chord == 0, migrated.motion_toggle_chord == 0,
"v2 profile controls did not migrate to v4"); "v2 profile controls did not migrate to v5");
ControllerProfile current = ControllerProfile current =
controller_profile_default(controller_identity_global(), 0); controller_profile_default(controller_identity_global(), 0);

View file

@ -39,36 +39,28 @@ ControllerProfile profile_with_macro(
ControllerProfileLogicalButton::kCapture) { ControllerProfileLogicalButton::kCapture) {
ControllerProfile profile = ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0); controller_profile_default(controller_identity_global(), 0);
profile.macro_trigger_mask = button_bit(trigger); profile.macros[0].trigger_mask = button_bit(trigger);
profile.macro_cancel = button_index(cancel); profile.macros[0].cancel_control = button_index(cancel);
profile.macros[0].first_step = 0;
return profile; return profile;
} }
void set_end(ControllerProfile* profile, uint8_t index) {
profile->macro_steps[index] = {};
profile->macro_steps[index].type =
ControllerProfileMacroStepType::kEnd;
}
void test_immediate_press_release_dpad_and_explicit_end() { void test_immediate_press_release_dpad_and_explicit_end() {
ControllerProfile profile = ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth); profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_step_count = 3; profile.macro_step_count = 2;
profile.macro_steps[0].type = profile.macros[0].step_count = 2;
ControllerProfileMacroStepType::kState;
profile.macro_steps[0].override_flags = profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].duration_ms = 10;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth) | button_bit(ControllerProfileLogicalButton::kNorth) |
button_bit(ControllerProfileLogicalButton::kDpadUp); button_bit(ControllerProfileLogicalButton::kDpadUp);
profile.macro_steps[1].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[1].override_flags = profile.macro_steps[1].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[1].duration_ms = 20; profile.macro_steps[1].duration_ms = 20;
profile.macro_steps[1].output_button_mask = 0; profile.macro_steps[1].output_button_mask = 0;
set_end(&profile, 2);
ControllerSyntheticInputContext context{}; ControllerSyntheticInputContext context{};
ControllerState input = ControllerState input =
@ -103,20 +95,18 @@ void test_immediate_press_release_dpad_and_explicit_end() {
void test_macro_trigger_chord_requires_every_button() { void test_macro_trigger_chord_requires_every_button() {
ControllerProfile profile = ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth); profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_trigger_mask = profile.macros[0].trigger_mask =
button_bit(ControllerProfileLogicalButton::kSouth) | button_bit(ControllerProfileLogicalButton::kSouth) |
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
profile.macro_cancel = profile.macros[0].cancel_control =
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
profile.macro_step_count = 2; profile.macro_step_count = 1;
profile.macro_steps[0].type = profile.macros[0].step_count = 1;
ControllerProfileMacroStepType::kState;
profile.macro_steps[0].override_flags = profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100; profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth); button_bit(ControllerProfileLogicalButton::kNorth);
set_end(&profile, 1);
ControllerSyntheticInputContext context{}; ControllerSyntheticInputContext context{};
ControllerState input = ControllerState input =
@ -151,9 +141,9 @@ void test_macro_trigger_chord_requires_every_button() {
void test_optional_field_overrides_and_motion_preservation() { void test_optional_field_overrides_and_motion_preservation() {
ControllerProfile profile = ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSelect); profile_with_macro(ControllerProfileLogicalButton::kSelect);
profile.macro_step_count = 2; profile.macro_step_count = 1;
profile.macros[0].step_count = 1;
ControllerProfileMacroStep& step = profile.macro_steps[0]; ControllerProfileMacroStep& step = profile.macro_steps[0];
step.type = ControllerProfileMacroStepType::kState;
step.override_flags = kControllerProfileOverrideLeftStick | step.override_flags = kControllerProfileOverrideLeftStick |
kControllerProfileOverrideRightStick | kControllerProfileOverrideRightStick |
kControllerProfileOverrideLeftTrigger | kControllerProfileOverrideLeftTrigger |
@ -165,7 +155,6 @@ void test_optional_field_overrides_and_motion_preservation() {
step.right_stick_y = INT16_MAX; step.right_stick_y = INT16_MAX;
step.left_trigger = 0; step.left_trigger = 0;
step.right_trigger = UINT16_MAX; step.right_trigger = UINT16_MAX;
set_end(&profile, 1);
ControllerState input = state_with_buttons( ControllerState input = state_with_buttons(
button_bit(ControllerProfileLogicalButton::kSelect) | button_bit(ControllerProfileLogicalButton::kSelect) |
@ -203,29 +192,23 @@ void test_optional_field_overrides_and_motion_preservation() {
void test_zero_max_wait_and_scheduled_catch_up() { void test_zero_max_wait_and_scheduled_catch_up() {
ControllerProfile profile = ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth); profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_step_count = 4; profile.macro_step_count = 3;
profile.macro_steps[0].type = profile.macros[0].step_count = 3;
ControllerProfileMacroStepType::kState;
profile.macro_steps[0].override_flags = profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 0; profile.macro_steps[0].duration_ms = 0;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth); button_bit(ControllerProfileLogicalButton::kNorth);
profile.macro_steps[1].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[1].override_flags = profile.macro_steps[1].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[1].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS; profile.macro_steps[1].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS;
profile.macro_steps[1].output_button_mask = profile.macro_steps[1].output_button_mask =
button_bit(ControllerProfileLogicalButton::kEast); button_bit(ControllerProfileLogicalButton::kEast);
profile.macro_steps[2].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[2].override_flags = profile.macro_steps[2].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[2].duration_ms = 0; profile.macro_steps[2].duration_ms = 0;
profile.macro_steps[2].output_button_mask = profile.macro_steps[2].output_button_mask =
button_bit(ControllerProfileLogicalButton::kWest); button_bit(ControllerProfileLogicalButton::kWest);
set_end(&profile, 3);
ControllerSyntheticInputContext context{}; ControllerSyntheticInputContext context{};
ControllerProfileTransformResult output = ControllerProfileTransformResult output =
@ -252,7 +235,8 @@ void test_zero_max_wait_and_scheduled_catch_up() {
!context.macro_active, !context.macro_active,
"zero-duration catch-up did not reach the explicit end"); "zero-duration catch-up did not reach the explicit end");
profile.macro_step_count = 4; profile.macro_step_count = 3;
profile.macros[0].step_count = 3;
profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].duration_ms = 10;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth); button_bit(ControllerProfileLogicalButton::kNorth);
@ -277,11 +261,11 @@ void test_zero_max_wait_and_scheduled_catch_up() {
controller_profile_extract_button_mask(output.state) == 0, controller_profile_extract_button_mask(output.state) == 0,
"large time jump did not finish the bounded macro"); "large time jump did not finish the bounded macro");
profile.macro_step_count = 2; profile.macro_step_count = 1;
profile.macros[0].step_count = 1;
profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].duration_ms = 10;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth); button_bit(ControllerProfileLogicalButton::kNorth);
set_end(&profile, 1);
context = {}; context = {};
constexpr uint32_t macro_near_wrap = UINT32_MAX - 5u; constexpr uint32_t macro_near_wrap = UINT32_MAX - 5u;
output = controller_synthetic_input_apply( output = controller_synthetic_input_apply(
@ -305,15 +289,13 @@ void test_consumption_cancel_precedence_and_duplicate_contributors() {
ControllerProfile profile = ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSelect, profile_with_macro(ControllerProfileLogicalButton::kSelect,
ControllerProfileLogicalButton::kCapture); ControllerProfileLogicalButton::kCapture);
profile.macro_step_count = 2; profile.macro_step_count = 1;
profile.macro_steps[0].type = profile.macros[0].step_count = 1;
ControllerProfileMacroStepType::kState;
profile.macro_steps[0].override_flags = profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons; kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100; profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask = profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kDpadLeft); button_bit(ControllerProfileLogicalButton::kDpadLeft);
set_end(&profile, 1);
profile.button_map[button_index(ControllerProfileLogicalButton::kSouth)] = profile.button_map[button_index(ControllerProfileLogicalButton::kSouth)] =
button_index(ControllerProfileLogicalButton::kNorth); button_index(ControllerProfileLogicalButton::kNorth);
profile.button_map[button_index(ControllerProfileLogicalButton::kEast)] = profile.button_map[button_index(ControllerProfileLogicalButton::kEast)] =
@ -469,6 +451,53 @@ void test_auto_burst_toggle_cancel_and_external_cancel() {
"Auto Burst did not restart after release following cancellation"); "Auto Burst did not restart after release following cancellation");
} }
void test_multiple_macro_bindings_share_step_pool() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.macros[0] = {
button_bit(ControllerProfileLogicalButton::kSouth),
CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
profile.macros[1] = {
button_bit(ControllerProfileLogicalButton::kEast),
CONTROLLER_PROFILE_NO_BUTTON, 1, 1};
profile.macros[2].first_step = 2;
profile.macros[3].first_step = 2;
profile.macro_step_count = 2;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 1;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth);
profile.macro_steps[1].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[1].duration_ms = 1;
profile.macro_steps[1].output_button_mask =
button_bit(ControllerProfileLogicalButton::kWest);
ControllerSyntheticInputContext context{};
ControllerProfileTransformResult output =
controller_synthetic_input_apply(
&context,
state_with_buttons(
button_bit(ControllerProfileLogicalButton::kEast)),
profile, 0);
require(context.macro_active && context.macro_index == 1 &&
has_button(output, ControllerProfileLogicalButton::kWest),
"second macro descriptor did not execute its shared step");
(void)controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 1);
(void)controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 2);
output = controller_synthetic_input_apply(
&context,
state_with_buttons(
button_bit(ControllerProfileLogicalButton::kSouth)),
profile, 3);
require(context.macro_active && context.macro_index == 0 &&
has_button(output, ControllerProfileLogicalButton::kNorth),
"first macro descriptor did not execute independently");
}
void test_four_contexts_are_isolated() { void test_four_contexts_are_isolated() {
ControllerProfile profile = ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0); controller_profile_default(controller_identity_global(), 0);
@ -507,6 +536,7 @@ int main() {
test_zero_max_wait_and_scheduled_catch_up(); test_zero_max_wait_and_scheduled_catch_up();
test_consumption_cancel_precedence_and_duplicate_contributors(); test_consumption_cancel_precedence_and_duplicate_contributors();
test_turbo_rate_release_and_uint32_wrap(); test_turbo_rate_release_and_uint32_wrap();
test_multiple_macro_bindings_share_step_pool();
test_auto_burst_toggle_cancel_and_external_cancel(); test_auto_burst_toggle_cancel_and_external_cancel();
test_four_contexts_are_isolated(); test_four_contexts_are_isolated();
return 0; return 0;

View file

@ -519,26 +519,70 @@ def custom_profile() -> config_manager.ControllerProfile:
confirmation_policy=2, confirmation_policy=2,
switching_chord=(1 << 6) | (1 << 16), switching_chord=(1 << 6) | (1 << 16),
motion_toggle_chord=(1 << 5) | (1 << 17), motion_toggle_chord=(1 << 5) | (1 << 17),
macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17), macros=(
macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"), config_manager.ControllerMacro(
macro_steps=( (1 << 0) | (1 << 16) | (1 << 17),
config_manager.MacroStep( config_manager.LOGICAL_CONTROLS.index("right_trigger"),
0, (
config_manager.MACRO_OVERRIDE_MASK, config_manager.MacroStep(
config_manager.PROFILE_MAXIMUM_WAIT_MS, 0,
(1 << 0) | (1 << 12), config_manager.MACRO_OVERRIDE_MASK,
-32768, config_manager.PROFILE_MAXIMUM_WAIT_MS,
32767, (1 << 0) | (1 << 12),
-1000, -32768,
1000, 32767,
12345, -1000,
54321, 1000,
12345,
54321,
),
),
), ),
config_manager.MacroStep.end(), config_manager.ControllerMacro(
(1 << 1) | (1 << 2),
config_manager.PROFILE_NONE_BUTTON,
(
config_manager.MacroStep(
0, 1, 25, 1 << 3, 0, 0, 0, 0, 0, 0
),
),
),
config_manager.ControllerMacro.empty(),
config_manager.ControllerMacro.empty(),
), ),
turbo_modes=(0, 1, 2) + (0,) * 13, turbo_modes=(0, 1, 2) + (0,) * 13,
) )
def legacy_profile_wire(
schema_version: int,
macro_trigger: int = config_manager.PROFILE_NONE_BUTTON,
macro_cancel: int = config_manager.PROFILE_NONE_BUTTON,
) -> bytearray:
profile = config_manager.ControllerProfile.default()
payload = bytearray(config_manager.PROFILE_SIZE)
struct.pack_into(
"<HH", payload, 0, schema_version, config_manager.PROFILE_SIZE
)
payload[4:20] = bytes(range(len(config_manager.LOGICAL_BUTTONS)))
payload[20:36] = profile.left_stick.to_bytes()
payload[36:52] = profile.right_stick.to_bytes()
payload[52:62] = profile.left_trigger.to_bytes()
payload[62:72] = profile.right_trigger.to_bytes()
if schema_version < config_manager.PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
payload[60:62] = b"\x00\x00"
payload[70:72] = b"\x00\x00"
payload[72:75] = bytes((0xFF, 0xFF, 3))
payload[78] = macro_trigger
payload[79] = macro_cancel
payload[80] = 1
if schema_version >= config_manager.PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
payload[81] = macro_cancel
payload[79] = 0
for index in range(config_manager.PROFILE_LEGACY_MACRO_STEP_CAPACITY):
payload[100 + index * config_manager.PROFILE_MACRO_STEP_SIZE] = 1
return payload
def test_response_validation() -> None: def test_response_validation() -> None:
payload = make_response(config_manager.OP_INFO, b"12345678") payload = make_response(config_manager.OP_INFO, b"12345678")
@ -1120,33 +1164,25 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
config_manager.PROFILE_SCHEMA_VERSION, config_manager.PROFILE_SCHEMA_VERSION,
config_manager.PROFILE_SIZE, config_manager.PROFILE_SIZE,
) )
assert encoded[75] == 0x2D assert encoded[75] == 0x21
assert encoded[60:62] == bytes((17, 0)) assert encoded[60:62] == bytes((17, 0))
assert encoded[70:72] == bytes((3, 0)) assert encoded[70:72] == bytes((3, 0))
assert encoded[81] == config_manager.LOGICAL_CONTROLS.index("right_trigger") assert struct.unpack_from("<H", encoded, 78)[0] == (
assert struct.unpack_from("<H", encoded, 98)[0] == (
profile.motion_toggle_chord & 0xFFFF profile.motion_toggle_chord & 0xFFFF
) )
assert encoded[96:102] == bytes((1, 0, 0x47, 0, 1, 17))
assert encoded[102:108] == bytes((6, 0, 0x7C, 17, 1, 5))
assert encoded[252:] == bytes(4) assert encoded[252:] == bytes(4)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile assert config_manager.ControllerProfile.from_bytes(encoded) == profile
serialized = profile.to_json() serialized = profile.to_json()
assert serialized.startswith('{\n "schema_version": 4,\n "size": 256,') assert serialized.startswith('{\n "schema_version": 5,\n "size": 256,')
decoded = config_manager.ControllerProfile.from_json(serialized) decoded = config_manager.ControllerProfile.from_json(serialized)
assert decoded == profile assert decoded == profile
assert decoded.to_json() == serialized assert decoded.to_json() == serialized
legacy_default_wire = legacy_profile_wire(
legacy_default_wire = bytearray(default_wire) config_manager.PROFILE_LEGACY_SCHEMA_VERSION
struct.pack_into(
"<H",
legacy_default_wire,
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
) )
legacy_default_wire[60:62] = b"\x00\x00"
legacy_default_wire[70:72] = b"\x00\x00"
legacy_default_wire[78] = config_manager.PROFILE_NONE_BUTTON
legacy_default_wire[79] = config_manager.PROFILE_NONE_BUTTON
legacy_default_wire[81] = 0 legacy_default_wire[81] = 0
legacy_default_wire[98:100] = b"\x00\x00" legacy_default_wire[98:100] = b"\x00\x00"
struct.pack_into( struct.pack_into(
@ -1198,26 +1234,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD == config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
) )
previous_wire = bytearray(default_wire) previous_wire = legacy_profile_wire(
struct.pack_into(
"<H",
previous_wire,
0,
config_manager.PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, config_manager.PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
macro_trigger=0,
macro_cancel=1,
) )
previous_wire[60:62] = b"\x00\x00"
previous_wire[70:72] = b"\x00\x00"
previous_wire[78] = 0
previous_wire[79] = 1
previous_wire[81] = 0
previous_wire[98:100] = b"\x00\x00"
migrated_previous = config_manager.ControllerProfile.from_bytes( migrated_previous = config_manager.ControllerProfile.from_bytes(
previous_wire previous_wire
) )
assert migrated_previous.left_trigger.output == 16 assert migrated_previous.left_trigger.output == 16
assert migrated_previous.right_trigger.output == 17 assert migrated_previous.right_trigger.output == 17
assert migrated_previous.macro_trigger_mask == 1 assert migrated_previous.macros[0].trigger_mask == 1
assert migrated_previous.macro_cancel == 1 assert migrated_previous.macros[0].cancel_control == 1
assert migrated_previous.motion_toggle_chord == 0 assert migrated_previous.motion_toggle_chord == 0
assert migrated_previous.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION assert migrated_previous.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION
@ -1228,7 +1256,12 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
del legacy_json_object["motion_toggle_chord"] del legacy_json_object["motion_toggle_chord"]
del legacy_json_object["triggers"]["left"]["output"] del legacy_json_object["triggers"]["left"]["output"]
del legacy_json_object["triggers"]["right"]["output"] del legacy_json_object["triggers"]["right"]["output"]
legacy_json_object["macro"]["trigger"] = "south" legacy_json_object.pop("macros")
legacy_json_object["macro"] = {
"trigger": "south",
"cancel": None,
"steps": [config_manager.MacroStep.end().to_json_object()],
}
legacy_json_object["triggers"]["left"]["digital_threshold"] = ( legacy_json_object["triggers"]["left"]["digital_threshold"] = (
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
) )
@ -1247,10 +1280,50 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
assert migrated_json.left_trigger.upper_saturation == 40000 assert migrated_json.left_trigger.upper_saturation == 40000
assert migrated_json.left_trigger.output == 16 assert migrated_json.left_trigger.output == 16
assert migrated_json.right_trigger.output == 17 assert migrated_json.right_trigger.output == 17
assert migrated_json.macro_trigger_mask == 1 assert migrated_json.macros[0].trigger_mask == 1
assert migrated_json.motion_toggle_chord == 0 assert migrated_json.motion_toggle_chord == 0
def test_sparse_macro_capacity_boundaries() -> None:
full_step = custom_profile().macros[0].steps[0].to_json_object()
button_step = custom_profile().macros[1].steps[0].to_json_object()
exact = config_manager.ControllerProfile.default().to_json_object()
exact["macros"][0]["trigger"] = ["south"]
exact["macros"][0]["steps"] = [full_step] * 8
exact_profile = config_manager.ControllerProfile.from_json_object(exact)
assert sum(
len(step.to_sparse_bytes())
for macro in exact_profile.macros
for step in macro.steps
) == config_manager.PROFILE_MACRO_STREAM_SIZE
assert (
config_manager.ControllerProfile.from_bytes(exact_profile.to_bytes())
== exact_profile
)
sixteen = config_manager.ControllerProfile.default().to_json_object()
for index, macro in enumerate(sixteen["macros"]):
macro["trigger"] = [config_manager.LOGICAL_CONTROLS[index]]
macro["steps"] = [button_step] * 4
sixteen_profile = config_manager.ControllerProfile.from_json_object(
sixteen
)
assert sum(len(macro.steps) for macro in sixteen_profile.macros) == 16
assert len(sixteen_profile.to_bytes()) == config_manager.PROFILE_SIZE
overflow = config_manager.ControllerProfile.default().to_json_object()
overflow["macros"][0]["trigger"] = ["south"]
overflow["macros"][0]["steps"] = [full_step] * 8
overflow["macros"][1]["trigger"] = ["east"]
overflow["macros"][1]["steps"] = [full_step]
with pytest.raises(
config_manager.ConfigManagerError,
match="136-byte sparse stream",
):
config_manager.ControllerProfile.from_json_object(overflow)
def test_trigger_threshold_uses_transformed_output_domain() -> None: def test_trigger_threshold_uses_transformed_output_domain() -> None:
for threshold in (0, 0xFFFF): for threshold in (0, 0xFFFF):
trigger = config_manager.TriggerConfig( trigger = config_manager.TriggerConfig(