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

View file

@ -57,18 +57,8 @@ bool valid_control_output(uint8_t output) {
output == CONTROLLER_PROFILE_NO_BUTTON;
}
bool valid_macro_step(const ControllerProfileMacroStep& step,
bool must_end) {
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 ||
bool valid_macro_step(const ControllerProfileMacroStep& step) {
if ((step.override_flags & ~kMacroOverrideMask) != 0 ||
step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) {
return false;
}
@ -95,6 +85,112 @@ bool valid_macro_step(const ControllerProfileMacroStep& step,
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,
size_t output_size, size_t field_offset,
const uint8_t* field, size_t field_size) {
@ -164,12 +260,13 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
ControllerProfileConfirmationPolicy::kRumbleAndLed;
profile.switching_chord = 0;
profile.motion_toggle_chord = 0;
profile.macro_trigger_mask = 0;
profile.macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
profile.macro_step_count = 1;
for (ControllerProfileMacro& macro : profile.macros) {
macro = {};
macro.cancel_control = CONTROLLER_PROFILE_NO_BUTTON;
}
profile.macro_step_count = 0;
for (ControllerProfileMacroStep& step : profile.macro_steps) {
step = {};
step.type = ControllerProfileMacroStepType::kEnd;
}
return profile;
}
@ -207,27 +304,65 @@ bool controller_profile_validate(const ControllerProfile& profile) {
if (static_cast<uint8_t>(profile.confirmation_policy) >
static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::kRumbleAndLed) ||
!valid_control_output(profile.macro_cancel) ||
(profile.switching_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) {
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) {
if (static_cast<uint8_t>(mode) >
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
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;
}
@ -269,37 +404,53 @@ bool controller_profile_encode(const ControllerProfile& profile,
output[74] = static_cast<uint8_t>(profile.confirmation_policy);
output[75] = static_cast<uint8_t>(
((profile.switching_chord >> 16) & 0x03u) |
(((profile.macro_trigger_mask >> 16) & 0x03u) << 2) |
(((profile.motion_toggle_chord >> 16) & 0x03u) << 4));
profile_write_u16(
&output[76], static_cast<uint16_t>(profile.switching_chord));
profile_write_u16(
&output[78], static_cast<uint16_t>(profile.macro_trigger_mask));
output[80] = profile.macro_step_count;
output[81] = profile.macro_cancel;
profile_write_u16(
&output[98], static_cast<uint16_t>(profile.motion_toggle_chord));
&output[78], static_cast<uint16_t>(profile.motion_toggle_chord));
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
output[82 + index] =
output[80 + index] =
static_cast<uint8_t>(profile.turbo_modes[index]);
}
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
const ControllerProfileMacroStep& step = profile.macro_steps[index];
uint8_t* encoded = &output[100 + index * 19];
encoded[0] = static_cast<uint8_t>(step.type);
encoded[1] = step.override_flags;
profile_write_u16(&encoded[2], step.duration_ms);
profile_write_u16(&encoded[4], step.output_button_mask);
profile_write_i16(&encoded[6], step.left_stick_x);
profile_write_i16(&encoded[8], step.left_stick_y);
profile_write_i16(&encoded[10], step.right_stick_x);
profile_write_i16(&encoded[12], step.right_stick_y);
profile_write_u16(&encoded[14], step.left_trigger);
profile_write_u16(&encoded[16], step.right_trigger);
size_t stream_offset = 0;
for (uint8_t macro_index = 0;
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
const ControllerProfileMacro& macro =
profile.macros[macro_index];
uint8_t* descriptor =
&output[96 + macro_index *
CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE];
profile_write_u16(
descriptor, static_cast<uint16_t>(macro.trigger_mask));
const uint8_t cancel =
macro.cancel_control == CONTROLLER_PROFILE_NO_BUTTON
? 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,
@ -309,27 +460,32 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
return false;
}
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 ||
schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION ||
profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
!profile_bytes_are_zero(&input[31], 5) ||
!profile_bytes_are_zero(&input[47], 5) ||
!trigger_extension_valid ||
(has_action_trigger_bits ? (input[75] & 0xc0u) != 0
: input[75] != 0) ||
(!has_control_mapping && input[81] != 0) ||
!profile_bytes_are_zero(&input[47], 5)) {
return false;
}
const bool has_control_mapping =
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 &&
!profile_bytes_are_zero(&input[98], 2)) ||
!profile_bytes_are_zero(&input[252], 4)) {
(input[81] != 0 ||
!profile_bytes_are_zero(&input[98], 2))) ||
(!sparse_macros &&
!profile_bytes_are_zero(&input[252], 4))) {
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) {
ControllerProfileStickConfiguration& stick = profile.sticks[index];
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;
}
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.confirmation_policy =
static_cast<ControllerProfileConfirmationPolicy>(input[74]);
profile.switching_chord = profile_read_u16(&input[76]);
profile.motion_toggle_chord =
has_control_mapping ? profile_read_u16(&input[98]) : 0;
if (has_control_mapping) {
profile.macro_trigger_mask = profile_read_u16(&input[78]);
profile.macro_cancel = input[81];
if (sparse_macros) {
profile.switching_chord =
profile_read_u16(&input[76]) |
(static_cast<uint32_t>(input[75] & 0x03u) << 16);
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 {
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;
}
profile.macro_trigger_mask =
input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
? static_cast<uint32_t>(1u << input[78])
: 0;
profile.macro_cancel = input[79];
}
if (has_action_trigger_bits) {
profile.switching_chord |=
static_cast<uint32_t>(input[75] & 0x03u) << 16;
profile.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;
}
profile.macro_step_count = input[80];
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
profile.turbo_modes[index] =
static_cast<ControllerProfileTurboMode>(input[82 + index]);
}
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
ControllerProfileMacroStep& step = profile.macro_steps[index];
const uint8_t* encoded = &input[100 + index * 19];
if (encoded[18] != 0) {
return false;
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY;
++index) {
const uint8_t* encoded = &input[100 + index * 19];
const bool must_end = index >= legacy_step_count - 1;
if (must_end) {
if (encoded[0] != 1 ||
!profile_bytes_are_zero(&encoded[1], 18)) {
return false;
}
continue;
}
if (encoded[0] != 0 || encoded[18] != 0) {
return false;
}
ControllerProfileMacroStep& step = profile.macro_steps[index];
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 (!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)) {
return false;

View file

@ -8,14 +8,20 @@
constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
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_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 uint8_t CONTROLLER_PROFILE_COUNT = 4;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16;
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18;
constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16;
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 uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff;
@ -74,10 +80,6 @@ enum class ControllerProfileTurboMode : uint8_t {
kAutoBurst = 2,
};
enum class ControllerProfileMacroStepType : uint8_t {
kState = 0,
kEnd = 1,
};
enum ControllerProfileMacroOverride : uint8_t {
kControllerProfileOverrideButtons = 1u << 0,
@ -109,8 +111,6 @@ struct ControllerProfileTriggerConfiguration {
};
struct ControllerProfileMacroStep {
ControllerProfileMacroStepType type =
ControllerProfileMacroStepType::kEnd;
uint8_t override_flags = 0;
uint16_t duration_ms = 0;
uint16_t output_button_mask = 0;
@ -122,6 +122,13 @@ struct ControllerProfileMacroStep {
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 {
uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
ControllerProfileStickConfiguration sticks[2]{};
@ -132,9 +139,8 @@ struct ControllerProfile {
ControllerProfileConfirmationPolicy::kRumbleAndLed;
uint32_t switching_chord = 0;
uint32_t motion_toggle_chord = 0;
uint32_t macro_trigger_mask = 0;
uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
uint8_t macro_step_count = 1;
ControllerProfileMacro macros[CONTROLLER_PROFILE_MACRO_COUNT]{};
uint8_t macro_step_count = 0;
ControllerProfileTurboMode
turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
ControllerProfileMacroStep

View file

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

View file

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

View file

@ -102,7 +102,8 @@ TRANSPORT_BLE = 2
PROFILE_LEGACY_SCHEMA_VERSION = 1
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3
PROFILE_SCHEMA_VERSION = 4
PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4
PROFILE_SCHEMA_VERSION = 5
PROFILE_SIZE = 256
PROFILE_CAPACITY = 4
PROFILE_IDENTITY_CAPACITY = 16
@ -110,7 +111,11 @@ PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1
CONTROLLER_IDENTITY_SIZE = 14
PROFILE_LIST_ROW_SIZE = 16
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_MAXIMUM_WAIT_MS = 10000
PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000
@ -856,6 +861,63 @@ class MacroStep:
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]:
return {
"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)
class ControllerProfile:
button_map: tuple[int, ...]
@ -968,9 +1091,7 @@ class ControllerProfile:
confirmation_policy: int
switching_chord: int
motion_toggle_chord: int
macro_trigger_mask: int
macro_cancel: int
macro_steps: tuple[MacroStep, ...]
macros: tuple[ControllerMacro, ...]
turbo_modes: tuple[int, ...]
def __post_init__(self) -> None:
@ -1029,27 +1150,33 @@ class ControllerProfile:
0,
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 (
type(self.macro_steps) is not tuple
or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY
or not all(isinstance(step, MacroStep) for step in self.macro_steps)
type(self.macros) is not tuple
or len(self.macros) != PROFILE_MACRO_COUNT
or not all(isinstance(macro, ControllerMacro) for macro in self.macros)
):
raise ConfigManagerError("macro must contain one to eight steps")
if any(step.step_type != 0 for step in self.macro_steps[:-1]):
raise ConfigManagerError("only the final macro step may be end")
if self.macro_steps[-1] != MacroStep.end():
raise ConfigManagerError("final macro step must be canonical end")
raise ConfigManagerError("profile must contain four macros")
total_steps = sum(len(macro.steps) for macro in self.macros)
if total_steps > PROFILE_MACRO_STEP_CAPACITY:
raise ConfigManagerError(
"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(
self.turbo_modes
) != len(LOGICAL_BUTTONS):
@ -1085,9 +1212,9 @@ class ControllerProfile:
confirmation_policy=3,
switching_chord=0,
motion_toggle_chord=0,
macro_trigger_mask=0,
macro_cancel=PROFILE_NONE_BUTTON,
macro_steps=(MacroStep.end(),),
macros=tuple(
ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT)
),
turbo_modes=(0,) * len(LOGICAL_BUTTONS),
)
@ -1106,35 +1233,25 @@ class ControllerProfile:
has_control_mapping = (
version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
)
has_action_trigger_bits = version == PROFILE_SCHEMA_VERSION
if payload[252:] != bytes(4):
raise ConfigManagerError("profile reserved fields must be zero")
if has_action_trigger_bits:
has_action_controls = (
version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION
)
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:
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")
if not has_control_mapping and (
payload[81] != 0 or payload[98:100] != b"\x00\x00"
):
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(
payload[52:62], schema_version=version, source_index=0
)
@ -1144,29 +1261,116 @@ class ControllerProfile:
if version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
if has_control_mapping:
macro_trigger_mask = struct.unpack_from("<H", payload, 78)[0]
macro_cancel = payload[81]
motion_toggle_chord = struct.unpack_from("<H", payload, 98)[0]
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")
macro_trigger_mask = (
0
if legacy_trigger == PROFILE_NONE_BUTTON
else 1 << legacy_trigger
if sparse_macros:
switching_chord = (
struct.unpack_from("<H", payload, 76)[0]
| ((payload[75] & 0x03) << 16)
)
macro_cancel = payload[79]
motion_toggle_chord = 0
switching_chord = struct.unpack_from("<H", payload, 76)[0]
if has_action_trigger_bits:
action_bits = payload[75]
switching_chord |= (action_bits & 0x03) << 16
macro_trigger_mask |= ((action_bits >> 2) & 0x03) << 16
motion_toggle_chord |= ((action_bits >> 4) & 0x03) << 16
motion_toggle_chord = (
struct.unpack_from("<H", payload, 78)[0]
| (((payload[75] >> 4) & 0x03) << 16)
)
turbo_modes = tuple(payload[80:96])
macros: list[ControllerMacro] = []
stream_offset = 0
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(
button_map=tuple(payload[4:20]),
left_stick=StickConfig.from_bytes(payload[20:36]),
@ -1178,12 +1382,9 @@ class ControllerProfile:
confirmation_policy=payload[74],
switching_chord=switching_chord,
motion_toggle_chord=motion_toggle_chord,
macro_trigger_mask=macro_trigger_mask,
macro_cancel=macro_cancel,
macro_steps=all_steps[:macro_count],
turbo_modes=tuple(payload[82:98]),
macros=tuple(macros),
turbo_modes=turbo_modes,
)
def to_bytes(self) -> bytes:
payload = bytearray(PROFILE_SIZE)
struct.pack_into(
@ -1203,23 +1404,38 @@ class ControllerProfile:
)
payload[75] = (
((self.switching_chord >> 16) & 0x03)
| (((self.macro_trigger_mask >> 16) & 0x03) << 2)
| (((self.motion_toggle_chord >> 16) & 0x03) << 4)
)
struct.pack_into("<H", payload, 76, self.switching_chord & 0xFFFF)
struct.pack_into("<H", payload, 78, self.macro_trigger_mask & 0xFFFF)
payload[80] = len(self.macro_steps)
payload[81] = self.macro_cancel
payload[82:98] = bytes(self.turbo_modes)
struct.pack_into("<H", payload, 98, self.motion_toggle_chord & 0xFFFF)
for index in range(PROFILE_MACRO_STEP_CAPACITY):
step = (
self.macro_steps[index]
if index < len(self.macro_steps)
else MacroStep.end()
struct.pack_into("<H", payload, 78, self.motion_toggle_chord & 0xFFFF)
payload[80:96] = bytes(self.turbo_modes)
stream = bytearray()
for macro_index, macro in enumerate(self.macros):
encoded_steps = b"".join(
step.to_sparse_bytes() for step in macro.steps
)
offset = 100 + index * PROFILE_MACRO_STEP_SIZE
payload[offset : offset + PROFILE_MACRO_STEP_SIZE] = step.to_bytes()
descriptor_offset = 96 + macro_index * 6
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)
def to_json_object(self) -> dict[str, Any]:
@ -1249,13 +1465,9 @@ class ControllerProfile:
"motion_toggle_chord": _control_mask_to_json(
self.motion_toggle_chord
),
"macro": {
"trigger": _control_mask_to_json(self.macro_trigger_mask),
"cancel": _control_name(self.macro_cancel),
"steps": [
step.to_json_object() for step in self.macro_steps
],
},
"macros": [
macro.to_json_object() for macro in self.macros
],
"turbo": {
name: TURBO_MODES[self.turbo_modes[index]]
for index, name in enumerate(LOGICAL_BUTTONS)
@ -1280,7 +1492,7 @@ class ControllerProfile:
or schema_version > PROFILE_SCHEMA_VERSION
):
raise ConfigManagerError("unsupported profile schema")
fields = (
fields = [
"schema_version",
"size",
"button_map",
@ -1288,11 +1500,13 @@ class ControllerProfile:
"triggers",
"rumble",
"switching_chord",
"macro",
"turbo",
)
]
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")
if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE:
raise ConfigManagerError("unsupported profile schema")
@ -1310,19 +1524,9 @@ class ControllerProfile:
("weak_scale", "strong_scale", "confirmation_policy"),
"profile.rumble",
)
macro = _require_object(
obj["macro"], ("trigger", "cancel", "steps"), "profile.macro"
)
turbo = _require_object(
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(
triggers["left"],
"profile.triggers.left",
@ -1338,55 +1542,93 @@ class ControllerProfile:
if schema_version == PROFILE_LEGACY_SCHEMA_VERSION:
left_trigger = _migrate_legacy_trigger_threshold(left_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:
switching_chord = _control_mask_from_json(
obj["switching_chord"], "profile.switching_chord"
)
motion_toggle_chord = _control_mask_from_json(
obj["motion_toggle_chord"],
"profile.motion_toggle_chord",
)
macro_trigger_mask = _control_mask_from_json(
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"
macro_values = obj["macros"]
if type(macro_values) is not list or len(macro_values) != PROFILE_MACRO_COUNT:
raise ConfigManagerError("profile.macros must contain four macros")
macros = tuple(
ControllerMacro.from_json_object(
macro, f"profile.macros[{index}]"
)
for index, macro in enumerate(macro_values)
)
else:
switching_chord = _button_mask_from_json(
obj["switching_chord"], "profile.switching_chord"
macro = _require_object(
obj["macro"], ("trigger", "cancel", "steps"), "profile.macro"
)
motion_toggle_chord = 0
legacy_trigger = _button_index(
macro["trigger"], "profile.macro.trigger"
steps = macro["steps"]
if type(steps) is not list or not (
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 = (
0
if legacy_trigger == PROFILE_NONE_BUTTON
else 1 << legacy_trigger
)
macro_cancel = _button_index(
macro["cancel"], "profile.macro.cancel"
if decoded_steps[-1] != MacroStep.end() or any(
step.step_type != 0 for step in decoded_steps[:-1]
):
raise ConfigManagerError(
"legacy macro must end with one canonical end step"
)
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(
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
),
left_stick=StickConfig.from_json_object(
@ -1398,16 +1640,10 @@ class ControllerProfile:
left_trigger=left_trigger,
right_trigger=right_trigger,
weak_rumble_scale=_require_int(
rumble["weak_scale"],
"profile.rumble.weak_scale",
0,
0xFF,
rumble["weak_scale"], "profile.rumble.weak_scale", 0, 0xFF
),
strong_rumble_scale=_require_int(
rumble["strong_scale"],
"profile.rumble.strong_scale",
0,
0xFF,
rumble["strong_scale"], "profile.rumble.strong_scale", 0, 0xFF
),
confirmation_policy=_require_enum(
rumble["confirmation_policy"],
@ -1416,14 +1652,7 @@ class ControllerProfile:
),
switching_chord=switching_chord,
motion_toggle_chord=motion_toggle_chord,
macro_trigger_mask=macro_trigger_mask,
macro_cancel=macro_cancel,
macro_steps=tuple(
MacroStep.from_json_object(
step, f"profile.macro.steps[{index}]"
)
for index, step in enumerate(steps)
),
macros=macros,
turbo_modes=tuple(
_require_enum(
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_TRIGGER_THRESHOLD_SCHEMA_VERSION,
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
PROFILE_ACTION_CONTROL_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION,
):
raise ConfigManagerError("unsupported profile-list schema")
@ -1823,6 +2053,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile:
PROFILE_LEGACY_SCHEMA_VERSION,
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
PROFILE_ACTION_CONTROL_SCHEMA_VERSION,
PROFILE_SCHEMA_VERSION,
):
raise ConfigManagerError("unsupported profile schema")

View file

@ -115,8 +115,15 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"rumble_policies": list(config_manager.RUMBLE_POLICIES),
"turbo_modes": list(config_manager.TURBO_MODES),
"macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES),
"macro_count": config_manager.PROFILE_MACRO_COUNT,
"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": (
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-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-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; }
@ -410,6 +420,8 @@ input[type="range"] { width: 100%; accent-color: var(--cyan); }
.builtin-actions { grid-template-columns: 1fr; }
.panel-heading > p { text-align: left; }
.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; }
}
@media (max-width: 470px) {

View file

@ -9,6 +9,7 @@ const state = {
original: "",
active: false,
selectedButton: "south",
selectedMacro: 0,
token: "",
busy: false,
};
@ -115,11 +116,18 @@ function setBusy(busy) {
elements.refresh.disabled = busy;
elements.identity.disabled = busy;
if (state.schema && state.profile) {
const stateStepCount = state.profile.macro.steps.filter(
(step) => step.type === "state"
).length;
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.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) => {
@ -431,11 +439,30 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
</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() {
const controllerStyle =
state.identities[state.identityIndex]?.controller?.style || "generic";
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 = [
actionChordCard(
"profile_switch",
@ -451,67 +478,89 @@ function renderMacro() {
state.profile.motion_toggle_chord,
state.schema.default_motion_toggle_chord
),
actionChordCard(
"custom_macro",
"Run custom macro",
"Start the editable sequence below.",
macro.trigger,
null
),
].join("");
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">
<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">
${buttonOptions(macro.cancel, true, state.schema.controls)}
</select>
</div>`;
elements.macroSteps.innerHTML = macro.steps.map((step, index) => {
if (step.type === "end") {
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>`;
}
const overrides = new Set(step.overrides);
const outputButtons = new Set(step.output_buttons);
return `
<div class="macro-step">
<div class="step-header">
<span class="step-number">State step ${index + 1}</span>
<button class="remove-step" type="button" data-remove-step="${index}">Remove</button>
</div>
<div class="step-grid">
${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")}
${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))}
${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))}
${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))}
${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("")}
elements.macroControls.querySelectorAll("[data-macro-index]").forEach((button) => {
button.onclick = () => {
state.selectedMacro = Number(button.dataset.macroIndex);
renderMacro();
};
});
elements.macroSteps.innerHTML = macro.steps.length === 0
? '<div class="macro-step end"><p class="field-help">No steps yet. Add a state step to build this macro.</p></div>'
: macro.steps.map((step, index) => {
const overrides = new Set(step.overrides);
const outputButtons = new Set(step.output_buttons);
return `
<div class="macro-step">
<div class="step-header">
<span class="step-number">Macro ${state.selectedMacro + 1} · step ${index + 1} · ${macroStepWireSize(step)} bytes</span>
<button class="remove-step" type="button" data-remove-step="${index}">Remove</button>
</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 class="step-grid">
${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")}
${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))}
${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))}
${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))}
${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>
</div>`;
}).join("");
const stateStepCount = macro.steps.filter((step) => step.type === "state").length;
elements.addMacroStep.disabled = state.busy || stateStepCount >= state.schema.maximum_macro_state_steps;
<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 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() {
@ -595,7 +644,7 @@ function handleFormChange(event) {
const fields = {
profile_switch: [state.profile, "switching_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 defaults = {
@ -611,16 +660,16 @@ function handleFormChange(event) {
} else if (kind === "turbo") {
state.profile.turbo[target.dataset.name] = target.value;
} 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") {
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") {
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);
target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name);
step.output_buttons = state.schema.buttons.filter((name) => selected.has(name));
} 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);
target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name);
step.overrides = state.schema.macro_overrides.filter((name) => selected.has(name));
@ -662,10 +711,17 @@ elements.resetDraft.addEventListener("click", () => {
});
elements.addMacroStep.addEventListener("click", () => {
const steps = state.profile.macro.steps;
const stateCount = steps.filter((step) => step.type === "state").length;
if (stateCount >= state.schema.maximum_macro_state_steps) return;
steps.splice(steps.length - 1, 0, {
const steps = state.profile.macros[state.selectedMacro].steps;
const totalSteps = state.profile.macros.reduce(
(sum, macro) => sum + macro.steps.length, 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",
overrides: ["buttons"],
duration_ms: 100,
@ -681,7 +737,7 @@ elements.addMacroStep.addEventListener("click", () => {
elements.macroSteps.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-step]");
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();
updateDirtyState();
});

View file

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

View file

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

View file

@ -39,36 +39,28 @@ ControllerProfile profile_with_macro(
ControllerProfileLogicalButton::kCapture) {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.macro_trigger_mask = button_bit(trigger);
profile.macro_cancel = button_index(cancel);
profile.macros[0].trigger_mask = button_bit(trigger);
profile.macros[0].cancel_control = button_index(cancel);
profile.macros[0].first_step = 0;
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() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_step_count = 3;
profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile.macro_step_count = 2;
profile.macros[0].step_count = 2;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 10;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth) |
button_bit(ControllerProfileLogicalButton::kDpadUp);
profile.macro_steps[1].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[1].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[1].duration_ms = 20;
profile.macro_steps[1].output_button_mask = 0;
set_end(&profile, 2);
ControllerSyntheticInputContext context{};
ControllerState input =
@ -103,20 +95,18 @@ void test_immediate_press_release_dpad_and_explicit_end() {
void test_macro_trigger_chord_requires_every_button() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_trigger_mask =
profile.macros[0].trigger_mask =
button_bit(ControllerProfileLogicalButton::kSouth) |
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
profile.macro_cancel =
profile.macros[0].cancel_control =
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
profile.macro_step_count = 2;
profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile.macro_step_count = 1;
profile.macros[0].step_count = 1;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth);
set_end(&profile, 1);
ControllerSyntheticInputContext context{};
ControllerState input =
@ -151,9 +141,9 @@ void test_macro_trigger_chord_requires_every_button() {
void test_optional_field_overrides_and_motion_preservation() {
ControllerProfile profile =
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];
step.type = ControllerProfileMacroStepType::kState;
step.override_flags = kControllerProfileOverrideLeftStick |
kControllerProfileOverrideRightStick |
kControllerProfileOverrideLeftTrigger |
@ -165,7 +155,6 @@ void test_optional_field_overrides_and_motion_preservation() {
step.right_stick_y = INT16_MAX;
step.left_trigger = 0;
step.right_trigger = UINT16_MAX;
set_end(&profile, 1);
ControllerState input = state_with_buttons(
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() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_step_count = 4;
profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile.macro_step_count = 3;
profile.macros[0].step_count = 3;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 0;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth);
profile.macro_steps[1].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[1].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[1].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS;
profile.macro_steps[1].output_button_mask =
button_bit(ControllerProfileLogicalButton::kEast);
profile.macro_steps[2].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[2].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[2].duration_ms = 0;
profile.macro_steps[2].output_button_mask =
button_bit(ControllerProfileLogicalButton::kWest);
set_end(&profile, 3);
ControllerSyntheticInputContext context{};
ControllerProfileTransformResult output =
@ -252,7 +235,8 @@ void test_zero_max_wait_and_scheduled_catch_up() {
!context.macro_active,
"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].output_button_mask =
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,
"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].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth);
set_end(&profile, 1);
context = {};
constexpr uint32_t macro_near_wrap = UINT32_MAX - 5u;
output = controller_synthetic_input_apply(
@ -305,15 +289,13 @@ void test_consumption_cancel_precedence_and_duplicate_contributors() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSelect,
ControllerProfileLogicalButton::kCapture);
profile.macro_step_count = 2;
profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile.macro_step_count = 1;
profile.macros[0].step_count = 1;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kDpadLeft);
set_end(&profile, 1);
profile.button_map[button_index(ControllerProfileLogicalButton::kSouth)] =
button_index(ControllerProfileLogicalButton::kNorth);
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");
}
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() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
@ -507,6 +536,7 @@ int main() {
test_zero_max_wait_and_scheduled_catch_up();
test_consumption_cancel_precedence_and_duplicate_contributors();
test_turbo_rate_release_and_uint32_wrap();
test_multiple_macro_bindings_share_step_pool();
test_auto_burst_toggle_cancel_and_external_cancel();
test_four_contexts_are_isolated();
return 0;

View file

@ -519,26 +519,70 @@ def custom_profile() -> config_manager.ControllerProfile:
confirmation_policy=2,
switching_chord=(1 << 6) | (1 << 16),
motion_toggle_chord=(1 << 5) | (1 << 17),
macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17),
macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"),
macro_steps=(
config_manager.MacroStep(
0,
config_manager.MACRO_OVERRIDE_MASK,
config_manager.PROFILE_MAXIMUM_WAIT_MS,
(1 << 0) | (1 << 12),
-32768,
32767,
-1000,
1000,
12345,
54321,
macros=(
config_manager.ControllerMacro(
(1 << 0) | (1 << 16) | (1 << 17),
config_manager.LOGICAL_CONTROLS.index("right_trigger"),
(
config_manager.MacroStep(
0,
config_manager.MACRO_OVERRIDE_MASK,
config_manager.PROFILE_MAXIMUM_WAIT_MS,
(1 << 0) | (1 << 12),
-32768,
32767,
-1000,
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,
)
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:
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_SIZE,
)
assert encoded[75] == 0x2D
assert encoded[75] == 0x21
assert encoded[60:62] == bytes((17, 0))
assert encoded[70:72] == bytes((3, 0))
assert encoded[81] == config_manager.LOGICAL_CONTROLS.index("right_trigger")
assert struct.unpack_from("<H", encoded, 98)[0] == (
assert struct.unpack_from("<H", encoded, 78)[0] == (
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 config_manager.ControllerProfile.from_bytes(encoded) == profile
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)
assert decoded == profile
assert decoded.to_json() == serialized
legacy_default_wire = bytearray(default_wire)
struct.pack_into(
"<H",
legacy_default_wire,
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
legacy_default_wire = legacy_profile_wire(
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[98:100] = b"\x00\x00"
struct.pack_into(
@ -1198,26 +1234,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
)
previous_wire = bytearray(default_wire)
struct.pack_into(
"<H",
previous_wire,
0,
previous_wire = legacy_profile_wire(
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(
previous_wire
)
assert migrated_previous.left_trigger.output == 16
assert migrated_previous.right_trigger.output == 17
assert migrated_previous.macro_trigger_mask == 1
assert migrated_previous.macro_cancel == 1
assert migrated_previous.macros[0].trigger_mask == 1
assert migrated_previous.macros[0].cancel_control == 1
assert migrated_previous.motion_toggle_chord == 0
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["triggers"]["left"]["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"] = (
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.output == 16
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
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:
for threshold in (0, 0xFFFF):
trigger = config_manager.TriggerConfig(