diff --git a/CMakeLists.txt b/CMakeLists.txt index b35736d..4df380a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") controller_identity.cpp controller_profile.cpp controller_profile_transform.cpp + controller_synthetic_input.cpp controller_profile_runtime.cpp profile_storage.cpp profile_service.cpp diff --git a/configuration_service.cpp b/configuration_service.cpp index d41199c..b71a1e0 100644 --- a/configuration_service.cpp +++ b/configuration_service.cpp @@ -14,6 +14,7 @@ bool g_prepared = false; ConfigurationStorage g_storage; ConfigurationTransaction g_transaction; ConfigurationServiceSnapshot g_snapshot; +uint32_t g_published_reset_generation = 0; bool g_has_committed = false; uint32_t g_last_commit_ms = 0; @@ -45,6 +46,7 @@ void configuration_service_prepare() { } critical_section_init(&g_lock); g_snapshot = {}; + __atomic_store_n(&g_published_reset_generation, 0, __ATOMIC_RELAXED); g_snapshot.configuration = adapter_configuration_default(); g_transaction.clear(); g_prepared = true; @@ -176,6 +178,10 @@ ConfigurationTransactionStatus configuration_service_reset( ++g_snapshot.reset_generation; } g_snapshot.transaction = g_transaction.snapshot(); + if (status == ConfigurationTransactionStatus::kPending) { + __atomic_store_n(&g_published_reset_generation, + g_snapshot.reset_generation, __ATOMIC_RELEASE); + } critical_section_exit(&g_lock); return status; } @@ -188,3 +194,7 @@ void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { *output = g_snapshot; critical_section_exit(&g_lock); } + +uint32_t configuration_service_reset_generation() { + return __atomic_load_n(&g_published_reset_generation, __ATOMIC_ACQUIRE); +} diff --git a/configuration_service.h b/configuration_service.h index 9eb8ceb..e540c26 100644 --- a/configuration_service.h +++ b/configuration_service.h @@ -36,3 +36,7 @@ ConfigurationTransactionStatus configuration_service_commit( ConfigurationTransactionStatus configuration_service_reset( uint32_t transaction_id); void configuration_service_snapshot(ConfigurationServiceSnapshot* output); + +// Lock-free publication for the report path. The value changes as soon as a +// configuration reset is accepted. +uint32_t configuration_service_reset_generation(); diff --git a/controller_profile_runtime.cpp b/controller_profile_runtime.cpp index 681c42c..68da390 100644 --- a/controller_profile_runtime.cpp +++ b/controller_profile_runtime.cpp @@ -1,6 +1,8 @@ #include "controller_profile_runtime.h" +#include "configuration_service.h" #include "controller_identity.h" +#include "controller_synthetic_input.h" #include "profile_service.h" namespace { @@ -12,6 +14,10 @@ struct ControllerProfileRuntimeContext { uint32_t database_generation = 0; uint8_t active_profile_index = 0; ControllerProfile profile{}; + ControllerSyntheticInputContext synthetic{}; + bool runtime_generations_initialized = false; + AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; + uint32_t configuration_reset_generation = 0; }; ControllerProfileRuntimeContext @@ -41,7 +47,8 @@ void clear_context(ControllerProfileRuntimeContext* context) { void refresh_profile(ControllerProfileRuntimeContext* context, const ControllerIdentity& identity, uint32_t connection_generation, - uint32_t observed_database_generation) { + uint32_t observed_database_generation, + uint16_t current_input_button_mask) { ProfileServiceActiveProfileSnapshot snapshot{}; profile_service_active_profile_snapshot(identity, &snapshot); @@ -53,6 +60,8 @@ void refresh_profile(ControllerProfileRuntimeContext* context, : observed_database_generation; context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0; context->profile = snapshot.valid ? snapshot.profile : g_default_profile; + controller_synthetic_input_cancel(&context->synthetic, + current_input_button_mask); } ControllerProfileRuntimeContext* update_context( @@ -75,9 +84,10 @@ ControllerProfileRuntimeContext* update_context( context.connection_generation != snapshot.connection_generation || !controller_identity_equal(context.identity, snapshot.identity) || context.database_generation != database_generation) { - refresh_profile(&context, snapshot.identity, - snapshot.connection_generation, - database_generation); + refresh_profile( + &context, snapshot.identity, snapshot.connection_generation, + database_generation, + controller_profile_extract_button_mask(snapshot.state)); } return &context; } @@ -94,14 +104,32 @@ void controller_profile_runtime_reset() { } ControllerProfileTransformResult controller_profile_runtime_transform( - uint8_t slot, const Bluepad32SlotSnapshot& snapshot) { + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms, + AdapterUsbMode output_mode) { initialize_defaults(); ControllerProfileRuntimeContext* context = update_context(slot, snapshot); if (context == nullptr) { return g_neutral_output; } - return controller_profile_transform(snapshot.state, context->profile); + + const uint32_t reset_generation = + configuration_service_reset_generation(); + if (!context->runtime_generations_initialized) { + context->runtime_generations_initialized = true; + context->output_mode = output_mode; + context->configuration_reset_generation = reset_generation; + } else if (context->output_mode != output_mode || + context->configuration_reset_generation != + reset_generation) { + controller_synthetic_input_cancel( + &context->synthetic, + controller_profile_extract_button_mask(snapshot.state)); + context->output_mode = output_mode; + context->configuration_reset_generation = reset_generation; + } + return controller_synthetic_input_apply( + &context->synthetic, snapshot.state, context->profile, now_ms); } ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( diff --git a/controller_profile_runtime.h b/controller_profile_runtime.h index 4eb246c..85e6509 100644 --- a/controller_profile_runtime.h +++ b/controller_profile_runtime.h @@ -2,6 +2,8 @@ #include +#include "adapter_usb_mode.h" + #include "bluepad32_input_backend.h" #include "controller_profile_transform.h" @@ -17,10 +19,12 @@ struct ControllerProfileRuntimeLocalConfirmation { void controller_profile_runtime_reset(); // Refresh a slot only when its connection key or database generation changes, -// then transform the raw snapshot. Inactive snapshots return neutral output and -// invalidate the slot immediately. +// cancel synthetic state on every runtime invalidation, then apply the shared +// transform and synthetic pipeline. Inactive snapshots return neutral output +// and invalidate the slot immediately. ControllerProfileTransformResult controller_profile_runtime_transform( - uint8_t slot, const Bluepad32SlotSnapshot& snapshot); + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms, + AdapterUsbMode output_mode); // Refresh from the current slot snapshot and scale host-originated rumble. ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( diff --git a/controller_synthetic_input.cpp b/controller_synthetic_input.cpp new file mode 100644 index 0000000..d777aff --- /dev/null +++ b/controller_synthetic_input.cpp @@ -0,0 +1,237 @@ +#include "controller_synthetic_input.h" + +namespace { + +constexpr uint32_t kTurboTransitionsPerSecond = 30; +constexpr uint32_t kTurboPhaseUnitsPerTransition = 1000; + +constexpr uint16_t button_bit(uint8_t button) { + return static_cast(1u << button); +} + +bool is_bound_button(uint8_t button) { + return button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; +} + +void clear_binding(ControllerSyntheticBindingState* binding) { + *binding = {}; +} + +void start_binding(ControllerSyntheticBindingState* binding, + uint32_t now_ms) { + binding->active = true; + binding->phase_on = true; + binding->phase_units = 0; + binding->last_update_ms = now_ms; +} + +void advance_binding(ControllerSyntheticBindingState* binding, + uint32_t now_ms) { + const uint32_t elapsed_ms = now_ms - binding->last_update_ms; + const uint64_t total_units = + static_cast(binding->phase_units) + + static_cast(elapsed_ms) * kTurboTransitionsPerSecond; + const uint64_t transition_count = + total_units / kTurboPhaseUnitsPerTransition; + if ((transition_count & 1u) != 0) { + binding->phase_on = !binding->phase_on; + } + binding->phase_units = static_cast( + total_units - + transition_count * kTurboPhaseUnitsPerTransition); + binding->last_update_ms = now_ms; +} + +bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) { + return now_ms - deadline_ms < (UINT32_MAX / 2u + 1u); +} + +void stop_macro(ControllerSyntheticInputContext* context) { + context->macro_active = false; + 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); + return false; + } + context->macro_active = true; + context->macro_step_index = 0; + context->macro_deadline_ms = + now_ms + profile.macro_steps[0].duration_ms; + return true; +} + +void advance_macro(ControllerSyntheticInputContext* context, + const ControllerProfile& profile, uint32_t now_ms) { + for (uint8_t transition = 0; + transition < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; + ++transition) { + if (!context->macro_active || + context->macro_step_index >= profile.macro_step_count) { + stop_macro(context); + return; + } + const ControllerProfileMacroStep& current = + profile.macro_steps[context->macro_step_index]; + if (current.type != ControllerProfileMacroStepType::kState) { + stop_macro(context); + return; + } + if (!deadline_reached(now_ms, context->macro_deadline_ms)) { + return; + } + + const uint8_t next_index = + static_cast(context->macro_step_index + 1u); + if (next_index >= profile.macro_step_count || + profile.macro_steps[next_index].type == + ControllerProfileMacroStepType::kEnd) { + stop_macro(context); + return; + } + context->macro_step_index = next_index; + context->macro_deadline_ms += + profile.macro_steps[next_index].duration_ms; + } +} + +void apply_macro_override(const ControllerProfileMacroStep& step, + ControllerState* output) { + if ((step.override_flags & kControllerProfileOverrideButtons) != 0) { + controller_profile_apply_button_mask(step.output_button_mask, + output); + } + if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) { + output->left_stick_x = step.left_stick_x; + output->left_stick_y = step.left_stick_y; + } + if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) { + output->right_stick_x = step.right_stick_x; + output->right_stick_y = step.right_stick_y; + } + if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) { + output->left_trigger = step.left_trigger; + } + if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) { + output->right_trigger = step.right_trigger; + } +} + +} // namespace + +void controller_synthetic_input_cancel( + ControllerSyntheticInputContext* context, + uint16_t current_input_button_mask) { + if (context == nullptr) { + return; + } + *context = {}; + context->previous_input_button_mask = current_input_button_mask; +} + +ControllerProfileTransformResult controller_synthetic_input_apply( + ControllerSyntheticInputContext* context, const ControllerState& input, + const ControllerProfile& profile, uint32_t now_ms) { + if (context == nullptr) { + return controller_profile_transform(input, profile); + } + + const uint16_t input_button_mask = + controller_profile_extract_button_mask(input); + uint16_t rising_button_mask = static_cast( + input_button_mask & ~context->previous_input_button_mask); + const bool cancel_pressed = + is_bound_button(profile.macro_cancel) && + (input_button_mask & button_bit(profile.macro_cancel)) != 0; + if (cancel_pressed) { + controller_synthetic_input_cancel(context, input_button_mask); + rising_button_mask = 0; + } + + bool macro_started = false; + if (!cancel_pressed && is_bound_button(profile.macro_trigger) && + (rising_button_mask & button_bit(profile.macro_trigger)) != 0) { + macro_started = start_macro(context, profile, now_ms); + } + if (!macro_started) { + advance_macro(context, profile, now_ms); + } + + uint16_t gated_input_button_mask = 0; + for (uint8_t input_button = 0; + input_button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + ++input_button) { + ControllerSyntheticBindingState& binding = + context->bindings[input_button]; + if (input_button == profile.macro_trigger || + input_button == profile.macro_cancel) { + clear_binding(&binding); + continue; + } + + const uint16_t bit = button_bit(input_button); + const bool pressed = (input_button_mask & bit) != 0; + const bool rising = (rising_button_mask & bit) != 0; + switch (profile.turbo_modes[input_button]) { + case ControllerProfileTurboMode::kOff: + clear_binding(&binding); + if (pressed) { + gated_input_button_mask |= bit; + } + break; + case ControllerProfileTurboMode::kTurbo: + if (!pressed) { + clear_binding(&binding); + break; + } + if (!binding.active) { + start_binding(&binding, now_ms); + } else { + advance_binding(&binding, now_ms); + } + if (binding.phase_on) { + gated_input_button_mask |= bit; + } + break; + case ControllerProfileTurboMode::kAutoBurst: + if (rising) { + if (binding.active) { + clear_binding(&binding); + } else { + start_binding(&binding, now_ms); + } + } else if (binding.active) { + advance_binding(&binding, now_ms); + } + if (binding.active && binding.phase_on) { + gated_input_button_mask |= bit; + } + break; + } + } + + ControllerState gated_input = input; + controller_profile_apply_button_mask(gated_input_button_mask, + &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); + } else { + stop_macro(context); + } + } + + context->previous_input_button_mask = input_button_mask; + return result; +} diff --git a/controller_synthetic_input.h b/controller_synthetic_input.h new file mode 100644 index 0000000..507065c --- /dev/null +++ b/controller_synthetic_input.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "controller_profile_transform.h" + +struct ControllerSyntheticBindingState { + bool active = false; + bool phase_on = false; + uint16_t phase_units = 0; + uint32_t last_update_ms = 0; +}; + +struct ControllerSyntheticInputContext { + bool macro_active = false; + uint8_t macro_step_index = 0; + uint32_t macro_deadline_ms = 0; + uint16_t previous_input_button_mask = 0; + ControllerSyntheticBindingState + bindings[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; +}; + +// Clear every synthetic source. Inputs already held at cancellation remain +// consumed or physical, but edge-triggered Macro and Auto Burst bindings do not +// restart until they are released and pressed again. +void controller_synthetic_input_cancel( + ControllerSyntheticInputContext* context, + uint16_t current_input_button_mask = 0); + +// Apply raw-input consumption, mapped physical contributions, Turbo/Auto Burst +// gating, and finally the active macro step's field overrides. +ControllerProfileTransformResult controller_synthetic_input_apply( + ControllerSyntheticInputContext* context, const ControllerState& input, + const ControllerProfile& profile, uint32_t now_ms); diff --git a/switch-pico.cpp b/switch-pico.cpp index bf889d9..b87c493 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -306,16 +306,24 @@ int main() { case BootselPairingButtonEvent::kNone: break; } + const uint32_t now_ms = + static_cast(to_ms_since_boot(get_absolute_time())); +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY + const AdapterUsbMode output_mode = adapter_host_probe_mode(); +#else + constexpr AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; +#endif for (uint8_t instance = 0; instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { Bluepad32SlotSnapshot snapshot{}; bluepad32_input_backend_snapshot(instance, &snapshot); const ControllerProfileTransformResult transformed = - controller_profile_runtime_transform(instance, snapshot); + controller_profile_runtime_transform( + instance, snapshot, now_ms, output_mode); g_user_states[instance] = transformed.state; #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY bool sent = false; - if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { + if (output_mode == AdapterUsbMode::kXInput) { xinput_feasibility_set_input(instance, g_user_states[instance]); sent = xinput_feasibility_task(instance); diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index 92eb8d7..d2fa5c2 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -19,6 +19,7 @@ struct FakeProfileRow { std::array rows{}; uint32_t database_generation = 7; +uint32_t configuration_reset_generation = 3; unsigned active_snapshot_count = 0; void require(bool condition, const char* message) { @@ -41,6 +42,7 @@ ControllerIdentity make_identity(uint8_t value) { void prepare_profiles() { database_generation = 7; + configuration_reset_generation = 3; active_snapshot_count = 0; for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { @@ -77,6 +79,14 @@ Bluepad32SlotSnapshot make_snapshot(uint8_t slot, return snapshot; } +ControllerProfileTransformResult runtime_transform( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + uint32_t now_ms = 0, + AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe) { + return controller_profile_runtime_transform(slot, snapshot, now_ms, + output_mode); +} + bool motion_equal(const ControllerState& first, const ControllerState& second) { return first.motion_sample_count == second.motion_sample_count && @@ -96,7 +106,7 @@ void test_four_slot_cache_and_unchanged_generation() { slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { snapshots[slot] = make_snapshot(slot); transformed[slot] = - controller_profile_runtime_transform(slot, snapshots[slot]); + runtime_transform(slot, snapshots[slot]); require(transformed[slot].left_trigger_digital_threshold == static_cast(1000u + slot) && transformed[slot].right_trigger_digital_threshold == @@ -110,7 +120,7 @@ void test_four_slot_cache_and_unchanged_generation() { for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { transformed[slot] = - controller_profile_runtime_transform(slot, snapshots[slot]); + runtime_transform(slot, snapshots[slot]); } require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT, "unchanged generations copied profiles on the report path"); @@ -119,7 +129,7 @@ void test_four_slot_cache_and_unchanged_generation() { snapshots[2].connection_generation = 2; transformed[2] = - controller_profile_runtime_transform(2, snapshots[2]); + runtime_transform(2, snapshots[2]); require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 1 && transformed[2].left_trigger_digital_threshold == 1002, @@ -127,7 +137,7 @@ void test_four_slot_cache_and_unchanged_generation() { snapshots[3].identity = rows[1].identity; transformed[3] = - controller_profile_runtime_transform(3, snapshots[3]); + runtime_transform(3, snapshots[3]); require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 2 && transformed[3].left_trigger_digital_threshold == 1001, @@ -139,7 +149,7 @@ void test_activation_disconnect_and_default_preservation() { Bluepad32SlotSnapshot snapshot = make_snapshot(0); snapshot.state.button_south = true; ControllerProfileTransformResult transformed = - controller_profile_runtime_transform(0, snapshot); + runtime_transform(0, snapshot); require(transformed.state.button_south && transformed.left_trigger_digital_threshold == 1000, "initial active profile was not applied"); @@ -150,25 +160,25 @@ void test_activation_disconnect_and_default_preservation() { static_cast(ControllerProfileLogicalButton::kNorth); rows[0].profiles[1].triggers[0].digital_threshold = 12345; ++database_generation; - transformed = controller_profile_runtime_transform(0, snapshot); + transformed = runtime_transform(0, snapshot); require(!transformed.state.button_south && transformed.state.button_north && transformed.left_trigger_digital_threshold == 12345, "profile activation generation did not refresh the slot cache"); const unsigned reads_before_disconnect = active_snapshot_count; snapshot.active = false; - transformed = controller_profile_runtime_transform(0, snapshot); + transformed = runtime_transform(0, snapshot); require(!transformed.state.button_south && !transformed.state.button_north && transformed.left_trigger_digital_threshold == CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && active_snapshot_count == reads_before_disconnect, "disconnect did not neutralize output without fetching a profile"); - transformed = controller_profile_runtime_transform(0, snapshot); + transformed = runtime_transform(0, snapshot); require(active_snapshot_count == reads_before_disconnect, "an unchanged inactive slot repeatedly cleared or fetched state"); snapshot.active = true; - transformed = controller_profile_runtime_transform(0, snapshot); + transformed = runtime_transform(0, snapshot); require(active_snapshot_count == reads_before_disconnect + 1 && transformed.state.button_north, "reconnection did not reload a cleared slot cache"); @@ -186,7 +196,7 @@ void test_activation_disconnect_and_default_preservation() { default_snapshot.state.motion_sample_count = 2; default_snapshot.state.motion_samples[0] = {1, 2, 3, 4, 5, 6}; default_snapshot.state.motion_samples[1] = {-1, -2, -3, -4, -5, -6}; - transformed = controller_profile_runtime_transform(2, default_snapshot); + transformed = runtime_transform(2, default_snapshot); require(transformed.state.button_east && transformed.state.dpad_left && transformed.state.left_trigger == 32123 && transformed.state.right_trigger == 54321 && @@ -207,9 +217,9 @@ void test_analog_thresholds_rumble_and_local_confirmation() { first.state.left_trigger = second.state.left_trigger = 23456; first.state.right_trigger = second.state.right_trigger = 45678; const ControllerProfileTransformResult first_output = - controller_profile_runtime_transform(0, first); + runtime_transform(0, first); const ControllerProfileTransformResult second_output = - controller_profile_runtime_transform(1, second); + runtime_transform(1, second); require(first_output.state.left_trigger == second_output.state.left_trigger && first_output.state.right_trigger == @@ -238,12 +248,172 @@ void test_analog_thresholds_rumble_and_local_confirmation() { "local confirmation was scaled or lost its profile policy"); } +void configure_synthetic_profile(uint8_t slot) { + ControllerProfile& profile = rows[slot].profiles[0]; + profile = controller_profile_default(rows[slot].identity, 0); + profile.macro_trigger = + static_cast(ControllerProfileLogicalButton::kSouth); + profile.macro_cancel = + static_cast(ControllerProfileLogicalButton::kCapture); + profile.macro_step_count = 2; + profile.macro_steps[0].type = + ControllerProfileMacroStepType::kState; + profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + profile.macro_steps[0].duration_ms = 1000; + profile.macro_steps[0].output_button_mask = static_cast( + 1u << static_cast( + ControllerProfileLogicalButton::kNorth)); + profile.macro_steps[1] = {}; + profile.macro_steps[1].type = + ControllerProfileMacroStepType::kEnd; + profile.turbo_modes[static_cast( + ControllerProfileLogicalButton::kEast)] = + ControllerProfileTurboMode::kAutoBurst; +} + +void start_macro(Bluepad32SlotSnapshot* snapshot, uint32_t now_ms, + AdapterUsbMode mode = AdapterUsbMode::kSwitchProbe) { + snapshot->state = controller_neutral_state(); + (void)runtime_transform(0, *snapshot, now_ms, mode); + snapshot->state.button_south = true; + const ControllerProfileTransformResult started = + runtime_transform(0, *snapshot, now_ms + 1u, mode); + require(started.state.button_north && !started.state.button_south, + "runtime fixture did not start its macro"); + snapshot->state = controller_neutral_state(); + const ControllerProfileTransformResult held = + runtime_transform(0, *snapshot, now_ms + 2u, mode); + require(held.state.button_north, + "runtime fixture macro did not remain active"); +} + +void start_auto_burst(Bluepad32SlotSnapshot* snapshot, uint32_t now_ms, + AdapterUsbMode mode = + AdapterUsbMode::kSwitchProbe) { + snapshot->state = controller_neutral_state(); + (void)runtime_transform(0, *snapshot, now_ms, mode); + snapshot->state.button_east = true; + const ControllerProfileTransformResult started = + runtime_transform(0, *snapshot, now_ms + 1u, mode); + require(started.state.button_east, + "runtime fixture did not start Auto Burst"); + snapshot->state = controller_neutral_state(); + const ControllerProfileTransformResult latched = + runtime_transform(0, *snapshot, now_ms + 2u, mode); + require(latched.state.button_east, + "runtime fixture Auto Burst did not latch"); +} + +void require_no_synthetic_output( + const ControllerProfileTransformResult& output, + const char* message) { + require(!output.state.button_north && !output.state.button_east && + !output.state.button_south && + !output.state.button_capture, + message); +} + +void test_all_runtime_cancellation_causes() { + prepare_profiles(); + configure_synthetic_profile(0); + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + start_macro(&snapshot, 10); + snapshot.active = false; + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 13); + require_no_synthetic_output( + output, "disconnect did not cancel synthetic output"); + snapshot.active = true; + output = runtime_transform(0, snapshot, 14); + require_no_synthetic_output( + output, "reconnection restored stale synthetic output"); + + prepare_profiles(); + configure_synthetic_profile(0); + snapshot = make_snapshot(0); + start_macro(&snapshot, 20); + ++snapshot.connection_generation; + output = runtime_transform(0, snapshot, 23); + require_no_synthetic_output( + output, "connection replacement did not cancel before output"); + + prepare_profiles(); + configure_synthetic_profile(0); + snapshot = make_snapshot(0); + start_macro(&snapshot, 30); + rows[0].active_profile = 1; + ++database_generation; + output = runtime_transform(0, snapshot, 33); + require_no_synthetic_output( + output, "profile/database generation change did not cancel"); + + prepare_profiles(); + configure_synthetic_profile(0); + snapshot = make_snapshot(0); + start_auto_burst(&snapshot, 40); + output = runtime_transform(0, snapshot, 43, + AdapterUsbMode::kXInput); + require_no_synthetic_output( + output, "output-mode change did not cancel Auto Burst"); + output = runtime_transform(0, snapshot, 44, + AdapterUsbMode::kXInput); + require_no_synthetic_output( + output, "output-mode cancellation left a stuck output"); + + prepare_profiles(); + configure_synthetic_profile(0); + snapshot = make_snapshot(0); + start_auto_burst(&snapshot, 50); + ++configuration_reset_generation; + output = runtime_transform(0, snapshot, 53); + require_no_synthetic_output( + output, "configuration reset did not cancel Auto Burst"); + output = runtime_transform(0, snapshot, 54); + require_no_synthetic_output( + output, "configuration reset cancellation left a stuck output"); +} + +void test_runtime_slot_synthetic_isolation() { + prepare_profiles(); + configure_synthetic_profile(0); + configure_synthetic_profile(1); + Bluepad32SlotSnapshot first = make_snapshot(0); + Bluepad32SlotSnapshot second = make_snapshot(1); + (void)runtime_transform(0, first, 0); + (void)runtime_transform(1, second, 0); + first.state.button_east = true; + second.state.button_east = true; + require(runtime_transform(0, first, 1).state.button_east && + runtime_transform(1, second, 1).state.button_east, + "runtime slots did not activate independently"); + first.state = controller_neutral_state(); + second.state = controller_neutral_state(); + require(runtime_transform(0, first, 2).state.button_east && + runtime_transform(1, second, 2).state.button_east, + "runtime Auto Burst state did not remain isolated"); + + first.state.button_capture = true; + const ControllerProfileTransformResult cancelled = + runtime_transform(0, first, 3); + const ControllerProfileTransformResult untouched = + runtime_transform(1, second, 3); + require_no_synthetic_output( + cancelled, "slot-local configured cancel did not clear its output"); + require(untouched.state.button_east, + "slot-local configured cancel affected another slot"); +} + } // namespace uint32_t profile_service_database_generation() { return database_generation; } +uint32_t configuration_service_reset_generation() { + return configuration_reset_generation; +} + void profile_service_active_profile_snapshot( const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) { @@ -269,5 +439,7 @@ int main() { test_four_slot_cache_and_unchanged_generation(); test_activation_disconnect_and_default_preservation(); test_analog_thresholds_rumble_and_local_confirmation(); + test_all_runtime_cancellation_causes(); + test_runtime_slot_synthetic_isolation(); return 0; } diff --git a/tests/controller_synthetic_input_test.cpp b/tests/controller_synthetic_input_test.cpp new file mode 100644 index 0000000..7af5e26 --- /dev/null +++ b/tests/controller_synthetic_input_test.cpp @@ -0,0 +1,465 @@ +#include "controller_synthetic_input.h" + +#include +#include +#include +#include + +namespace { + +constexpr uint8_t button_index(ControllerProfileLogicalButton button) { + return static_cast(button); +} + +constexpr uint16_t button_bit(ControllerProfileLogicalButton button) { + return static_cast(1u << button_index(button)); +} + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerState state_with_buttons(uint16_t mask) { + ControllerState state = controller_neutral_state(); + controller_profile_apply_button_mask(mask, &state); + return state; +} + +bool has_button(const ControllerProfileTransformResult& result, + ControllerProfileLogicalButton button) { + return (controller_profile_extract_button_mask(result.state) & + button_bit(button)) != 0; +} + +ControllerProfile profile_with_macro( + ControllerProfileLogicalButton trigger, + ControllerProfileLogicalButton cancel = + ControllerProfileLogicalButton::kCapture) { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.macro_trigger = button_index(trigger); + profile.macro_cancel = button_index(cancel); + 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_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 = + state_with_buttons(button_bit(ControllerProfileLogicalButton::kSouth)); + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, input, profile, 100); + require(has_button(output, ControllerProfileLogicalButton::kNorth) && + has_button(output, ControllerProfileLogicalButton::kDpadUp) && + !has_button(output, ControllerProfileLogicalButton::kSouth), + "macro step zero was not immediate or its trigger leaked"); + + input = controller_neutral_state(); + output = controller_synthetic_input_apply(&context, input, profile, 109); + require(has_button(output, ControllerProfileLogicalButton::kNorth) && + has_button(output, ControllerProfileLogicalButton::kDpadUp), + "macro press state ended before its scheduled deadline"); + output = controller_synthetic_input_apply(&context, input, profile, 110); + require(controller_profile_extract_button_mask(output.state) == 0, + "macro release state did not replace the full button mask"); + + input = + state_with_buttons(button_bit(ControllerProfileLogicalButton::kWest)); + output = controller_synthetic_input_apply(&context, input, profile, 129); + require(!has_button(output, ControllerProfileLogicalButton::kWest), + "active macro release state did not override physical buttons"); + output = controller_synthetic_input_apply(&context, input, profile, 130); + require(has_button(output, ControllerProfileLogicalButton::kWest) && + !context.macro_active, + "explicit end did not clear overrides and restore physical input"); +} + +void test_optional_field_overrides_and_motion_preservation() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSelect); + profile.macro_step_count = 2; + ControllerProfileMacroStep& step = profile.macro_steps[0]; + step.type = ControllerProfileMacroStepType::kState; + step.override_flags = kControllerProfileOverrideLeftStick | + kControllerProfileOverrideRightStick | + kControllerProfileOverrideLeftTrigger | + kControllerProfileOverrideRightTrigger; + step.duration_ms = 100; + step.left_stick_x = INT16_MIN; + step.left_stick_y = 1234; + step.right_stick_x = -2345; + 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) | + button_bit(ControllerProfileLogicalButton::kSouth)); + input.left_stick_x = 10; + input.left_stick_y = 20; + input.right_stick_x = 30; + input.right_stick_y = 40; + input.left_trigger = 111; + input.right_trigger = 222; + input.motion_sample_count = 2; + input.motion_samples[0] = {1, 2, 3, 4, 5, 6}; + input.motion_samples[1] = {-1, -2, -3, -4, -5, -6}; + + ControllerSyntheticInputContext context{}; + const ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, input, profile, 5); + require(has_button(output, ControllerProfileLogicalButton::kSouth) && + !has_button(output, ControllerProfileLogicalButton::kSelect), + "a trigger-only macro override changed buttons or leaked trigger"); + require(output.state.left_stick_x == INT16_MIN && + output.state.left_stick_y == 1234 && + output.state.right_stick_x == -2345 && + output.state.right_stick_y == INT16_MAX && + output.state.left_trigger == 0 && + output.state.right_trigger == UINT16_MAX, + "one or more optional macro fields were not overridden"); + require(output.state.motion_sample_count == input.motion_sample_count && + std::memcmp(output.state.motion_samples, + input.motion_samples, + sizeof(input.motion_samples)) == 0, + "synthetic processing changed motion samples"); +} + +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_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 = + controller_synthetic_input_apply( + &context, + state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)), + profile, 0); + require(has_button(output, ControllerProfileLogicalButton::kNorth), + "zero-wait step zero was skipped on its trigger report"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 0); + require(has_button(output, ControllerProfileLogicalButton::kEast), + "zero wait did not advance on the next scheduler observation"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, + CONTROLLER_PROFILE_MAX_WAIT_MS - 1u); + require(has_button(output, ControllerProfileLogicalButton::kEast), + "maximum legal wait expired early"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, + CONTROLLER_PROFILE_MAX_WAIT_MS); + require(controller_profile_extract_button_mask(output.state) == 0 && + !context.macro_active, + "zero-duration catch-up did not reach the explicit end"); + + profile.macro_step_count = 4; + profile.macro_steps[0].duration_ms = 10; + profile.macro_steps[0].output_button_mask = + button_bit(ControllerProfileLogicalButton::kNorth); + profile.macro_steps[1].duration_ms = 20; + profile.macro_steps[1].output_button_mask = + button_bit(ControllerProfileLogicalButton::kEast); + profile.macro_steps[2].duration_ms = 30; + profile.macro_steps[2].output_button_mask = + button_bit(ControllerProfileLogicalButton::kWest); + context = {}; + (void)controller_synthetic_input_apply( + &context, + state_with_buttons(button_bit(ControllerProfileLogicalButton::kSouth)), + profile, 100); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 145); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "catch-up used observation time instead of prior deadlines"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 1000); + require(!context.macro_active && + controller_profile_extract_button_mask(output.state) == 0, + "large time jump did not finish the bounded macro"); + + profile.macro_step_count = 2; + 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( + &context, + state_with_buttons(button_bit(ControllerProfileLogicalButton::kSouth)), + profile, macro_near_wrap); + require(has_button(output, ControllerProfileLogicalButton::kNorth), + "macro did not start immediately near uint32 wrap"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 3); + require(has_button(output, ControllerProfileLogicalButton::kNorth), + "macro deadline expired early across uint32 wrap"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 4); + require(!context.macro_active && + controller_profile_extract_button_mask(output.state) == 0, + "macro deadline was not uint32-wrap safe"); +} + +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_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)] = + button_index(ControllerProfileLogicalButton::kNorth); + profile.turbo_modes[button_index(ControllerProfileLogicalButton::kSouth)] = + ControllerProfileTurboMode::kTurbo; + + ControllerSyntheticInputContext context{}; + ControllerState input = state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth) | + button_bit(ControllerProfileLogicalButton::kEast)); + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, input, profile, 0); + require(has_button(output, ControllerProfileLogicalButton::kNorth), + "duplicate mapped contributors were not ORed"); + output = controller_synthetic_input_apply(&context, input, profile, 34); + require(has_button(output, ControllerProfileLogicalButton::kNorth), + "Turbo gating erased a duplicate physical contributor"); + + input = state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth) | + button_bit(ControllerProfileLogicalButton::kSelect)); + output = controller_synthetic_input_apply(&context, input, profile, 35); + require(has_button(output, ControllerProfileLogicalButton::kDpadLeft) && + !has_button(output, ControllerProfileLogicalButton::kNorth) && + !has_button(output, ControllerProfileLogicalButton::kSelect), + "macro button override did not outrank Turbo or consume trigger"); + + input = state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth) | + button_bit(ControllerProfileLogicalButton::kCapture)); + output = controller_synthetic_input_apply(&context, input, profile, 36); + require(!context.macro_active && + !has_button(output, ControllerProfileLogicalButton::kCapture) && + has_button(output, ControllerProfileLogicalButton::kNorth), + "configured cancel leaked or failed to clear macro state"); +} + +void test_turbo_rate_release_and_uint32_wrap() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + const uint8_t south = + button_index(ControllerProfileLogicalButton::kSouth); + profile.turbo_modes[south] = ControllerProfileTurboMode::kTurbo; + const ControllerState pressed = + state_with_buttons(button_bit(ControllerProfileLogicalButton::kSouth)); + ControllerSyntheticInputContext context{}; + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, pressed, profile, 0); + require(has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo did not begin in its ON phase"); + + constexpr std::array deltas = {17, 29, 11, 23}; + uint32_t now_ms = 0; + size_t delta_index = 0; + bool previous_on = true; + unsigned completed_activations = 0; + while (now_ms < 1000) { + uint32_t delta = deltas[delta_index++ % deltas.size()]; + if (delta > 1000 - now_ms) { + delta = 1000 - now_ms; + } + now_ms += delta; + output = controller_synthetic_input_apply( + &context, pressed, profile, now_ms); + const bool on = + has_button(output, ControllerProfileLogicalButton::kSouth); + if (previous_on && !on) { + ++completed_activations; + } + previous_on = on; + } + require(completed_activations == 15 && previous_on, + "irregular ticks did not produce exactly 15 activations per second"); + + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 1001); + require(!has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo release left a stuck output"); + output = controller_synthetic_input_apply(&context, pressed, profile, 1002); + require(has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo repress did not restart in the ON phase"); + + context = {}; + constexpr uint32_t near_wrap = UINT32_MAX - 10u; + output = controller_synthetic_input_apply( + &context, pressed, profile, near_wrap); + require(has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo wrap test did not start ON"); + output = controller_synthetic_input_apply(&context, pressed, profile, 23); + require(!has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo phase accumulation was not uint32-wrap safe"); + output = controller_synthetic_input_apply(&context, pressed, profile, 56); + require(has_button(output, ControllerProfileLogicalButton::kSouth), + "Turbo remainder was lost across uint32 wrap"); +} + +void test_auto_burst_toggle_cancel_and_external_cancel() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSelect, + ControllerProfileLogicalButton::kEast); + const uint8_t west = button_index(ControllerProfileLogicalButton::kWest); + profile.turbo_modes[west] = ControllerProfileTurboMode::kAutoBurst; + const ControllerState pressed = + state_with_buttons(button_bit(ControllerProfileLogicalButton::kWest)); + ControllerSyntheticInputContext context{}; + + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, pressed, profile, 0); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst did not toggle on in the ON phase"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 1); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst stopped when its physical input was released"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 34); + require(!has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst did not enter its OFF phase"); + output = controller_synthetic_input_apply(&context, pressed, profile, 35); + require(!has_button(output, ControllerProfileLogicalButton::kWest) && + !context.bindings[west].active, + "second Auto Burst rising press did not toggle it off"); + + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 36); + output = controller_synthetic_input_apply(&context, pressed, profile, 40); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst did not toggle on a second time"); + output = controller_synthetic_input_apply( + &context, + state_with_buttons(button_bit(ControllerProfileLogicalButton::kEast)), + profile, 41); + require(!has_button(output, ControllerProfileLogicalButton::kWest) && + !has_button(output, ControllerProfileLogicalButton::kEast) && + !context.bindings[west].active, + "macro cancel did not clear all Auto Burst state or was not consumed"); + + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 42); + output = controller_synthetic_input_apply(&context, pressed, profile, 50); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst could not restart after configured cancellation"); + controller_synthetic_input_cancel( + &context, button_bit(ControllerProfileLogicalButton::kWest)); + output = controller_synthetic_input_apply(&context, pressed, profile, 51); + require(!has_button(output, ControllerProfileLogicalButton::kWest), + "external cancel retriggered an already-held Auto Burst input"); + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 52); + output = controller_synthetic_input_apply(&context, pressed, profile, 53); + require(has_button(output, ControllerProfileLogicalButton::kWest), + "Auto Burst did not restart after release following cancellation"); +} + +void test_four_contexts_are_isolated() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.turbo_modes[button_index(ControllerProfileLogicalButton::kSouth)] = + ControllerProfileTurboMode::kAutoBurst; + std::array contexts{}; + const ControllerState pressed = + state_with_buttons(button_bit(ControllerProfileLogicalButton::kSouth)); + + ControllerProfileTransformResult slot0 = + controller_synthetic_input_apply(&contexts[0], pressed, profile, 0); + ControllerProfileTransformResult slot1 = controller_synthetic_input_apply( + &contexts[1], controller_neutral_state(), profile, 0); + require(has_button(slot0, ControllerProfileLogicalButton::kSouth) && + !has_button(slot1, ControllerProfileLogicalButton::kSouth), + "synthetic activation leaked into another slot"); + (void)controller_synthetic_input_apply( + &contexts[0], controller_neutral_state(), profile, 1); + slot1 = controller_synthetic_input_apply(&contexts[1], pressed, profile, 10); + controller_synthetic_input_cancel(&contexts[0]); + slot0 = controller_synthetic_input_apply( + &contexts[0], controller_neutral_state(), profile, 11); + slot1 = controller_synthetic_input_apply( + &contexts[1], controller_neutral_state(), profile, 11); + require(!has_button(slot0, ControllerProfileLogicalButton::kSouth) && + has_button(slot1, ControllerProfileLogicalButton::kSouth), + "cancelling one slot changed another slot's Auto Burst state"); +} + +} // namespace + +int main() { + test_immediate_press_release_dpad_and_explicit_end(); + test_optional_field_overrides_and_motion_preservation(); + test_zero_max_wait_and_scheduled_catch_up(); + test_consumption_cancel_precedence_and_duplicate_contributors(); + test_turbo_rate_release_and_uint32_wrap(); + test_auto_burst_toggle_cancel_and_external_cancel(); + test_four_contexts_are_isolated(); + return 0; +} diff --git a/tests/test_controller_profile_runtime_native.py b/tests/test_controller_profile_runtime_native.py index 92ebce1..0fa353d 100644 --- a/tests/test_controller_profile_runtime_native.py +++ b/tests/test_controller_profile_runtime_native.py @@ -22,6 +22,7 @@ def test_controller_profile_runtime_native(tmp_path: Path) -> None: str(root / "controller_identity.cpp"), str(root / "controller_profile.cpp"), str(root / "controller_profile_transform.cpp"), + str(root / "controller_synthetic_input.cpp"), str(root / "controller_profile_runtime.cpp"), "-o", str(executable), diff --git a/tests/test_controller_synthetic_input_native.py b/tests/test_controller_synthetic_input_native.py new file mode 100644 index 0000000..aa347a8 --- /dev/null +++ b/tests/test_controller_synthetic_input_native.py @@ -0,0 +1,32 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_synthetic_input_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "controller_synthetic_input_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_synthetic_input_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "controller_profile_transform.cpp"), + str(root / "controller_synthetic_input.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root)