diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 2a4c1e9..1370863 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -75,21 +75,21 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: | Automatic Windows/Switch selection | Feasibility complete | Windows enumeration fix is in `db4a860`; real Windows transition and rumble were reported working. | | Windows feasibility test | Complete | `tools/Test-AdapterFeasibility.ps1` checks transition, PnP health, four XInput slots, controls, and rumble isolation. | | Protocol-neutral controller state | Complete | `ControllerState` is shared by Bluetooth, UART, Switch, and XInput paths; analog trigger precision is retained. | -| Persistent configuration protocol | Core complete | Two-copy CRC-validated flash storage, chunked transactions, generation recovery, and PC read/write/reset are hardware-tested. | +| Persistent configuration protocol | Complete | Adapter settings and identity-keyed profiles use separate two-copy CRC/generation stores with bounded transactions and recovery. | | Production USB VID/PID | Missing | Prototype uses `CAFE:4010`; obtain an appropriate project VID/PID and repeat Windows binding tests. | | DInput output | Missing | Add generic HID descriptor and report driver. | | Mac output mode | Missing | Capture/define compatible descriptor and report semantics. | | PlayStation Classic mode | Missing | Add strict one-controller legacy descriptor/report mode. | | Mega Drive mode | Missing | Add strict one-controller legacy descriptor/report mode. | | Manual output-mode selection | Missing | Add persistent PC command and controller chord. | -| General button remapping | Partial | Only per-controller ABXY swap exists. | -| Stick sensitivity | Missing in AIO | Add inner deadzone, outer saturation, curve, inversion, and center calibration. | -| Trigger ranges | Partial | Full analog values are preserved; profile-configurable lower/upper range, curve, and digital threshold remain. | -| Vibration intensity | Missing as configuration | Transport works; add per-profile weak/strong scaling. | -| Macros | Missing | Add a bounded deterministic macro engine. | -| Turbo and Auto Burst | Missing | Add exact 15 Hz behavior and cancellation rules. | -| Persistent profiles | Missing | Hotkey state currently resets on disconnect/reboot. | -| Profile switching | Missing | Add controller chord, USB command, rumble, and LED confirmation. | +| General button remapping | Complete | Sixteen positional logical inputs map directly to supported logical outputs per profile. | +| Stick sensitivity | Complete | Per-stick center calibration, inner deadzone, outer saturation, fixed-point curve, and inversion run before every output serializer. | +| Trigger ranges | Complete for current outputs | Per-trigger deadzone, saturation, curve, and digital threshold preserve analog XInput values and configured Switch thresholds. | +| Vibration intensity | Complete | Independent weak/strong profile scales apply to host rumble; local confirmation policy remains separate. | +| Macros | Complete | One bounded eight-step deterministic macro per profile supports buttons, D-pad, sticks, triggers, waits, and explicit end. | +| Turbo and Auto Burst | Complete | Fixed-point phase accumulation produces 15 activations per second with deterministic cancellation. | +| Persistent profiles | Complete | Global fallback plus sixteen stable identities each store four fixed 256-byte profiles in a two-bank atomic database. | +| Profile switching | Complete | PC commands and a configurable controller chord persist selection with isolated rumble/onboard/transient controller LED confirmation. | | Firmware updater | Partial | UF2 updating works; version query and guided reboot/install tool are missing. | | Switch 2 | Unverified | Requires real-hardware qualification. | | Windows/SteamOS/Linux/Android compatibility | Partial | Windows XInput feasibility passed; other host/output combinations need qualification. | @@ -322,7 +322,7 @@ Core completion evidence: and feasibility firmware reflash, after which reset stored the 60-second default as generation 2 -### Phase 3 — Mapping, tuning, profiles, and macros +### Phase 3 — Mapping, tuning, profiles, and macros — Complete Use four profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable. @@ -393,6 +393,29 @@ Acceptance: - No synthetic input remains stuck after any cancellation path. - Four controllers can use different profiles simultaneously. +Completion evidence: + +- strict 256-byte profile schema and 17,696-byte fixed database support four + profiles for the global fallback and each of sixteen stable identities +- profile and adapter stores remain separate from each other and BTstack bonds; + profile commits use one flash-safe batched inactive-bank replacement +- schema-v1 profile databases migrate inherited trigger defaults to schema v2 + without losing custom thresholds, identities, active profiles, or other data +- direct mapping, stick/trigger fixed-point transforms, Switch thresholds, + XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all + cancellation paths have deterministic native coverage +- the AIO backend uses BTstack GAP connection type for stable Classic/BLE + identity, not the stale cached Bluepad protocol field +- profile switching is serialized against host writes, applies only after + commit, and uses generation-safe per-slot feedback +- profile colors/player counts are transient on connect/switch and restore the + persistent USB slot indication after the final 75 ms gap +- 70 tests passed; UART, AIO, and feasibility variants linked and published +- hardware verified separate 8BitDo `057e:2009` and DualSense `054c:0ce6` + identities, isolated active profiles and feedback, persistent 2.2-second + profile commits, button remapping, stick tuning, 15 Hz Turbo, a releasing + macro, and transient profile-to-slot LED restoration + ### Phase 4 — Production USB output modes Implement in this order: @@ -536,8 +559,7 @@ Do not mark a host/controller combination complete from descriptor inspection or ## Next action -Begin Phase 3 now that the storage and USB transaction boundary is proven. -Define the fixed-capacity profile schema first, then add mapping and tuning -transforms before macros or Turbo. Wire profile list/import/export/activate -commands through the Phase 2 transaction protocol only after each profile -field has runtime semantics and deterministic tests. +Begin Phase 4 by placing the existing Switch and verified XInput +implementations behind one production output-driver boundary. Preserve the +Phase 3 transform/runtime seam before serialization, then add DInput, Mac, +PlayStation Classic, and Mega Drive descriptors in the documented order. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5fb3461..4df380a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,14 @@ add_executable(switch-pico if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp + controller_identity.cpp + controller_profile.cpp + controller_profile_transform.cpp + controller_synthetic_input.cpp + controller_profile_runtime.cpp + profile_storage.cpp + profile_service.cpp + pico_profile_storage.cpp bootsel_pairing_button.cpp adapter_configuration.cpp configuration_storage.cpp diff --git a/README.md b/README.md index 0f26801..c730339 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ The Pico 2 W onboard LED reports the overall Bluetooth state: - **Pairing window expires**: new authentication and active discovery stop while a controller is active; remembered controllers may still initiate reconnects. - **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again. -### Managing configuration and pairings from a PC +### Managing configuration, profiles, and pairings from a PC Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. `switch-pico-config` uses versioned private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes. @@ -94,25 +94,34 @@ uv run switch-pico-config status uv run switch-pico-config config show uv run switch-pico-config config set --pairing-window-seconds 90 uv run switch-pico-config config reset --yes +uv run switch-pico-config profiles list +uv run switch-pico-config profiles export 1 profile.json --identity 0 +uv run switch-pico-config profiles import 2 profile.json --identity 0 +uv run switch-pico-config profiles activate 2 --identity 0 +uv run switch-pico-config profiles reset all --identity 0 --yes uv run switch-pico-config pairings list uv run switch-pico-config pairings clear --yes ``` -Configuration records use version, size, generation, and CRC fields. Writes are chunked and atomically committed between two dedicated flash sectors; interrupted or corrupt writes fall back to the previous valid generation. Those sectors do not overlap Bluepad32's separate bond store. Rewriting identical configuration does not consume a flash write, and successful writes are rate-limited. +Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use a separate two-bank atomic store before the adapter and Bluepad32 bond regions. Profile writes are chunked, verified before the new bank is committed, recover the previous generation after interruption or corruption, skip unchanged data, and are rate-limited. + +`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. Exported JSON contains direct logical button mappings, independent stick and trigger calibration/curves, digital trigger thresholds, weak/strong rumble scales, a profile-switching chord, one bounded eight-step macro, and per-button Turbo modes. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`. `pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. -### Per-controller ABXY layout +### Per-controller profiles -Each connected AIO controller can toggle its own ABXY layout by pressing **L + R + Select + Start** together. On DualSense, use **L1 + R1 + Create + Options**. The controller gives one short rumble when the toggle is accepted; release the chord before toggling again. +The default profile-switching chord is **L + R + Select + Start**. On DualSense, use **L1 + R1 + Create + Options**. A profile can replace this chord with any nonzero logical-button mask; a stored zero selects the default chord. -- **Standard**: south→B, east→A, west→Y, north→X. -- **Swapped**: south→A, east→B, west→X, north→Y. -- The chord is consumed locally and is not forwarded to the Switch. -- Other controller slots are unaffected. -- Layout returns to the configured default after disconnect or reboot. +- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`. +- Chord buttons are consumed locally and are not forwarded to the host. +- The new profile applies only after its atomic flash commit completes. +- Confirmation uses one to four 75 ms pulses matching the active profile number. +- The profile policy independently enables rumble and LED feedback. +- 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. -Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse. +Profile input processing is deterministic: physical input is 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 button. Macros contain at most eight state steps plus an explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset. ### Per-controller motion toggle @@ -271,7 +280,7 @@ Filters you can use: - When testing on a PC before plugging into a Switch, you can verify activity with the lightweight `switch_pico_bridge.switch_pico_uart` helper or the Windows "Game Controllers" panel. ## Building and flashing firmware -Prereqs: Pico SDK + CMake toolchain set up. +Prereqs: Pico SDK, Arm GNU toolchain, CMake, and `picotool`. ### Using `build.py` @@ -279,6 +288,14 @@ Prereqs: Pico SDK + CMake toolchain set up. were created, copies the release artifacts into `firmware/`, and flashes the ELF with `picotool`. +`build.py` automatically locates the Pico SDK and Arm GNU toolchain from valid +existing `build/`, `build-aio/`, or `build-feasibility/` CMake caches, then from +project-local `build/_deps/pico_sdk-src` and `build/toolchain` installs, and +finally from conventional user and system locations. A compiler already on +`PATH` is used without setting a toolchain override. Explicit `PICO_SDK_PATH` +and `PICO_TOOLCHAIN_PATH` values always take precedence; an invalid explicit +path is reported instead of silently falling back. + Before running it: 1. Install the Pico SDK, CMake toolchain, and `picotool`. @@ -316,16 +333,19 @@ If the tools or artifacts are in non-default locations, use these environment variables: ```sh +PICO_SDK_PATH=/path/to/pico-sdk \ +PICO_TOOLCHAIN_PATH=/path/to/arm-none-eabi-toolchain \ PICOTOOL_PATH=/path/to/picotool \ ELF_PATH=/path/to/switch-pico.elf \ UF2_PATH=/path/to/switch-pico.uf2 \ python3 build.py ``` -`PICOTOOL_PATH` selects the flashing tool, `ELF_PATH` selects the ELF that is -checked and flashed, and `UF2_PATH` selects the UF2 that is checked after the -build. Their defaults are `picotool` from `PATH`, `build/switch-pico.elf`, and -`build/switch-pico.uf2`, respectively. +`PICO_SDK_PATH` and `PICO_TOOLCHAIN_PATH` explicitly select the SDK and +cross-compiler installations. `PICOTOOL_PATH` selects the flashing tool, +`ELF_PATH` selects the ELF that is checked and flashed, and `UF2_PATH` selects +the UF2 that is checked after the build. Their defaults are `picotool` from +`PATH`, `build/switch-pico.elf`, and `build/switch-pico.uf2`, respectively. ### Manual build ```sh diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index 057669d..beab8e9 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -1,6 +1,7 @@ #include "bluepad32_input_backend.h" #include "controller_hotkey_config.h" #include "configuration_service.h" +#include "profile_service.h" #include #include @@ -23,11 +24,9 @@ constexpr int32_t kAxisMinimum = -512; constexpr int32_t kAxisMaximum = 511; constexpr int32_t kTriggerMaximum = 1023; constexpr uint16_t kSwitchHostRumbleDurationMs = 50; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY // XInput vibration is stateful and remains active until XInputSetState sends // a new magnitude. constexpr uint16_t kXInputHostRumbleDurationMs = UINT16_MAX; -#endif constexpr uint32_t kRumblePollIntervalMs = 5; constexpr uint32_t kConfigurationPollIntervalMs = 50; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; @@ -41,16 +40,19 @@ constexpr uint8_t kAllBlePairingMethods = SM_STK_GENERATION_METHOD_OOB | SM_STK_GENERATION_METHOD_PASSKEY | SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON; -constexpr uint32_t kAbxyHotkeyButtonMask = - SWITCH_ABXY_HOTKEY_BUTTON_MASK; -constexpr uint32_t kAbxyHotkeyMiscMask = SWITCH_ABXY_HOTKEY_MISC_MASK; -constexpr bool kDefaultSwapAbxy = SWITCH_ABXY_DEFAULT_SWAPPED != 0; -constexpr uint32_t kAbxyFeedbackDurationMs = - SWITCH_ABXY_FEEDBACK_DURATION_MS; -constexpr uint8_t kAbxyFeedbackWeakMagnitude = - SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE; -constexpr uint8_t kAbxyFeedbackStrongMagnitude = - SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE; +constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75; +constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX; +constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX; +// One initial indication can be followed by one committed switch before the +// Core 1 timer drains the queue. Profile commits are rate-limited well beyond +// the longest feedback sequence. +constexpr uint8_t kProfileFeedbackQueueCapacity = 2; +constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = { + {0x00, 0x55, 0xff}, + {0x00, 0xcc, 0x66}, + {0xff, 0xaa, 0x00}, + {0xcc, 0x33, 0xff}, +}; constexpr uint32_t kMotionHotkeyDpadMask = SWITCH_MOTION_HOTKEY_DPAD_MASK; constexpr uint32_t kMotionHotkeyButtonMask = @@ -72,9 +74,8 @@ constexpr uint8_t kMotionEnabledFeedbackWeakMagnitude = constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; -static_assert(kAbxyHotkeyButtonMask != 0); -static_assert(kAbxyHotkeyMiscMask != 0); -static_assert(kAbxyFeedbackDurationMs > 0); +static_assert(kProfileFeedbackPhaseDurationMs == 75); +static_assert(CONTROLLER_PROFILE_COUNT == 4); static_assert(kMotionHotkeyDpadMask != 0); static_assert(kMotionHotkeyButtonMask != 0); static_assert(kMotionHotkeyMiscMask != 0); @@ -103,6 +104,7 @@ struct RumbleEnvelope { uint8_t slot; uint32_t connection_generation; ControllerRumbleOutput rumble; + uint16_t duration_ms; }; struct FeedbackEnvelope { uint32_t connection_generation; @@ -110,28 +112,63 @@ struct FeedbackEnvelope { uint8_t weak_magnitude; uint8_t strong_magnitude; }; +struct ProfileFeedbackEnvelope { + uint32_t connection_generation; + uint8_t active_profile_number; + ControllerProfileConfirmationPolicy policy; +}; + +struct ProfileFeedbackSequence { + uint32_t connection_generation; + uint32_t phase_deadline_ms; + uint8_t pulse_count; + uint8_t pulses_started; + bool active; + bool on; + bool rumble_enabled; + bool led_enabled; +}; + + +// Security Manager identity events arrive before Bluepad32 publishes a ready +// device. Retain only the four live handle/address associations so a BLE RPA +// is never promoted to a stable identity on its own. +struct BleIdentityMapping { + bool used; + hci_con_handle_t connection_handle; + bd_addr_t connection_address; + uint8_t identity_address_type; + bd_addr_t identity_address; +}; struct BackendSlot { ControllerState state; + uint16_t pre_hotkey_button_mask; + ControllerIdentity identity; // Non-null with active=false is a connected device still becoming ready. uni_hid_device_t* device; uint32_t state_generation; uint32_t connection_generation; bool active; bool rumble_pending; - bool swap_abxy; - bool abxy_hotkey_latched; bool motion_enabled; bool motion_hotkey_latched; bool feedback_pending; uint32_t feedback_until_ms; + uint8_t pending_profile_feedback_count; RumbleEnvelope pending_rumble; + bool retained_host_rumble_valid; + RumbleEnvelope retained_host_rumble; FeedbackEnvelope pending_feedback; + ProfileFeedbackEnvelope + pending_profile_feedback[kProfileFeedbackQueueCapacity]; + ProfileFeedbackSequence profile_feedback; }; critical_section_t g_state_lock; BackendSlot g_slots[kSlotCount]; +BleIdentityMapping g_ble_identity_mappings[kSlotCount]{}; // These acknowledgement generations and the pairing request producer are only // used by Core 0. The request is transferred under the cross-core state lock. @@ -148,6 +185,7 @@ btstack_timer_source_t g_rumble_timer{}; btstack_timer_source_t g_configuration_timer{}; ConnectionStatus g_connection_status = ConnectionStatus::Initializing; btstack_packet_callback_registration_t g_pairing_event_callback{}; +btstack_packet_callback_registration_t g_identity_event_callback{}; ConnectionPolicyState g_connection_policy_state = ConnectionPolicyState::Uninitialized; uint32_t g_pairing_window_deadline_ms = 0; @@ -203,6 +241,193 @@ int slot_for_device(const uni_hid_device_t* device) { const int slot = uni_hid_device_get_idx_for_instance(device); return slot >= 0 && slot < kSlotCount ? slot : -1; } + +bool addresses_equal(const bd_addr_t first, const bd_addr_t second) { + return memcmp(first, second, sizeof(bd_addr_t)) == 0; +} + +BleIdentityMapping* find_ble_identity_mapping( + hci_con_handle_t connection_handle, + const bd_addr_t connection_address) { + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + if (mapping.used && + mapping.connection_handle == connection_handle && + addresses_equal(mapping.connection_address, + connection_address)) { + return &mapping; + } + } + return nullptr; +} + +BleIdentityMapping* find_ble_identity_mapping_for_handle( + hci_con_handle_t connection_handle) { + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + if (mapping.used && + mapping.connection_handle == connection_handle) { + return &mapping; + } + } + return nullptr; +} + +BleIdentityMapping* reserve_ble_identity_mapping( + hci_con_handle_t connection_handle) { + BleIdentityMapping* available = nullptr; + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + if (mapping.used && + mapping.connection_handle == connection_handle) { + return &mapping; + } + if (!mapping.used && available == nullptr) { + available = &mapping; + } + } + return available; +} + +ControllerIdentity make_ble_identity( + const BleIdentityMapping& mapping, const uni_hid_device_t* device) { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kBle; + identity.address_type = mapping.identity_address_type; + memcpy(identity.address, mapping.identity_address, + sizeof(identity.address)); + identity.vendor_id = device->vendor_id; + identity.product_id = device->product_id; + return identity; +} + +ControllerIdentity identity_for_device(const uni_hid_device_t* device) { + if (device == nullptr) { + return controller_identity_global(); + } + switch (gap_get_connection_type(device->conn.handle)) { + case GAP_CONNECTION_ACL: { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kClassic; + identity.address_type = BD_ADDR_TYPE_UNKNOWN; + memcpy(identity.address, device->conn.btaddr, + sizeof(identity.address)); + identity.vendor_id = device->vendor_id; + identity.product_id = device->product_id; + return identity; + } + case GAP_CONNECTION_LE: { + const BleIdentityMapping* mapping = find_ble_identity_mapping( + device->conn.handle, device->conn.btaddr); + if (mapping != nullptr) { + return make_ble_identity(*mapping, device); + } + break; + } + case GAP_CONNECTION_INVALID: + case GAP_CONNECTION_SCO: + break; + } + return controller_identity_global(); +} + +void publish_ble_identity(const BleIdentityMapping& mapping) { + ControllerIdentity observed_identity{}; + bool observe_identity = false; + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + if (slot.device != nullptr && + gap_get_connection_type(slot.device->conn.handle) == + GAP_CONNECTION_LE && + slot.device->conn.handle == mapping.connection_handle && + addresses_equal(slot.device->conn.btaddr, + mapping.connection_address)) { + slot.identity = make_ble_identity(mapping, slot.device); + if (slot.active) { + observed_identity = slot.identity; + observe_identity = true; + } + } + } + critical_section_exit(&g_state_lock); + if (observe_identity) { + profile_service_observe_identity_on_storage_core( + observed_identity); + } +} + +void record_ble_identity(hci_con_handle_t connection_handle, + const bd_addr_t connection_address, + uint8_t identity_address_type, + const bd_addr_t identity_address) { + BleIdentityMapping* mapping = + reserve_ble_identity_mapping(connection_handle); + if (mapping == nullptr) { + return; + } + *mapping = {}; + mapping->used = true; + mapping->connection_handle = connection_handle; + memcpy(mapping->connection_address, connection_address, + sizeof(mapping->connection_address)); + mapping->identity_address_type = identity_address_type; + memcpy(mapping->identity_address, identity_address, + sizeof(mapping->identity_address)); + publish_ble_identity(*mapping); +} + +void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) { + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + if (mapping.used && + mapping.connection_handle == connection_handle) { + mapping = {}; + } + } + + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + if (slot.device != nullptr && + gap_get_connection_type(slot.device->conn.handle) == + GAP_CONNECTION_LE && + slot.device->conn.handle == connection_handle) { + slot.identity = controller_identity_global(); + } + } + critical_section_exit(&g_state_lock); +} + +void clear_ble_identity_for_device(const uni_hid_device_t* device) { + if (device == nullptr) { + return; + } + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + if (mapping.used && + mapping.connection_handle == device->conn.handle && + addresses_equal(mapping.connection_address, + device->conn.btaddr)) { + mapping = {}; + } + } +} + +void connection_address_for_handle(hci_con_handle_t connection_handle, + const bd_addr_t fallback, + bd_addr_t output) { + const uni_hid_device_t* device = + uni_hid_device_get_instance_for_connection_handle( + connection_handle); + if (device != nullptr) { + memcpy(output, device->conn.btaddr, sizeof(bd_addr_t)); + return; + } + const BleIdentityMapping* mapping = + find_ble_identity_mapping_for_handle(connection_handle); + if (mapping != nullptr) { + memcpy(output, mapping->connection_address, sizeof(bd_addr_t)); + return; + } + memcpy(output, fallback, sizeof(bd_addr_t)); +} + void apply_slot_lighting(uint8_t slot_index, uni_hid_device_t* device) { const SwitchRgbColor color = switch_pro_get_slot_light_color(slot_index); @@ -214,6 +439,44 @@ void apply_slot_lighting(uint8_t slot_index, uni_hid_device_t* device) { device, static_cast(1u << slot_index)); } } +bool valid_confirmation_policy( + ControllerProfileConfirmationPolicy policy) { + return static_cast(policy) <= + static_cast( + ControllerProfileConfirmationPolicy::kRumbleAndLed); +} + +void apply_profile_lighting( + uint8_t active_profile_number, uni_hid_device_t* device) { + if (device == nullptr || active_profile_number == 0 || + active_profile_number > CONTROLLER_PROFILE_COUNT) { + return; + } + if (device->report_parser.set_lightbar_color != nullptr) { + const SwitchRgbColor color = + kProfileLightbarPalette[active_profile_number - 1u]; + device->report_parser.set_lightbar_color( + device, color.red, color.green, color.blue); + } else if (device->report_parser.set_player_leds != nullptr) { + device->report_parser.set_player_leds( + device, static_cast( + (1u << active_profile_number) - 1u)); + } +} + +bool lighting_target_is_current( + uint8_t slot_index, uint32_t connection_generation, + const uni_hid_device_t* device) { + critical_section_enter_blocking(&g_state_lock); + const bool current = + slot_index < kSlotCount && g_slots[slot_index].active && + g_slots[slot_index].device == device && + g_slots[slot_index].connection_generation == + connection_generation; + critical_section_exit(&g_state_lock); + return current; +} + ConnectionStatus compute_connection_status() { @@ -235,11 +498,13 @@ ConnectionStatus compute_connection_status() { } void publish_device_state(uint8_t slot, uni_hid_device_t* device, + uint16_t pre_hotkey_button_mask, const ControllerState& state) { critical_section_enter_blocking(&g_state_lock); BackendSlot& target = g_slots[slot]; if (target.active && target.device == device) { target.state = state; + target.pre_hotkey_button_mask = pre_hotkey_button_mask; ++target.state_generation; } critical_section_exit(&g_state_lock); @@ -249,13 +514,28 @@ void publish_all_neutral() { critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { slot.state = make_neutral_state(); + slot.pre_hotkey_button_mask = 0; + slot.identity = controller_identity_global(); slot.device = nullptr; slot.active = false; slot.rumble_pending = false; + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; + slot.feedback_pending = false; + slot.feedback_until_ms = 0; + slot.pending_profile_feedback_count = 0; + for (ProfileFeedbackEnvelope& feedback : + slot.pending_profile_feedback) { + feedback = {}; + } + slot.profile_feedback = {}; ++slot.state_generation; ++slot.connection_generation; } critical_section_exit(&g_state_lock); + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + mapping = {}; + } g_connection_status = ConnectionStatus::Initializing; g_connection_policy_state = ConnectionPolicyState::FailedClosed; g_pairing_window_open = false; @@ -342,32 +622,129 @@ bool has_motion(const uni_gamepad_t& gamepad) { } return false; } +constexpr uint16_t logical_button_bit( + ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + +constexpr uint16_t logical_button_mask( + uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) { + return static_cast( + ((buttons & BUTTON_A) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSouth) + : 0u) | + ((buttons & BUTTON_B) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kEast) + : 0u) | + ((buttons & BUTTON_X) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kWest) + : 0u) | + ((buttons & BUTTON_Y) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kNorth) + : 0u) | + ((buttons & BUTTON_SHOULDER_L) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder) + : 0u) | + ((buttons & BUTTON_SHOULDER_R) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder) + : 0u) | + ((misc_buttons & MISC_BUTTON_SELECT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSelect) + : 0u) | + ((misc_buttons & MISC_BUTTON_START) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kStart) + : 0u) | + ((misc_buttons & MISC_BUTTON_SYSTEM) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kSystem) + : 0u) | + ((misc_buttons & MISC_BUTTON_CAPTURE) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kCapture) + : 0u) | + ((buttons & BUTTON_THUMB_L) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kLeftStick) + : 0u) | + ((buttons & BUTTON_THUMB_R) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kRightStick) + : 0u) | + ((dpad & DPAD_UP) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadUp) + : 0u) | + ((dpad & DPAD_DOWN) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadDown) + : 0u) | + ((dpad & DPAD_LEFT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadLeft) + : 0u) | + ((dpad & DPAD_RIGHT) != 0 + ? logical_button_bit( + ControllerProfileLogicalButton::kDpadRight) + : 0u)); +} + +constexpr uint16_t kMotionHotkeyLogicalButtonMask = + logical_button_mask( + kMotionHotkeyDpadMask, kMotionHotkeyButtonMask, + kMotionHotkeyMiscMask); + +uint16_t logical_button_mask(const uni_gamepad_t& gamepad) { + return logical_button_mask( + gamepad.dpad, gamepad.buttons, gamepad.misc_buttons); +} ControllerState map_gamepad(const uni_gamepad_t& gamepad, - bool swap_abxy, - bool motion_enabled) { + bool motion_enabled, + uint16_t button_mask) { ControllerState state = make_neutral_state(); - state.dpad_up = (gamepad.dpad & DPAD_UP) != 0; - state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0; - state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0; - state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0; + state.dpad_up = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadUp)) != 0; + state.dpad_down = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadDown)) != 0; + state.dpad_left = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadLeft)) != 0; + state.dpad_right = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kDpadRight)) != 0; - // Bluepad32's A/B/X/Y are positional: south/east/west/north. - state.button_south = (gamepad.buttons & BUTTON_A) != 0; - state.button_east = (gamepad.buttons & BUTTON_B) != 0; - state.button_west = (gamepad.buttons & BUTTON_X) != 0; - state.button_north = (gamepad.buttons & BUTTON_Y) != 0; - if (swap_abxy) { - bool temporary = state.button_east; - state.button_east = state.button_south; - state.button_south = temporary; - temporary = state.button_north; - state.button_north = state.button_west; - state.button_west = temporary; - } - state.button_left_shoulder = (gamepad.buttons & BUTTON_SHOULDER_L) != 0; - state.button_right_shoulder = (gamepad.buttons & BUTTON_SHOULDER_R) != 0; + // Bluepad32's A/B/X/Y are positional: south/east/west/north. Persistent + // profile mappings are the only button remapping layer. + state.button_south = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSouth)) != 0; + state.button_east = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kEast)) != 0; + state.button_west = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kWest)) != 0; + state.button_north = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kNorth)) != 0; + state.button_left_shoulder = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder)) != 0; + state.button_right_shoulder = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder)) != 0; state.left_trigger = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 ? UINT16_MAX @@ -376,13 +753,25 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, (gamepad.buttons & BUTTON_TRIGGER_R) != 0 ? UINT16_MAX : scale_trigger(gamepad.throttle); - state.button_left_stick = (gamepad.buttons & BUTTON_THUMB_L) != 0; - state.button_right_stick = (gamepad.buttons & BUTTON_THUMB_R) != 0; + state.button_left_stick = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kLeftStick)) != 0; + state.button_right_stick = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kRightStick)) != 0; - state.button_select = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0; - state.button_start = (gamepad.misc_buttons & MISC_BUTTON_START) != 0; - state.button_system = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0; - state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0; + state.button_select = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSelect)) != 0; + state.button_start = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kStart)) != 0; + state.button_system = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kSystem)) != 0; + state.button_capture = + (button_mask & logical_button_bit( + ControllerProfileLogicalButton::kCapture)) != 0; state.left_stick_x = scale_axis(gamepad.axis_x); state.left_stick_y = scale_axis(gamepad.axis_y); @@ -407,11 +796,11 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, return state; } struct HotkeyDecision { - bool swap_abxy; bool motion_enabled; uint32_t suppress_dpad; uint32_t suppress_buttons; uint32_t suppress_misc_buttons; + uint16_t suppress_logical_buttons; }; void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, @@ -423,24 +812,26 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, strong_magnitude}; } void reset_slot_hotkeys(BackendSlot& slot) { - slot.swap_abxy = kDefaultSwapAbxy; - slot.abxy_hotkey_latched = false; slot.motion_enabled = kDefaultMotionEnabled; slot.motion_hotkey_latched = false; + slot.pre_hotkey_button_mask = 0; slot.feedback_pending = false; slot.feedback_until_ms = 0; slot.pending_feedback = {}; + slot.pending_profile_feedback_count = 0; + for (ProfileFeedbackEnvelope& feedback : + slot.pending_profile_feedback) { + feedback = {}; + } + slot.profile_feedback = {}; + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; } HotkeyDecision update_controller_hotkeys( uint8_t slot_index, uni_hid_device_t* device, const uni_gamepad_t& gamepad) { - const bool abxy_pressed = - (gamepad.buttons & kAbxyHotkeyButtonMask) == - kAbxyHotkeyButtonMask && - (gamepad.misc_buttons & kAbxyHotkeyMiscMask) == - kAbxyHotkeyMiscMask; const bool motion_pressed = (gamepad.dpad & kMotionHotkeyDpadMask) == kMotionHotkeyDpadMask && @@ -449,18 +840,12 @@ HotkeyDecision update_controller_hotkeys( (gamepad.misc_buttons & kMotionHotkeyMiscMask) == kMotionHotkeyMiscMask; HotkeyDecision decision{ - kDefaultSwapAbxy, kDefaultMotionEnabled, 0, 0, 0}; + kDefaultMotionEnabled, 0, 0, 0, 0}; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device == device) { - if (abxy_pressed && !slot.abxy_hotkey_latched) { - slot.swap_abxy = !slot.swap_abxy; - queue_local_feedback( - slot, static_cast(kAbxyFeedbackDurationMs), - kAbxyFeedbackWeakMagnitude, - kAbxyFeedbackStrongMagnitude); - } else if (motion_pressed && !slot.motion_hotkey_latched) { + if (motion_pressed && !slot.motion_hotkey_latched) { slot.motion_enabled = !slot.motion_enabled; if (slot.motion_enabled) { queue_local_feedback( @@ -474,18 +859,14 @@ HotkeyDecision update_controller_hotkeys( kMotionDisabledFeedbackStrongMagnitude); } } - slot.abxy_hotkey_latched = abxy_pressed; slot.motion_hotkey_latched = motion_pressed; - decision.swap_abxy = slot.swap_abxy; decision.motion_enabled = slot.motion_enabled; - if (abxy_pressed) { - decision.suppress_buttons |= kAbxyHotkeyButtonMask; - decision.suppress_misc_buttons |= kAbxyHotkeyMiscMask; - } if (motion_pressed) { decision.suppress_dpad |= kMotionHotkeyDpadMask; decision.suppress_buttons |= kMotionHotkeyButtonMask; decision.suppress_misc_buttons |= kMotionHotkeyMiscMask; + decision.suppress_logical_buttons |= + kMotionHotkeyLogicalButtonMask; } } critical_section_exit(&g_state_lock); @@ -498,18 +879,101 @@ bool pairing_window_active_at(uint32_t now_ms) { static_cast(now_ms - g_pairing_window_deadline_ms) < 0; } -void handle_pairing_hci_event(uint8_t packet_type, uint16_t channel, - uint8_t* packet, uint16_t size) { +void handle_btstack_event(uint8_t packet_type, uint16_t channel, + uint8_t* packet, uint16_t size) { (void)channel; - if (packet_type != HCI_EVENT_PACKET || packet == nullptr || size < 8) { + if (packet_type != HCI_EVENT_PACKET || packet == nullptr || size < 2) { return; } bd_addr_t address{}; + bd_addr_t identity_address{}; + bd_addr_t connection_address{}; + hci_con_handle_t connection_handle = 0; const bool pairing_open = pairing_window_active_at(btstack_run_loop_get_time_ms()); switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_IDENTITY_RESOLVING_STARTED: + if (size >= 11) { + clear_ble_identity_for_handle( + sm_event_identity_resolving_started_get_handle(packet)); + } + break; + case SM_EVENT_IDENTITY_RESOLVING_FAILED: + if (size >= 11) { + clear_ble_identity_for_handle( + sm_event_identity_resolving_failed_get_handle(packet)); + } + break; + case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED: + if (size >= 20) { + connection_handle = + sm_event_identity_resolving_succeeded_get_handle(packet); + sm_event_identity_resolving_succeeded_get_address( + packet, connection_address); + sm_event_identity_resolving_succeeded_get_identity_address( + packet, identity_address); + record_ble_identity( + connection_handle, connection_address, + sm_event_identity_resolving_succeeded_get_identity_addr_type( + packet), + identity_address); + } + break; + case SM_EVENT_IDENTITY_CREATED: + if (size >= 20) { + connection_handle = + sm_event_identity_created_get_handle(packet); + sm_event_identity_created_get_address(packet, address); + sm_event_identity_created_get_identity_address( + packet, identity_address); + connection_address_for_handle( + connection_handle, address, connection_address); + record_ble_identity( + connection_handle, connection_address, + sm_event_identity_created_get_identity_addr_type(packet), + identity_address); + } + break; + case SM_EVENT_REENCRYPTION_STARTED: + if (size >= 11) { + connection_handle = + sm_event_reencryption_started_get_handle(packet); + sm_event_reencryption_started_get_address( + packet, identity_address); + connection_address_for_handle( + connection_handle, identity_address, + connection_address); + record_ble_identity( + connection_handle, connection_address, + sm_event_reencryption_started_get_addr_type(packet), + identity_address); + } + break; + case SM_EVENT_REENCRYPTION_COMPLETE: + if (size >= 12) { + connection_handle = + sm_event_reencryption_complete_get_handle(packet); + if (sm_event_reencryption_complete_get_status(packet) == + ERROR_CODE_SUCCESS) { + sm_event_reencryption_complete_get_address( + packet, identity_address); + connection_address_for_handle( + connection_handle, identity_address, + connection_address); + record_ble_identity( + connection_handle, connection_address, + sm_event_reencryption_complete_get_addr_type(packet), + identity_address); + } else { + clear_ble_identity_for_handle(connection_handle); + } + } + break; case HCI_EVENT_USER_CONFIRMATION_REQUEST: + if (size < 8) { + break; + } hci_event_user_confirmation_request_get_bd_addr(packet, address); if (pairing_open) { gap_ssp_confirmation_response(address); @@ -518,6 +982,9 @@ void handle_pairing_hci_event(uint8_t packet_type, uint16_t channel, } break; case HCI_EVENT_USER_PASSKEY_REQUEST: + if (size < 8) { + break; + } hci_event_user_passkey_request_get_bd_addr(packet, address); if (pairing_open) { gap_ssp_passkey_response(address, 0); @@ -634,6 +1101,7 @@ void process_clear_pairings(uint32_t now_ms) { BackendSlot& slot = g_slots[slot_index]; devices[slot_index] = slot.device; slot.state = make_neutral_state(); + slot.identity = controller_identity_global(); slot.device = nullptr; slot.active = false; slot.rumble_pending = false; @@ -648,6 +1116,9 @@ void process_clear_pairings(uint32_t now_ms) { if (!requested) { return; } + for (BleIdentityMapping& mapping : g_ble_identity_mappings) { + mapping = {}; + } g_pairing_window_open = false; gap_set_bondable_mode(false); @@ -706,13 +1177,56 @@ void apply_connection_policy() { } } +bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) { + return static_cast(now_ms - deadline_ms) >= 0; +} + +bool advance_profile_feedback(ProfileFeedbackSequence* sequence, + uint32_t now_ms) { + bool rumble_dispatch = false; + for (uint8_t transition = 0; + transition < CONTROLLER_PROFILE_COUNT * 2u && + sequence->active && + deadline_reached(now_ms, sequence->phase_deadline_ms); + ++transition) { + sequence->phase_deadline_ms += + kProfileFeedbackPhaseDurationMs; + if (sequence->on) { + sequence->on = false; + rumble_dispatch = false; + } else if (sequence->pulses_started >= + sequence->pulse_count) { + sequence->active = false; + } else { + sequence->on = true; + ++sequence->pulses_started; + rumble_dispatch = sequence->rumble_enabled; + } + } + return rumble_dispatch; +} + void update_status_led() { ++g_status_led_tick; const uint32_t now_ms = btstack_run_loop_get_time_ms(); - bool led_on = false; + bool profile_led_override = false; + bool profile_led_on = false; + critical_section_enter_blocking(&g_state_lock); + for (const BackendSlot& slot : g_slots) { + if (slot.profile_feedback.active && + slot.profile_feedback.led_enabled) { + profile_led_override = true; + profile_led_on = + profile_led_on || slot.profile_feedback.on; + } + } + critical_section_exit(&g_state_lock); - if (static_cast( - now_ms - g_pairing_reset_feedback_deadline_ms) < 0) { + bool led_on = false; + if (profile_led_override) { + led_on = profile_led_on; + } else if (static_cast( + now_ms - g_pairing_reset_feedback_deadline_ms) < 0) { led_on = (g_status_led_tick % 20) < 10; } else if (pairing_window_active_at(now_ms)) { const uint16_t phase = g_status_led_tick % 200; @@ -733,10 +1247,11 @@ void update_status_led() { } void process_configuration_timer(btstack_timer_source_t* timer) { - configuration_service_task_on_storage_core( - btstack_run_loop_get_time_ms()); btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(timer); + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + configuration_service_task_on_storage_core(now_ms); + profile_service_task_on_storage_core(now_ms); } void process_rumble_timer(btstack_timer_source_t* timer) { @@ -747,17 +1262,122 @@ void process_rumble_timer(btstack_timer_source_t* timer) { if (update_pairing_window(now_ms)) { apply_connection_policy(); } + const bool xinput_host_mode = + host_rumble_duration_ms() == kXInputHostRumbleDurationMs; for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { RumbleEnvelope envelope{}; FeedbackEnvelope feedback{}; + ProfileFeedbackEnvelope profile_feedback{}; uni_hid_device_t* device = nullptr; + uni_hid_device_t* profile_lighting_device = nullptr; + uint32_t profile_lighting_generation = 0; + bool profile_lighting_dispatch = false; + bool profile_lighting_restore = false; + bool profile_rumble_dispatch = false; bool feedback_dispatch = false; bool host_dispatch = false; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - if (slot.feedback_pending) { + if (slot.retained_host_rumble_valid && + (!xinput_host_mode || + slot.retained_host_rumble.duration_ms != + kXInputHostRumbleDurationMs || + slot.retained_host_rumble.slot != slot_index || + slot.retained_host_rumble.connection_generation != + slot.connection_generation || + !slot.active || slot.device == nullptr)) { + slot.retained_host_rumble_valid = false; + slot.retained_host_rumble = {}; + } + if (slot.profile_feedback.active && + slot.profile_feedback.connection_generation != + slot.connection_generation) { + slot.profile_feedback = {}; + } + const bool profile_feedback_was_active = + slot.profile_feedback.active; + const bool completed_feedback_had_rumble = + slot.profile_feedback.rumble_enabled; + const bool completed_feedback_had_led = + slot.profile_feedback.led_enabled; + const uint32_t completed_feedback_generation = + slot.profile_feedback.connection_generation; + profile_rumble_dispatch = + advance_profile_feedback(&slot.profile_feedback, now_ms); + if (profile_feedback_was_active && + !slot.profile_feedback.active && + completed_feedback_had_rumble && + slot.retained_host_rumble_valid) { + slot.pending_rumble = slot.retained_host_rumble; + slot.rumble_pending = true; + } + if (profile_feedback_was_active && + !slot.profile_feedback.active && + completed_feedback_had_led && slot.active && + slot.device != nullptr && + completed_feedback_generation == + slot.connection_generation) { + profile_lighting_device = slot.device; + profile_lighting_generation = + completed_feedback_generation; + profile_lighting_restore = true; + } + if (profile_rumble_dispatch) { + device = slot.device; + } + + const bool feedback_active = + static_cast(now_ms - slot.feedback_until_ms) < 0; + if (!slot.profile_feedback.active && !feedback_active && + slot.pending_profile_feedback_count != 0) { + profile_feedback = slot.pending_profile_feedback[0]; + if (slot.pending_profile_feedback_count == 2) { + slot.pending_profile_feedback[0] = + slot.pending_profile_feedback[1]; + } + --slot.pending_profile_feedback_count; + slot.pending_profile_feedback[ + slot.pending_profile_feedback_count] = {}; + const uint8_t policy = + static_cast(profile_feedback.policy); + if (slot.active && slot.device != nullptr && + profile_feedback.connection_generation == + slot.connection_generation && + profile_feedback.active_profile_number != 0 && + profile_feedback.active_profile_number <= + CONTROLLER_PROFILE_COUNT && + valid_confirmation_policy(profile_feedback.policy) && + profile_feedback.policy != + ControllerProfileConfirmationPolicy::kNone) { + slot.profile_feedback = { + slot.connection_generation, + now_ms + kProfileFeedbackPhaseDurationMs, + profile_feedback.active_profile_number, + 1, + true, + true, + (policy & static_cast( + ControllerProfileConfirmationPolicy:: + kRumble)) != 0, + (policy & static_cast( + ControllerProfileConfirmationPolicy:: + kLed)) != 0, + }; + device = slot.device; + profile_lighting_device = slot.device; + profile_lighting_generation = + slot.profile_feedback.connection_generation; + profile_lighting_dispatch = + slot.profile_feedback.led_enabled; + profile_rumble_dispatch = + slot.profile_feedback.rumble_enabled; + } + } + + if (!slot.profile_feedback.active && + slot.feedback_pending) { feedback = slot.pending_feedback; feedback_dispatch = slot.active && slot.device != nullptr && @@ -772,10 +1392,12 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } } - const bool feedback_active = - static_cast(now_ms - slot.feedback_until_ms) < 0; - if (!feedback_dispatch && !feedback_active && - slot.rumble_pending) { + const bool local_feedback_active = + slot.profile_feedback.active || + static_cast( + now_ms - slot.feedback_until_ms) < 0; + if (!profile_rumble_dispatch && !feedback_dispatch && + !local_feedback_active && slot.rumble_pending) { envelope = slot.pending_rumble; slot.rumble_pending = false; host_dispatch = @@ -789,7 +1411,28 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } critical_section_exit(&g_state_lock); - if (feedback_dispatch) { + if (profile_lighting_restore && + lighting_target_is_current( + slot_index, profile_lighting_generation, + profile_lighting_device)) { + apply_slot_lighting(slot_index, + profile_lighting_device); + } + if (profile_lighting_dispatch && + lighting_target_is_current( + slot_index, profile_lighting_generation, + profile_lighting_device)) { + apply_profile_lighting( + profile_feedback.active_profile_number, + profile_lighting_device); + } + if (profile_rumble_dispatch && device != nullptr && + device->report_parser.play_dual_rumble != nullptr) { + device->report_parser.play_dual_rumble( + device, 0, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, + kProfileFeedbackStrongMagnitude); + } else if (feedback_dispatch) { device->report_parser.play_dual_rumble( device, 0, feedback.duration_ms, feedback.weak_magnitude, feedback.strong_magnitude); @@ -799,7 +1442,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { envelope.rumble.low_frequency_magnitude == 0 && envelope.rumble.high_frequency_magnitude == 0; device->report_parser.play_dual_rumble( - device, 0, stop ? 0 : host_rumble_duration_ms(), + device, 0, stop ? 0 : envelope.duration_ms, envelope.rumble.high_frequency_magnitude, envelope.rumble.low_frequency_magnitude); } @@ -828,7 +1471,9 @@ void platform_on_init_complete() { gap_set_bondable_mode(false); sm_set_accepted_stk_generation_methods(0); gap_ssp_set_auto_accept(false); - g_pairing_event_callback.callback = handle_pairing_hci_event; + g_pairing_event_callback.callback = handle_btstack_event; + g_identity_event_callback.callback = handle_btstack_event; + sm_add_event_handler(&g_identity_event_callback); hci_add_event_handler(&g_pairing_event_callback); refresh_pairing_snapshot(); // Keep Bluepad32 autoconnect active whenever at least one slot is free. @@ -869,6 +1514,8 @@ void platform_on_device_connected(uni_hid_device_t* device) { if (slot_index < 0) { return; } + const ControllerIdentity connection_identity = + identity_for_device(device); bool tracked_connection = false; critical_section_enter_blocking(&g_state_lock); @@ -881,6 +1528,9 @@ void platform_on_device_connected(uni_hid_device_t* device) { } else { tracked_connection = slot.device == device; } + if (tracked_connection) { + slot.identity = connection_identity; + } critical_section_exit(&g_state_lock); if (tracked_connection) { @@ -898,18 +1548,18 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.device == device) { - if (slot.active) { - slot.state = make_neutral_state(); - ++slot.state_generation; - } + slot.state = make_neutral_state(); + slot.identity = controller_identity_global(); slot.device = nullptr; slot.active = false; slot.rumble_pending = false; reset_slot_hotkeys(slot); + ++slot.state_generation; ++slot.connection_generation; disconnected_tracked_device = true; } critical_section_exit(&g_state_lock); + clear_ble_identity_for_device(device); if (disconnected_tracked_device) { // Re-evaluate from scratch: resume discovery only after the final @@ -929,13 +1579,17 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { if (slot_index < 0) { return UNI_ERROR_NO_SLOTS; } + const ControllerIdentity connection_identity = + identity_for_device(device); bool occupied_mismatch = false; bool became_active = false; + uint32_t lighting_generation = 0; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; occupied_mismatch = slot.device != nullptr && slot.device != device; if (!occupied_mismatch) { + slot.identity = connection_identity; slot.device = device; if (!slot.active) { slot.state = make_neutral_state(); @@ -944,6 +1598,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { reset_slot_hotkeys(slot); ++slot.state_generation; became_active = true; + lighting_generation = slot.connection_generation; } } critical_section_exit(&g_state_lock); @@ -952,7 +1607,16 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { return UNI_ERROR_NO_SLOTS; } if (became_active) { - apply_slot_lighting(static_cast(slot_index), device); + if (lighting_target_is_current( + static_cast(slot_index), + lighting_generation, device)) { + apply_slot_lighting( + static_cast(slot_index), device); + } + if (connection_identity.stable) { + profile_service_observe_identity_on_storage_core( + connection_identity); + } } @@ -969,15 +1633,20 @@ void platform_on_controller_data(uni_hid_device_t* device, } uni_gamepad_t gamepad = controller->gamepad; + const uint16_t pre_hotkey_button_mask = + logical_button_mask(gamepad); const HotkeyDecision hotkeys = update_controller_hotkeys( static_cast(slot_index), device, gamepad); gamepad.dpad &= ~hotkeys.suppress_dpad; gamepad.buttons &= ~hotkeys.suppress_buttons; gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons; + const uint16_t output_button_mask = static_cast( + pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons); publish_device_state( static_cast(slot_index), device, - map_gamepad(gamepad, hotkeys.swap_abxy, - hotkeys.motion_enabled)); + pre_hotkey_button_mask, + map_gamepad( + gamepad, hotkeys.motion_enabled, output_button_mask)); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -1021,6 +1690,7 @@ uni_platform* get_platform() { halt_wireless_backend(); } configuration_service_initialize_on_storage_core(); + profile_service_initialize_on_storage_core(); if (cyw43_arch_init() != 0) { halt_wireless_backend(); } @@ -1047,14 +1717,17 @@ void bluepad32_input_backend_init() { critical_section_init(&g_state_lock); configuration_service_prepare(); + profile_service_prepare(); for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; slot = {}; slot.state = make_neutral_state(); + slot.identity = controller_identity_global(); slot.pending_rumble.slot = slot_index; reset_slot_hotkeys(slot); g_consumed_generation[slot_index] = 0; g_last_snapshot_generation[slot_index] = 0; + g_ble_identity_mappings[slot_index] = {}; } g_pairing_window_requested = false; g_pairing_snapshot_requested = false; @@ -1139,27 +1812,31 @@ void bluepad32_input_backend_pairing_snapshot( } -bool bluepad32_input_backend_snapshot(uint8_t slot_index, - ControllerState* out) { - if (out == nullptr || !valid_slot(slot_index)) { - return false; +void bluepad32_input_backend_snapshot(uint8_t slot_index, + Bluepad32SlotSnapshot* out) { + if (out == nullptr) { + return; } - if (!g_initialized) { - *out = make_neutral_state(); - return false; + *out = {}; + if (!valid_slot(slot_index) || !g_initialized) { + return; } critical_section_enter_blocking(&g_state_lock); - *out = g_slots[slot_index].state; - const bool controller_active = g_slots[slot_index].active; - const uint32_t generation = g_slots[slot_index].state_generation; + const BackendSlot& slot = g_slots[slot_index]; + out->active = slot.active; + out->connection_generation = slot.connection_generation; + out->identity = slot.identity; + out->pre_hotkey_button_mask = + slot.pre_hotkey_button_mask; + out->state = slot.state; + const uint32_t state_generation = slot.state_generation; critical_section_exit(&g_state_lock); - if (generation == g_consumed_generation[slot_index]) { - out->motion_sample_count = 0; + if (state_generation == g_consumed_generation[slot_index]) { + out->state.motion_sample_count = 0; } - g_last_snapshot_generation[slot_index] = generation; - return controller_active; + g_last_snapshot_generation[slot_index] = state_generation; } void bluepad32_input_backend_report_sent(uint8_t slot_index) { @@ -1175,11 +1852,52 @@ void bluepad32_input_backend_queue_rumble( return; } + const uint16_t duration_ms = host_rumble_duration_ms(); critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device != nullptr) { - slot.pending_rumble = {slot_index, slot.connection_generation, rumble}; + const RumbleEnvelope envelope{ + slot_index, slot.connection_generation, rumble, + duration_ms}; + slot.pending_rumble = envelope; slot.rumble_pending = true; + if (duration_ms == kXInputHostRumbleDurationMs) { + slot.retained_host_rumble = envelope; + slot.retained_host_rumble_valid = true; + } else { + slot.retained_host_rumble = {}; + slot.retained_host_rumble_valid = false; + } + } + critical_section_exit(&g_state_lock); +} + +void bluepad32_input_backend_queue_profile_feedback( + uint8_t slot_index, uint32_t connection_generation, + uint8_t active_profile_number, + ControllerProfileConfirmationPolicy policy) { + if (!g_initialized || !valid_slot(slot_index) || + active_profile_number == 0 || + active_profile_number > CONTROLLER_PROFILE_COUNT || + !valid_confirmation_policy(policy) || + policy == ControllerProfileConfirmationPolicy::kNone) { + return; + } + + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && slot.device != nullptr && + slot.connection_generation == connection_generation) { + const ProfileFeedbackEnvelope feedback{ + connection_generation, active_profile_number, policy}; + if (slot.pending_profile_feedback_count < + kProfileFeedbackQueueCapacity) { + slot.pending_profile_feedback[ + slot.pending_profile_feedback_count++] = feedback; + } else { + slot.pending_profile_feedback[ + kProfileFeedbackQueueCapacity - 1u] = feedback; + } } critical_section_exit(&g_state_lock); } diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index d7bdaa6..36ce5ae 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -3,6 +3,8 @@ #include #include "controller_color.h" +#include "controller_identity.h" +#include "controller_profile.h" #include "controller_state.h" #include "switch_haptics.h" @@ -32,16 +34,35 @@ struct Bluepad32PairingSnapshot { bool overflow; Bluepad32PairingRecord records[BLUEPAD32_PAIRING_RECORD_CAPACITY]; }; +struct Bluepad32SlotSnapshot { + bool active; + uint32_t connection_generation; + ControllerIdentity identity; + // Physical logical-button state before backend hotkey consumption. + // Valid only for this snapshot's connection generation. + uint16_t pre_hotkey_button_mask; + ControllerState state; +}; + void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); void bluepad32_input_backend_open_pairing_window(); void bluepad32_input_backend_clear_pairings(); -bool bluepad32_input_backend_snapshot(uint8_t slot, ControllerState* out); +void bluepad32_input_backend_snapshot(uint8_t slot, + Bluepad32SlotSnapshot* out); void bluepad32_input_backend_request_pairing_snapshot(); void bluepad32_input_backend_pairing_snapshot( Bluepad32PairingSnapshot* out); void bluepad32_input_backend_report_sent(uint8_t slot); void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); +// Enqueue bounded local profile confirmation for the matching live connection +// generation. The two-entry per-slot FIFO preserves initial-then-switch +// ordering. Profile lighting is transient and restored to the steady slot +// indication after the final gap. Profile numbers are one-based (1..4). +void bluepad32_input_backend_queue_profile_feedback( + uint8_t slot, uint32_t connection_generation, + uint8_t active_profile_number, + ControllerProfileConfirmationPolicy policy); diff --git a/build.py b/build.py index a46585d..e1360f1 100644 --- a/build.py +++ b/build.py @@ -35,6 +35,232 @@ MACROS = tuple( for component in ("R", "G", "B") ) +CMAKE_CACHE_PATHS = tuple( + build_dir / "CMakeCache.txt" + for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR) +) +TOOLCHAIN_COMPILER = ( + "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc" +) + + +class BuildEnvironmentError(RuntimeError): + """A required Pico build dependency could not be resolved.""" + + +def parse_cmake_cache(cache_path): + """Return the simple key/value entries from an existing CMake cache.""" + cache_path = Path(cache_path) + try: + lines = cache_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + return {} + + entries = {} + for line in lines: + if not line or line.startswith(("//", "#")) or "=" not in line: + continue + key_and_type, value = line.split("=", 1) + key = key_and_type.split(":", 1)[0] + if key: + entries[key] = value + return entries + + +def _cache_path(value, cache_path): + path = Path(value).expanduser() + if not path.is_absolute(): + path = cache_path.parent / path + return path + + +def _versioned_candidates(parent): + try: + return sorted( + (path for path in parent.iterdir() if path.is_dir()), + key=lambda path: path.name, + reverse=True, + ) + except OSError: + return [] + + +def _sdk_fallback_candidates(): + yield ("project-local install", BUILD_DIR / "_deps" / "pico_sdk-src") + pico_sdk_home = Path.home() / ".pico-sdk" / "sdk" + for path in _versioned_candidates(pico_sdk_home): + yield ("user Pico SDK install", path) + yield ("user Pico SDK install", Path.home() / "pico" / "pico-sdk") + yield ("user Pico SDK install", Path.home() / "pico-sdk") + yield ("system Pico SDK install", Path("/opt/pico-sdk")) + yield ("system Pico SDK install", Path("/usr/local/pico-sdk")) + yield ("system Pico SDK install", Path("/usr/share/pico-sdk")) + + +def _toolchain_fallback_candidates(): + yield ("project-local install", BUILD_DIR / "toolchain") + pico_toolchain_home = Path.home() / ".pico-sdk" / "toolchain" + for path in _versioned_candidates(pico_toolchain_home): + yield ("user Pico toolchain install", path) + yield ( + "user Pico toolchain install", + Path.home() / "pico" / "arm-none-eabi-gcc", + ) + yield ("user Pico toolchain install", Path.home() / "arm-none-eabi-gcc") + yield ("system Pico toolchain install", Path("/opt/arm-none-eabi-gcc")) + yield ( + "system Pico toolchain install", + Path("/usr/local/arm-none-eabi-gcc"), + ) + + +def _valid_sdk(path): + return (path / "pico_sdk_init.cmake").is_file() + + +def _valid_toolchain(path): + return (path / "bin" / TOOLCHAIN_COMPILER).is_file() + + +def _first_valid_candidate(candidates, validator): + for source, candidate in candidates: + path = Path(candidate).expanduser() + if validator(path): + return path, source + return None, None + + +def _cache_candidates(cache_paths, variables): + for cache_path in cache_paths: + cache_path = Path(cache_path) + entries = parse_cmake_cache(cache_path) + for variable, compiler_path in variables: + value = entries.get(variable) + if not value: + continue + path = _cache_path(value, cache_path) + if compiler_path: + path = path.parent.parent + yield (f"cache {cache_path}", path) + + +def _explicit_path(environ, variable, validator, expected): + if variable not in environ: + return None + value = environ[variable] + if not value: + raise BuildEnvironmentError(f"{variable} is set but empty.") + path = Path(value).expanduser() + if not validator(path): + raise BuildEnvironmentError( + f"{variable} is set to {path}, but {expected} was not found." + ) + return path + + +def configure_pico_environment( + *, + environ=None, + cache_paths=None, + sdk_candidates=None, + toolchain_candidates=None, + which=None, +): + """Resolve Pico dependencies and apply auto-detected environment values.""" + environ = os.environ if environ is None else environ + cache_paths = CMAKE_CACHE_PATHS if cache_paths is None else cache_paths + + which = shutil.which if which is None else which + explicit_sdk = _explicit_path( + environ, + "PICO_SDK_PATH", + _valid_sdk, + "pico_sdk_init.cmake", + ) + explicit_toolchain = _explicit_path( + environ, + "PICO_TOOLCHAIN_PATH", + _valid_toolchain, + f"bin/{TOOLCHAIN_COMPILER}", + ) + + sdk_path = explicit_sdk + sdk_source = None + if sdk_path is None: + sdk_path, sdk_source = _first_valid_candidate( + _cache_candidates( + cache_paths, + (("PICO_SDK_PATH", False),), + ), + _valid_sdk, + ) + if sdk_path is None: + sdk_path, sdk_source = _first_valid_candidate( + ( + _sdk_fallback_candidates() + if sdk_candidates is None + else sdk_candidates + ), + _valid_sdk, + ) + + compiler_on_path = which( + TOOLCHAIN_COMPILER, + path=environ.get("PATH", ""), + ) + toolchain_path = explicit_toolchain + toolchain_source = None + if toolchain_path is None and compiler_on_path is None: + toolchain_path, toolchain_source = _first_valid_candidate( + _cache_candidates( + cache_paths, + ( + ("PICO_TOOLCHAIN_PATH", False), + ("CMAKE_C_COMPILER", True), + ("PICO_COMPILER_CC", True), + ), + ), + _valid_toolchain, + ) + if toolchain_path is None: + toolchain_path, toolchain_source = _first_valid_candidate( + ( + _toolchain_fallback_candidates() + if toolchain_candidates is None + else toolchain_candidates + ), + _valid_toolchain, + ) + + missing = [] + if sdk_path is None: + missing.append("Pico SDK (set PICO_SDK_PATH)") + if toolchain_path is None and compiler_on_path is None: + missing.append( + "Arm GNU toolchain " + "(set PICO_TOOLCHAIN_PATH or add arm-none-eabi-gcc to PATH)" + ) + if missing: + raise BuildEnvironmentError( + "Missing build prerequisite(s): " + "; ".join(missing) + "." + ) + + updates = {} + detected = [] + if explicit_sdk is None: + updates["PICO_SDK_PATH"] = str(sdk_path) + detected.append(("PICO_SDK_PATH", sdk_path, sdk_source)) + if explicit_toolchain is None and compiler_on_path is None: + updates["PICO_TOOLCHAIN_PATH"] = str(toolchain_path) + detected.append( + ("PICO_TOOLCHAIN_PATH", toolchain_path, toolchain_source) + ) + + environ.update(updates) + for variable, path, source in detected: + print(f"Auto-detected {variable}={path} ({source})") + return updates + def parse_args(): parser = argparse.ArgumentParser( description="Build and flash the project, optionally setting grip colors.", @@ -197,6 +423,12 @@ def flash(elf_path, allow_elf_override): def main(): args = parse_args() + try: + configure_pico_environment() + except BuildEnvironmentError as exc: + sys.stderr.write(f"Error: {exc}\n") + sys.exit(1) + color = None if args.random_grip_color: diff --git a/configuration_service.cpp b/configuration_service.cpp index e693947..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; @@ -171,7 +173,15 @@ ConfigurationTransactionStatus configuration_service_reset( if (status == ConfigurationTransactionStatus::kReceiving) { status = g_transaction.finish(transaction_id); } + if (status == ConfigurationTransactionStatus::kPending && + g_snapshot.reset_generation != UINT32_MAX) { + ++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; } @@ -184,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 5a12709..e540c26 100644 --- a/configuration_service.h +++ b/configuration_service.h @@ -17,6 +17,7 @@ struct ConfigurationServiceSnapshot { AdapterConfiguration configuration{}; uint32_t generation = 0; uint32_t payload_crc = 0; + uint32_t reset_generation = 0; ConfigurationTransactionSnapshot transaction{}; }; @@ -35,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_hotkey_config.h b/controller_hotkey_config.h index ebd2cc0..818515f 100644 --- a/controller_hotkey_config.h +++ b/controller_hotkey_config.h @@ -2,26 +2,12 @@ #pragma once -// Bluepad32 button masks. Default chord: L + R + SELECT + START. -#define SWITCH_ABXY_HOTKEY_BUTTON_MASK \ - (BUTTON_SHOULDER_L | BUTTON_SHOULDER_R) -#define SWITCH_ABXY_HOTKEY_MISC_MASK \ - (MISC_BUTTON_SELECT | MISC_BUTTON_START) - // Motion toggle chord: D-pad Up + R + START / Options. #define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP #define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R #define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START #define SWITCH_MOTION_DEFAULT_ENABLED 1 -// 0 starts each new connection in Nintendo positional layout; 1 starts swapped. -#define SWITCH_ABXY_DEFAULT_SWAPPED 0 - -// Local confirmation pulse sent only to the controller that toggled. -#define SWITCH_ABXY_FEEDBACK_DURATION_MS 120 -#define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0xFF -#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0xFF - // A longer pulse confirms disabled; a shorter pulse confirms enabled. #define SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS 180 #define SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE 0xA0 diff --git a/controller_identity.cpp b/controller_identity.cpp new file mode 100644 index 0000000..bfe32b3 --- /dev/null +++ b/controller_identity.cpp @@ -0,0 +1,82 @@ +#include "controller_identity.h" + +#include + +namespace { + +bool controller_identity_valid(const ControllerIdentity& identity) { + const uint8_t transport = static_cast(identity.transport); + if (transport > static_cast(ControllerTransport::kBle)) { + return false; + } + if (identity.stable) { + return identity.transport != ControllerTransport::kUnknown; + } + return controller_identity_is_global(identity); +} + +} // namespace + +ControllerIdentity controller_identity_global() { + return {}; +} + +bool controller_identity_is_global(const ControllerIdentity& identity) { + const ControllerIdentity global{}; + return controller_identity_equal(identity, global); +} + +bool controller_identity_equal(const ControllerIdentity& first, + const ControllerIdentity& second) { + return first.stable == second.stable && + first.transport == second.transport && + first.address_type == second.address_type && + memcmp(first.address, second.address, sizeof(first.address)) == 0 && + first.vendor_id == second.vendor_id && + first.product_id == second.product_id; +} + +bool controller_identity_encode(const ControllerIdentity& identity, + uint8_t* output, size_t output_size) { + if (output == nullptr || output_size < CONTROLLER_IDENTITY_ENCODED_SIZE || + !controller_identity_valid(identity)) { + return false; + } + + output[0] = identity.stable ? 1 : 0; + output[1] = static_cast(identity.transport); + output[2] = identity.address_type; + output[3] = 0; + memcpy(&output[4], identity.address, sizeof(identity.address)); + output[10] = static_cast(identity.vendor_id); + output[11] = static_cast(identity.vendor_id >> 8); + output[12] = static_cast(identity.product_id); + output[13] = static_cast(identity.product_id >> 8); + return true; +} + +bool controller_identity_decode(const uint8_t* input, size_t input_size, + ControllerIdentity* output) { + if (input == nullptr || output == nullptr || + input_size != CONTROLLER_IDENTITY_ENCODED_SIZE || input[0] > 1 || + input[1] > static_cast(ControllerTransport::kBle) || + input[3] != 0) { + return false; + } + + ControllerIdentity decoded{}; + decoded.stable = input[0] != 0; + decoded.transport = static_cast(input[1]); + decoded.address_type = input[2]; + memcpy(decoded.address, &input[4], sizeof(decoded.address)); + decoded.vendor_id = static_cast(input[10]) | + static_cast(input[11] << 8); + decoded.product_id = static_cast(input[12]) | + static_cast(input[13] << 8); + if (!controller_identity_valid(decoded)) { + return false; + } + + *output = decoded; + return true; +} diff --git a/controller_identity.h b/controller_identity.h new file mode 100644 index 0000000..c363991 --- /dev/null +++ b/controller_identity.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +constexpr size_t CONTROLLER_IDENTITY_ENCODED_SIZE = 14; + +enum class ControllerTransport : uint8_t { + kUnknown = 0, + kClassic = 1, + kBle = 2, +}; + +struct ControllerIdentity { + bool stable = false; + ControllerTransport transport = ControllerTransport::kUnknown; + uint8_t address_type = 0; + uint8_t address[6]{}; + uint16_t vendor_id = 0; + uint16_t product_id = 0; +}; + +ControllerIdentity controller_identity_global(); +bool controller_identity_is_global(const ControllerIdentity& identity); +bool controller_identity_equal(const ControllerIdentity& first, + const ControllerIdentity& second); +bool controller_identity_encode(const ControllerIdentity& identity, + uint8_t* output, size_t output_size); +bool controller_identity_decode(const uint8_t* input, size_t input_size, + ControllerIdentity* output); diff --git a/controller_profile.cpp b/controller_profile.cpp new file mode 100644 index 0000000..7209944 --- /dev/null +++ b/controller_profile.cpp @@ -0,0 +1,741 @@ +#include "controller_profile.h" + +#include + +namespace { + +constexpr uint8_t kDatabaseMagic[4] = {'S', 'P', 'D', 'B'}; +constexpr size_t kFallbackOffset = CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; +constexpr size_t kEntriesOffset = + kFallbackOffset + CONTROLLER_PROFILE_COUNT * + CONTROLLER_PROFILE_ENCODED_SIZE; +constexpr uint8_t kStickInvertX = 1u << 0; +constexpr uint8_t kStickInvertY = 1u << 1; +constexpr uint8_t kMacroOverrideMask = + kControllerProfileOverrideButtons | + kControllerProfileOverrideLeftStick | + kControllerProfileOverrideRightStick | + kControllerProfileOverrideLeftTrigger | + kControllerProfileOverrideRightTrigger; +constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000; + +uint16_t profile_read_u16(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8); +} + +int16_t profile_read_i16(const uint8_t* input) { + return static_cast(profile_read_u16(input)); +} + +void profile_write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void profile_write_i16(uint8_t* output, int16_t value) { + profile_write_u16(output, static_cast(value)); +} + +bool profile_bytes_are_zero(const uint8_t* data, size_t size) { + for (size_t index = 0; index < size; ++index) { + if (data[index] != 0) { + return false; + } + } + return true; +} + +bool valid_button(uint8_t button) { + return button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT || + button == 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 || + step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) { + return false; + } + if ((step.override_flags & kControllerProfileOverrideButtons) == 0 && + step.output_button_mask != 0) { + return false; + } + if ((step.override_flags & kControllerProfileOverrideLeftStick) == 0 && + (step.left_stick_x != 0 || step.left_stick_y != 0)) { + return false; + } + if ((step.override_flags & kControllerProfileOverrideRightStick) == 0 && + (step.right_stick_x != 0 || step.right_stick_y != 0)) { + return false; + } + if ((step.override_flags & kControllerProfileOverrideLeftTrigger) == 0 && + step.left_trigger != 0) { + return false; + } + if ((step.override_flags & kControllerProfileOverrideRightTrigger) == 0 && + step.right_trigger != 0) { + return false; + } + 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) { + const size_t range_end = range_offset + output_size; + const size_t field_end = field_offset + field_size; + if (range_offset >= field_end || field_offset >= range_end) { + return; + } + const size_t start = range_offset > field_offset + ? range_offset + : field_offset; + const size_t end = range_end < field_end ? range_end : field_end; + memcpy(&output[start - range_offset], &field[start - field_offset], + end - start); +} + +bool read_zero_region(ControllerProfileDatabaseRead read, void* context, + size_t offset, size_t size) { + uint8_t buffer[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + while (size != 0) { + const size_t chunk = size < sizeof(buffer) ? size : sizeof(buffer); + if (!read(context, offset, buffer, chunk) || + !profile_bytes_are_zero(buffer, chunk)) { + return false; + } + offset += chunk; + size -= chunk; + } + return true; +} + +} // namespace + +ControllerProfile controller_profile_default(const ControllerIdentity& identity, + uint8_t profile_index) { + (void)identity; + (void)profile_index; + ControllerProfile profile{}; + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + profile.button_map[index] = index; + profile.turbo_modes[index] = ControllerProfileTurboMode::kOff; + } + for (ControllerProfileStickConfiguration& stick : profile.sticks) { + stick.center_x = 0; + stick.center_y = 0; + stick.inner_deadzone = 0; + stick.outer_saturation = 32767; + stick.curve_q8_8 = 256; + stick.invert_x = false; + stick.invert_y = false; + } + for (ControllerProfileTriggerConfiguration& trigger : profile.triggers) { + trigger.lower_deadzone = 0; + trigger.upper_saturation = UINT16_MAX; + trigger.curve_q8_8 = 256; + trigger.digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + } + profile.weak_rumble_scale = UINT8_MAX; + profile.strong_rumble_scale = UINT8_MAX; + profile.confirmation_policy = + ControllerProfileConfirmationPolicy::kRumbleAndLed; + profile.switching_chord = 0; + profile.macro_trigger = CONTROLLER_PROFILE_NO_BUTTON; + profile.macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; + profile.macro_step_count = 1; + for (ControllerProfileMacroStep& step : profile.macro_steps) { + step = {}; + step.type = ControllerProfileMacroStepType::kEnd; + } + return profile; +} + +bool controller_profile_validate(const ControllerProfile& profile) { + for (uint8_t output : profile.button_map) { + if (!valid_button(output)) { + return false; + } + } + for (const ControllerProfileStickConfiguration& stick : profile.sticks) { + if (stick.inner_deadzone >= stick.outer_saturation || + stick.outer_saturation > 32767 || stick.curve_q8_8 == 0) { + return false; + } + } + for (const ControllerProfileTriggerConfiguration& trigger : + profile.triggers) { + if (trigger.lower_deadzone >= trigger.upper_saturation || + trigger.curve_q8_8 == 0) { + return false; + } + } + if (static_cast(profile.confirmation_policy) > + static_cast( + ControllerProfileConfirmationPolicy::kRumbleAndLed) || + !valid_button(profile.macro_trigger) || + !valid_button(profile.macro_cancel) || + profile.macro_step_count == 0 || + profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) { + return false; + } + for (ControllerProfileTurboMode mode : profile.turbo_modes) { + if (static_cast(mode) > + static_cast(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; +} + +bool controller_profile_encode(const ControllerProfile& profile, + uint8_t* output, size_t output_size) { + if (output == nullptr || output_size != CONTROLLER_PROFILE_ENCODED_SIZE || + !controller_profile_validate(profile)) { + return false; + } + memset(output, 0, output_size); + profile_write_u16(&output[0], CONTROLLER_PROFILE_SCHEMA_VERSION); + profile_write_u16(&output[2], CONTROLLER_PROFILE_ENCODED_SIZE); + memcpy(&output[4], profile.button_map, + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + for (uint8_t index = 0; index < 2; ++index) { + const ControllerProfileStickConfiguration& stick = + profile.sticks[index]; + uint8_t* encoded = &output[20 + index * 16]; + profile_write_i16(&encoded[0], stick.center_x); + profile_write_i16(&encoded[2], stick.center_y); + profile_write_u16(&encoded[4], stick.inner_deadzone); + profile_write_u16(&encoded[6], stick.outer_saturation); + profile_write_u16(&encoded[8], stick.curve_q8_8); + encoded[10] = (stick.invert_x ? kStickInvertX : 0) | + (stick.invert_y ? kStickInvertY : 0); + } + for (uint8_t index = 0; index < 2; ++index) { + const ControllerProfileTriggerConfiguration& trigger = + profile.triggers[index]; + uint8_t* encoded = &output[52 + index * 10]; + profile_write_u16(&encoded[0], trigger.lower_deadzone); + profile_write_u16(&encoded[2], trigger.upper_saturation); + profile_write_u16(&encoded[4], trigger.curve_q8_8); + profile_write_u16(&encoded[6], trigger.digital_threshold); + } + output[72] = profile.weak_rumble_scale; + output[73] = profile.strong_rumble_scale; + output[74] = static_cast(profile.confirmation_policy); + profile_write_u16(&output[76], profile.switching_chord); + output[78] = profile.macro_trigger; + output[79] = profile.macro_cancel; + output[80] = profile.macro_step_count; + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + output[82 + index] = + static_cast(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(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); + } + return true; +} + +bool controller_profile_decode(const uint8_t* input, size_t input_size, + ControllerProfile* output) { + if (input == nullptr || output == nullptr || + input_size != CONTROLLER_PROFILE_ENCODED_SIZE) { + return false; + } + const uint16_t schema_version = profile_read_u16(&input[0]); + 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) || + !profile_bytes_are_zero(&input[60], 2) || + !profile_bytes_are_zero(&input[70], 2) || input[75] != 0 || + input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) || + !profile_bytes_are_zero(&input[252], 4)) { + return false; + } + + ControllerProfile profile{}; + memcpy(profile.button_map, &input[4], + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + 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) { + return false; + } + stick.center_x = profile_read_i16(&encoded[0]); + stick.center_y = profile_read_i16(&encoded[2]); + stick.inner_deadzone = profile_read_u16(&encoded[4]); + stick.outer_saturation = profile_read_u16(&encoded[6]); + stick.curve_q8_8 = profile_read_u16(&encoded[8]); + stick.invert_x = (encoded[10] & kStickInvertX) != 0; + stick.invert_y = (encoded[10] & kStickInvertY) != 0; + } + for (uint8_t index = 0; index < 2; ++index) { + ControllerProfileTriggerConfiguration& trigger = + profile.triggers[index]; + const uint8_t* encoded = &input[52 + index * 10]; + trigger.lower_deadzone = profile_read_u16(&encoded[0]); + trigger.upper_saturation = profile_read_u16(&encoded[2]); + trigger.curve_q8_8 = profile_read_u16(&encoded[4]); + trigger.digital_threshold = profile_read_u16(&encoded[6]); + if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && + trigger.digital_threshold == kLegacyDefaultDigitalThreshold) { + trigger.digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + } + } + profile.weak_rumble_scale = input[72]; + profile.strong_rumble_scale = input[73]; + profile.confirmation_policy = + static_cast(input[74]); + profile.switching_chord = profile_read_u16(&input[76]); + profile.macro_trigger = input[78]; + profile.macro_cancel = input[79]; + profile.macro_step_count = input[80]; + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + profile.turbo_modes[index] = + static_cast(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; + } + step.type = static_cast(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; + } + *output = profile; + return true; +} + +void controller_profile_database_default(ControllerProfileDatabase* database) { + if (database == nullptr) { + return; + } + database->fallback_active_profile = 0; + const ControllerIdentity global = controller_identity_global(); + for (ControllerProfileDatabaseEntry& entry : database->entries) { + entry.used = false; + entry.identity = global; + entry.active_profile = 0; + } + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + database->fallback_profiles[profile_index] = + controller_profile_default(global, profile_index); + } +} + +bool controller_profile_database_validate( + const ControllerProfileDatabase& database) { + if (database.fallback_active_profile >= CONTROLLER_PROFILE_COUNT) { + return false; + } + for (const ControllerProfile& profile : database.fallback_profiles) { + if (!controller_profile_validate(profile)) { + return false; + } + } + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) { + const ControllerProfileDatabaseEntry& entry = database.entries[index]; + if (!entry.used) { + continue; + } + if (!entry.identity.stable || + controller_identity_is_global(entry.identity) || + entry.active_profile >= CONTROLLER_PROFILE_COUNT) { + return false; + } + for (uint8_t prior = 0; prior < index; ++prior) { + if (database.entries[prior].used && + controller_identity_equal(database.entries[prior].identity, + entry.identity)) { + return false; + } + } + for (const ControllerProfile& profile : entry.profiles) { + if (!controller_profile_validate(profile)) { + return false; + } + } + } + return true; +} + +bool controller_profile_database_encode_range( + const ControllerProfileDatabase& database, size_t offset, + uint8_t* output, size_t size) { + if (output == nullptr || offset > CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + size > CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset || + !controller_profile_database_validate(database)) { + return false; + } + memset(output, 0, size); + uint8_t header[CONTROLLER_PROFILE_DATABASE_HEADER_SIZE]{}; + memcpy(header, kDatabaseMagic, sizeof(kDatabaseMagic)); + profile_write_u16(&header[4], + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION); + profile_write_u16(&header[6], CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + header[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + header[9] = CONTROLLER_PROFILE_COUNT; + header[10] = database.fallback_active_profile; + uint8_t used_count = 0; + for (const ControllerProfileDatabaseEntry& entry : database.entries) { + used_count += entry.used ? 1 : 0; + } + header[11] = used_count; + copy_overlap(offset, output, size, 0, header, sizeof(header)); + + uint8_t encoded_profile[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const size_t profile_offset = + kFallbackOffset + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + if (offset < profile_offset + CONTROLLER_PROFILE_ENCODED_SIZE && + offset + size > profile_offset) { + if (!controller_profile_encode( + database.fallback_profiles[profile_index], + encoded_profile, sizeof(encoded_profile))) { + return false; + } + copy_overlap(offset, output, size, profile_offset, + encoded_profile, sizeof(encoded_profile)); + } + } + + for (uint8_t entry_index = 0; + entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++entry_index) { + const ControllerProfileDatabaseEntry& entry = + database.entries[entry_index]; + if (!entry.used) { + continue; + } + const size_t entry_offset = + kEntriesOffset + entry_index * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; + uint8_t entry_header[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE]{}; + if (!controller_identity_encode(entry.identity, entry_header, + CONTROLLER_IDENTITY_ENCODED_SIZE)) { + return false; + } + entry_header[14] = entry.active_profile; + entry_header[15] = 1; + copy_overlap(offset, output, size, entry_offset, entry_header, + sizeof(entry_header)); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const size_t profile_offset = + entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + if (offset < profile_offset + CONTROLLER_PROFILE_ENCODED_SIZE && + offset + size > profile_offset) { + if (!controller_profile_encode(entry.profiles[profile_index], + encoded_profile, + sizeof(encoded_profile))) { + return false; + } + copy_overlap(offset, output, size, profile_offset, + encoded_profile, sizeof(encoded_profile)); + } + } + } + return true; +} + +bool controller_profile_database_decode( + ControllerProfileDatabaseRead read, void* context, + ControllerProfileDatabase* output) { + if (read == nullptr || output == nullptr) { + return false; + } + uint8_t header[CONTROLLER_PROFILE_DATABASE_HEADER_SIZE]{}; + if (!read(context, 0, header, sizeof(header))) { + return false; + } + const uint16_t schema_version = profile_read_u16(&header[4]); + if (memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 || + (schema_version != + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + schema_version != CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || + profile_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || + header[9] != CONTROLLER_PROFILE_COUNT || + header[10] >= CONTROLLER_PROFILE_COUNT || + header[11] > CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || + !profile_bytes_are_zero(&header[12], sizeof(header) - 12)) { + return false; + } + + controller_profile_database_default(output); + output->fallback_active_profile = header[10]; + uint8_t encoded_profile[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const size_t profile_offset = + kFallbackOffset + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + if (!read(context, profile_offset, encoded_profile, + sizeof(encoded_profile)) || + !controller_profile_decode( + encoded_profile, sizeof(encoded_profile), + &output->fallback_profiles[profile_index])) { + controller_profile_database_default(output); + return false; + } + } + + uint8_t decoded_used_count = 0; + for (uint8_t entry_index = 0; + entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++entry_index) { + const size_t entry_offset = + kEntriesOffset + entry_index * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; + uint8_t entry_header[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE]{}; + if (!read(context, entry_offset, entry_header, + sizeof(entry_header))) { + controller_profile_database_default(output); + return false; + } + if (entry_header[15] == 0) { + if (!profile_bytes_are_zero(entry_header, sizeof(entry_header)) || + !read_zero_region( + read, context, + entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE, + CONTROLLER_PROFILE_COUNT * + CONTROLLER_PROFILE_ENCODED_SIZE)) { + controller_profile_database_default(output); + return false; + } + continue; + } + if (entry_header[15] != 1 || + entry_header[14] >= CONTROLLER_PROFILE_COUNT) { + controller_profile_database_default(output); + return false; + } + ControllerProfileDatabaseEntry& entry = output->entries[entry_index]; + if (!controller_identity_decode(entry_header, + CONTROLLER_IDENTITY_ENCODED_SIZE, + &entry.identity) || + !entry.identity.stable || + controller_identity_is_global(entry.identity)) { + controller_profile_database_default(output); + return false; + } + entry.used = true; + entry.active_profile = entry_header[14]; + ++decoded_used_count; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const size_t profile_offset = + entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + if (!read(context, profile_offset, encoded_profile, + sizeof(encoded_profile)) || + !controller_profile_decode(encoded_profile, + sizeof(encoded_profile), + &entry.profiles[profile_index])) { + controller_profile_database_default(output); + return false; + } + } + } + if (decoded_used_count != header[11] || + !controller_profile_database_validate(*output)) { + controller_profile_database_default(output); + return false; + } + return true; +} + +const ControllerProfileDatabaseEntry* controller_profile_database_find( + const ControllerProfileDatabase& database, + const ControllerIdentity& identity) { + if (!identity.stable || controller_identity_is_global(identity)) { + return nullptr; + } + for (const ControllerProfileDatabaseEntry& entry : database.entries) { + if (entry.used && + controller_identity_equal(entry.identity, identity)) { + return &entry; + } + } + return nullptr; +} + +ControllerProfileDatabaseEntry* controller_profile_database_find( + ControllerProfileDatabase* database, + const ControllerIdentity& identity) { + if (database == nullptr) { + return nullptr; + } + return const_cast( + controller_profile_database_find(*database, identity)); +} + +ControllerProfileDatabaseResult controller_profile_database_ensure( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + ControllerProfileDatabaseEntry** output) { + if (database == nullptr || output == nullptr || !identity.stable || + controller_identity_is_global(identity)) { + return ControllerProfileDatabaseResult::kInvalidArgument; + } + if (ControllerProfileDatabaseEntry* found = + controller_profile_database_find(database, identity)) { + *output = found; + return ControllerProfileDatabaseResult::kOk; + } + for (ControllerProfileDatabaseEntry& entry : database->entries) { + if (!entry.used) { + entry.active_profile = 0; + entry.used = true; + entry.identity = identity; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + entry.profiles[profile_index] = + controller_profile_default(identity, profile_index); + } + *output = &entry; + return ControllerProfileDatabaseResult::kOk; + } + } + return ControllerProfileDatabaseResult::kFull; +} + +const ControllerProfile* controller_profile_database_get( + const ControllerProfileDatabase& database, + const ControllerIdentity& identity, uint8_t profile_index) { + if (profile_index >= CONTROLLER_PROFILE_COUNT) { + return nullptr; + } + if (controller_identity_is_global(identity)) { + return &database.fallback_profiles[profile_index]; + } + const ControllerProfileDatabaseEntry* entry = + controller_profile_database_find(database, identity); + return entry == nullptr ? nullptr : &entry->profiles[profile_index]; +} + +ControllerProfileDatabaseResult controller_profile_database_set( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index, const ControllerProfile& profile) { + if (database == nullptr || profile_index >= CONTROLLER_PROFILE_COUNT || + !controller_profile_validate(profile)) { + return ControllerProfileDatabaseResult::kInvalidArgument; + } + if (controller_identity_is_global(identity)) { + database->fallback_profiles[profile_index] = profile; + return ControllerProfileDatabaseResult::kOk; + } + ControllerProfileDatabaseEntry* entry = nullptr; + const ControllerProfileDatabaseResult result = + controller_profile_database_ensure(database, identity, &entry); + if (result == ControllerProfileDatabaseResult::kOk) { + entry->profiles[profile_index] = profile; + } + return result; +} + +ControllerProfileDatabaseResult controller_profile_database_reset( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index) { + if (database == nullptr || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT)) { + return ControllerProfileDatabaseResult::kInvalidArgument; + } + if (controller_identity_is_global(identity)) { + for (uint8_t index = 0; index < CONTROLLER_PROFILE_COUNT; ++index) { + if (profile_index == CONTROLLER_PROFILE_ALL || + profile_index == index) { + database->fallback_profiles[index] = + controller_profile_default(identity, index); + } + } + return ControllerProfileDatabaseResult::kOk; + } + ControllerProfileDatabaseEntry* entry = nullptr; + const ControllerProfileDatabaseResult result = + controller_profile_database_ensure(database, identity, &entry); + if (result != ControllerProfileDatabaseResult::kOk) { + return result; + } + for (uint8_t index = 0; index < CONTROLLER_PROFILE_COUNT; ++index) { + if (profile_index == CONTROLLER_PROFILE_ALL || + profile_index == index) { + entry->profiles[index] = controller_profile_default(identity, index); + } + } + return ControllerProfileDatabaseResult::kOk; +} + +ControllerProfileDatabaseResult controller_profile_database_activate( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index) { + if (database == nullptr || profile_index >= CONTROLLER_PROFILE_COUNT) { + return ControllerProfileDatabaseResult::kInvalidArgument; + } + if (controller_identity_is_global(identity)) { + database->fallback_active_profile = profile_index; + return ControllerProfileDatabaseResult::kOk; + } + ControllerProfileDatabaseEntry* entry = nullptr; + const ControllerProfileDatabaseResult result = + controller_profile_database_ensure(database, identity, &entry); + if (result == ControllerProfileDatabaseResult::kOk) { + entry->active_profile = profile_index; + } + return result; +} diff --git a/controller_profile.h b/controller_profile.h new file mode 100644 index 0000000..d7e7618 --- /dev/null +++ b/controller_profile.h @@ -0,0 +1,197 @@ +#pragma once + +#include +#include + +#include "controller_identity.h" + +constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 2; +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_MACRO_STEP_CAPACITY = 8; +constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000; +constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff; +constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff; +// Exact 16-bit counterpart of the existing 358-of-1023 Switch boundary. +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934; + +constexpr uint8_t CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY = 16; +constexpr uint16_t CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION = 1; +constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 2; +constexpr size_t CONTROLLER_PROFILE_DATABASE_HEADER_SIZE = 32; +constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE = 16; +constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE = + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; +constexpr size_t CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE + + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * + CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; + +static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 17696, + "profile database wire size changed"); +enum class ControllerProfileLogicalButton : uint8_t { + kSouth = 0, + kEast = 1, + kWest = 2, + kNorth = 3, + kLeftShoulder = 4, + kRightShoulder = 5, + kSelect = 6, + kStart = 7, + kSystem = 8, + kCapture = 9, + kLeftStick = 10, + kRightStick = 11, + kDpadUp = 12, + kDpadDown = 13, + kDpadLeft = 14, + kDpadRight = 15, +}; +static_assert( + static_cast(ControllerProfileLogicalButton::kDpadRight) + 1 == + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + + +enum class ControllerProfileConfirmationPolicy : uint8_t { + kNone = 0, + kRumble = 1, + kLed = 2, + kRumbleAndLed = 3, +}; + +enum class ControllerProfileTurboMode : uint8_t { + kOff = 0, + kTurbo = 1, + kAutoBurst = 2, +}; + +enum class ControllerProfileMacroStepType : uint8_t { + kState = 0, + kEnd = 1, +}; + +enum ControllerProfileMacroOverride : uint8_t { + kControllerProfileOverrideButtons = 1u << 0, + kControllerProfileOverrideLeftStick = 1u << 1, + kControllerProfileOverrideRightStick = 1u << 2, + kControllerProfileOverrideLeftTrigger = 1u << 3, + kControllerProfileOverrideRightTrigger = 1u << 4, +}; + +struct ControllerProfileStickConfiguration { + int16_t center_x = 0; + int16_t center_y = 0; + uint16_t inner_deadzone = 0; + uint16_t outer_saturation = 32767; + uint16_t curve_q8_8 = 256; + bool invert_x = false; + bool invert_y = false; +}; + +struct ControllerProfileTriggerConfiguration { + uint16_t lower_deadzone = 0; + uint16_t upper_saturation = UINT16_MAX; + uint16_t curve_q8_8 = 256; + // Compared against the transformed uint16 trigger output. + uint16_t digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; +}; + +struct ControllerProfileMacroStep { + ControllerProfileMacroStepType type = + ControllerProfileMacroStepType::kEnd; + uint8_t override_flags = 0; + uint16_t duration_ms = 0; + uint16_t output_button_mask = 0; + int16_t left_stick_x = 0; + int16_t left_stick_y = 0; + int16_t right_stick_x = 0; + int16_t right_stick_y = 0; + uint16_t left_trigger = 0; + uint16_t right_trigger = 0; +}; + +struct ControllerProfile { + uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; + ControllerProfileStickConfiguration sticks[2]{}; + ControllerProfileTriggerConfiguration triggers[2]{}; + uint8_t weak_rumble_scale = UINT8_MAX; + uint8_t strong_rumble_scale = UINT8_MAX; + ControllerProfileConfirmationPolicy confirmation_policy = + ControllerProfileConfirmationPolicy::kRumbleAndLed; + uint16_t switching_chord = 0; + uint8_t macro_trigger = CONTROLLER_PROFILE_NO_BUTTON; + uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; + uint8_t macro_step_count = 1; + ControllerProfileTurboMode + turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; + ControllerProfileMacroStep + macro_steps[CONTROLLER_PROFILE_MACRO_STEP_CAPACITY]{}; +}; + +struct ControllerProfileDatabaseEntry { + bool used = false; + ControllerIdentity identity{}; + uint8_t active_profile = 0; + ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{}; +}; + +struct ControllerProfileDatabase { + uint8_t fallback_active_profile = 0; + ControllerProfile fallback_profiles[CONTROLLER_PROFILE_COUNT]{}; + ControllerProfileDatabaseEntry + entries[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY]{}; +}; + +enum class ControllerProfileDatabaseResult : uint8_t { + kOk = 0, + kInvalidArgument = 1, + kFull = 2, +}; + +using ControllerProfileDatabaseRead = bool (*)( + void* context, size_t offset, uint8_t* output, size_t size); + +ControllerProfile controller_profile_default(const ControllerIdentity& identity, + uint8_t profile_index); +bool controller_profile_validate(const ControllerProfile& profile); +bool controller_profile_encode(const ControllerProfile& profile, + uint8_t* output, size_t output_size); +bool controller_profile_decode(const uint8_t* input, size_t input_size, + ControllerProfile* output); + +void controller_profile_database_default(ControllerProfileDatabase* database); +bool controller_profile_database_validate( + const ControllerProfileDatabase& database); +bool controller_profile_database_encode_range( + const ControllerProfileDatabase& database, size_t offset, + uint8_t* output, size_t size); +bool controller_profile_database_decode( + ControllerProfileDatabaseRead read, void* context, + ControllerProfileDatabase* output); + +const ControllerProfileDatabaseEntry* controller_profile_database_find( + const ControllerProfileDatabase& database, + const ControllerIdentity& identity); +ControllerProfileDatabaseEntry* controller_profile_database_find( + ControllerProfileDatabase* database, + const ControllerIdentity& identity); +ControllerProfileDatabaseResult controller_profile_database_ensure( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + ControllerProfileDatabaseEntry** output); +const ControllerProfile* controller_profile_database_get( + const ControllerProfileDatabase& database, + const ControllerIdentity& identity, uint8_t profile_index); +ControllerProfileDatabaseResult controller_profile_database_set( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index, const ControllerProfile& profile); +ControllerProfileDatabaseResult controller_profile_database_reset( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index); +ControllerProfileDatabaseResult controller_profile_database_activate( + ControllerProfileDatabase* database, const ControllerIdentity& identity, + uint8_t profile_index); diff --git a/controller_profile_runtime.cpp b/controller_profile_runtime.cpp new file mode 100644 index 0000000..ddf6478 --- /dev/null +++ b/controller_profile_runtime.cpp @@ -0,0 +1,362 @@ +#include "controller_profile_runtime.h" + +#include "configuration_service.h" +#include "controller_identity.h" +#include "controller_synthetic_input.h" +#include "profile_service.h" + +namespace { + +struct ControllerProfileRuntimeContext { + bool active = false; + uint32_t connection_generation = 0; + ControllerIdentity identity{}; + uint32_t database_generation = 0; + uint8_t active_profile_index = 0; + bool profile_snapshot_valid = false; + ControllerProfile profile{}; + ControllerSyntheticInputContext synthetic{}; + bool runtime_generations_initialized = false; + AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; + uint32_t configuration_reset_generation = 0; + bool switching_chord_held = false; + bool switching_chord_armed = true; + bool switching_activation_requested = false; + uint16_t held_switching_chord = 0; + uint8_t switching_target_profile_index = 0; + uint32_t switching_transaction_id = 0; + bool profile_change_pending = false; + ControllerProfileRuntimeProfileChangeEvent pending_profile_change{}; + bool initial_profile_indication_resolved = false; + bool initial_profile_indication_pending = false; + ControllerProfileRuntimeProfileChangeEvent + pending_initial_profile_indication{}; +}; + +ControllerProfileRuntimeContext + g_contexts[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT]{}; +ControllerProfile g_default_profile{}; +ControllerProfileTransformResult g_neutral_output{}; +bool g_initialized = false; +uint32_t g_next_activation_sequence = 1; + +uint32_t next_activation_transaction_id() { + const uint32_t transaction_id = + 0x80000000u | g_next_activation_sequence; + g_next_activation_sequence = + g_next_activation_sequence == 0x7fffffffu + ? 1u + : g_next_activation_sequence + 1u; + return transaction_id; +} + +uint16_t effective_switching_chord(const ControllerProfile& profile) { + return profile.switching_chord == 0 + ? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD + : profile.switching_chord; +} + +void initialize_defaults() { + if (g_initialized) { + return; + } + g_default_profile = + controller_profile_default(controller_identity_global(), 0); + g_neutral_output = controller_profile_transform( + controller_neutral_state(), g_default_profile); + g_initialized = true; +} + +void clear_context(ControllerProfileRuntimeContext* context) { + if (context == nullptr) { + return; + } + *context = {}; +} + +void refresh_profile(ControllerProfileRuntimeContext* context, + const ControllerIdentity& identity, + uint32_t connection_generation, + uint32_t observed_database_generation, + uint16_t current_input_button_mask) { + ProfileServiceActiveProfileSnapshot snapshot{}; + profile_service_active_profile_snapshot(identity, &snapshot); + + const bool same_connection = + context->active && + context->connection_generation == connection_generation; + const bool same_identity = + same_connection && + controller_identity_equal(context->identity, identity); + const uint8_t previous_profile_index = + context->active_profile_index; + const bool previous_profile_valid = + context->profile_snapshot_valid; + if (!same_connection) { + context->runtime_generations_initialized = false; + context->switching_chord_held = false; + context->switching_chord_armed = true; + context->switching_activation_requested = false; + context->held_switching_chord = 0; + context->switching_target_profile_index = 0; + context->switching_transaction_id = 0; + context->profile_change_pending = false; + context->pending_profile_change = {}; + context->initial_profile_indication_resolved = false; + context->initial_profile_indication_pending = false; + context->pending_initial_profile_indication = {}; + } + + controller_synthetic_input_cancel(&context->synthetic, + current_input_button_mask); + context->active = true; + context->connection_generation = connection_generation; + context->identity = identity; + context->database_generation = snapshot.valid + ? snapshot.metadata.generation + : observed_database_generation; + context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0; + context->profile_snapshot_valid = snapshot.valid; + context->profile = snapshot.valid ? snapshot.profile : g_default_profile; + if (!context->initial_profile_indication_resolved && snapshot.valid) { + context->initial_profile_indication_resolved = true; + const uint8_t policy = static_cast( + controller_profile_confirmation_policy(context->profile)); + if ((policy & static_cast( + ControllerProfileConfirmationPolicy::kLed)) != 0) { + context->pending_initial_profile_indication = { + connection_generation, + context->database_generation, + static_cast(context->active_profile_index + 1u), + ControllerProfileConfirmationPolicy::kLed, + }; + context->initial_profile_indication_pending = true; + } + } + if (context->switching_chord_held && + !context->switching_activation_requested) { + context->switching_target_profile_index = + static_cast( + (context->active_profile_index + 1u) % + CONTROLLER_PROFILE_COUNT); + } + if (same_identity && previous_profile_valid && snapshot.valid && + previous_profile_index != context->active_profile_index) { + context->pending_profile_change = { + connection_generation, + context->database_generation, + static_cast(context->active_profile_index + 1u), + controller_profile_confirmation_policy(context->profile), + }; + context->profile_change_pending = true; + } +} + +ControllerProfileRuntimeContext* update_context( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot) { + if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) { + return nullptr; + } + + ControllerProfileRuntimeContext& context = g_contexts[slot]; + if (!snapshot.active) { + if (context.active) { + clear_context(&context); + } + return nullptr; + } + + const uint32_t database_generation = + profile_service_database_generation(); + if (!context.active || + 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, + controller_profile_extract_button_mask(snapshot.state)); + } + return &context; +} +void process_profile_switching( + ControllerProfileRuntimeContext* context, + const ControllerIdentity& identity, + uint16_t switching_input_button_mask, + uint16_t current_input_button_mask, + ControllerState* consumed_input) { + if (context == nullptr || consumed_input == nullptr) { + return; + } + + if (context->switching_chord_held) { + if ((switching_input_button_mask & + context->held_switching_chord) == + context->held_switching_chord) { + controller_profile_apply_button_mask( + static_cast( + current_input_button_mask & + ~context->held_switching_chord), + consumed_input); + if (!context->switching_activation_requested) { + const ConfigurationTransactionStatus status = + profile_service_activate_internal( + context->switching_transaction_id, identity, + context->switching_target_profile_index); + if (status != ConfigurationTransactionStatus::kBusy) { + context->switching_activation_requested = true; + } + } + return; + } + context->switching_chord_held = false; + context->switching_activation_requested = false; + context->held_switching_chord = 0; + context->switching_transaction_id = 0; + } + + const uint16_t chord = + effective_switching_chord(context->profile); + const bool chord_fully_held = + (switching_input_button_mask & chord) == chord; + if (!chord_fully_held) { + context->switching_chord_armed = true; + return; + } + if (!context->switching_chord_armed) { + return; + } + + context->switching_chord_armed = false; + context->switching_chord_held = true; + context->held_switching_chord = chord; + context->switching_target_profile_index = + static_cast( + (context->active_profile_index + 1u) % + CONTROLLER_PROFILE_COUNT); + context->switching_transaction_id = + next_activation_transaction_id(); + controller_profile_apply_button_mask( + static_cast( + current_input_button_mask & ~context->held_switching_chord), + consumed_input); + const ConfigurationTransactionStatus status = + profile_service_activate_internal( + context->switching_transaction_id, identity, + context->switching_target_profile_index); + if (status != ConfigurationTransactionStatus::kBusy) { + context->switching_activation_requested = true; + } +} + +} // namespace + +void controller_profile_runtime_reset() { + g_initialized = false; + initialize_defaults(); + for (ControllerProfileRuntimeContext& context : g_contexts) { + clear_context(&context); + } + g_next_activation_sequence = 1; +} + +ControllerProfileTransformResult controller_profile_runtime_transform( + 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; + } + ControllerState consumed_input = snapshot.state; + const uint16_t current_input_button_mask = + controller_profile_extract_button_mask(snapshot.state); + process_profile_switching( + context, snapshot.identity, snapshot.pre_hotkey_button_mask, + current_input_button_mask, &consumed_input); + + 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(consumed_input)); + context->output_mode = output_mode; + context->configuration_reset_generation = reset_generation; + } + return controller_synthetic_input_apply( + &context->synthetic, consumed_input, context->profile, now_ms); +} + +bool controller_profile_runtime_take_initial_profile_indication( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) { + if (output == nullptr) { + return false; + } + *output = {}; + if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) { + return false; + } + + ControllerProfileRuntimeContext& context = g_contexts[slot]; + if (!context.initial_profile_indication_pending) { + return false; + } + *output = context.pending_initial_profile_indication; + context.initial_profile_indication_pending = false; + context.pending_initial_profile_indication = {}; + return true; +} + +bool controller_profile_runtime_take_profile_change( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) { + if (output == nullptr) { + return false; + } + *output = {}; + if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) { + return false; + } + + ControllerProfileRuntimeContext& context = g_contexts[slot]; + if (!context.profile_change_pending) { + return false; + } + *output = context.pending_profile_change; + context.profile_change_pending = false; + context.pending_profile_change = {}; + return true; +} + +ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble) { + initialize_defaults(); + ControllerProfileRuntimeContext* context = + update_context(slot, snapshot); + const ControllerProfile& profile = + context == nullptr ? g_default_profile : context->profile; + return controller_profile_scale_host_rumble(rumble, profile); +} + +ControllerProfileRuntimeLocalConfirmation +controller_profile_runtime_local_confirmation( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble) { + initialize_defaults(); + ControllerProfileRuntimeContext* context = + update_context(slot, snapshot); + const ControllerProfile& profile = + context == nullptr ? g_default_profile : context->profile; + return { + rumble, + controller_profile_confirmation_policy(profile), + }; +} diff --git a/controller_profile_runtime.h b/controller_profile_runtime.h new file mode 100644 index 0000000..be8d1f1 --- /dev/null +++ b/controller_profile_runtime.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +#include "adapter_usb_mode.h" + +#include "bluepad32_input_backend.h" +#include "controller_profile_transform.h" + +constexpr uint8_t CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT = 4; +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD = + static_cast( + (1u << static_cast( + ControllerProfileLogicalButton::kLeftShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kRightShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kSelect)) | + (1u << static_cast( + ControllerProfileLogicalButton::kStart))); + +struct ControllerProfileRuntimeProfileChangeEvent { + uint32_t connection_generation = 0; + uint32_t database_generation = 0; + uint8_t active_profile_number = 0; + ControllerProfileConfirmationPolicy policy = + ControllerProfileConfirmationPolicy::kNone; +}; + +// Take the one-shot LED-only indication for the first committed profile +// resolved on a connection. Identity promotion and database refresh do not +// publish it again. +bool controller_profile_runtime_take_initial_profile_indication( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output); + +struct ControllerProfileRuntimeLocalConfirmation { + ControllerRumbleOutput rumble{}; + ControllerProfileConfirmationPolicy policy = + ControllerProfileConfirmationPolicy::kRumbleAndLed; +}; + +// Reset all four fixed slot caches to the default profile. +void controller_profile_runtime_reset(); + +// Refresh a slot when its identity, connection generation, or database +// generation changes; identity-only promotion preserves a held switching +// transaction. Consume pre-hotkey switching chords while transforming the +// backend-suppressed state through the shared synthetic pipeline. Inactive +// snapshots return neutral output and invalidate the slot. +ControllerProfileTransformResult controller_profile_runtime_transform( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms, + AdapterUsbMode output_mode); + +// Take the single committed profile-index change observed by the slot. Initial +// profile loads, identity promotions, and connection replacements do not +// publish an event. +bool controller_profile_runtime_take_profile_change( + uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output); + +// Refresh from the current slot snapshot and scale host-originated rumble. +ControllerRumbleOutput controller_profile_runtime_scale_host_rumble( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble); + +// Preserve local confirmation rumble exactly while exposing profile policy. +ControllerProfileRuntimeLocalConfirmation +controller_profile_runtime_local_confirmation( + uint8_t slot, const Bluepad32SlotSnapshot& snapshot, + const ControllerRumbleOutput& rumble); diff --git a/controller_profile_transform.cpp b/controller_profile_transform.cpp new file mode 100644 index 0000000..9039035 --- /dev/null +++ b/controller_profile_transform.cpp @@ -0,0 +1,344 @@ +#include "controller_profile_transform.h" + +#include + +namespace { + +constexpr uint32_t kQ16One = 1u << 16; + +constexpr uint16_t button_bit(ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + +int32_t clamp_centered_axis(int16_t value, int16_t center) { + const int32_t adjusted = + static_cast(value) - static_cast(center); + if (adjusted < INT16_MIN) { + return INT16_MIN; + } + if (adjusted > INT16_MAX) { + return INT16_MAX; + } + return adjusted; +} + +uint32_t absolute_axis(int32_t value) { + return value < 0 ? static_cast(-value) + : static_cast(value); +} + +// This endpoint-preserving rational curve is x / (c + (1 - c) * x), +// evaluated with x in Q16 and c in Q8.8. Values above 1.0 reduce the +// response below the linear curve; values below 1.0 increase it. +uint32_t apply_curve_q16(uint32_t input_q16, uint16_t curve_q8_8) { + if (input_q16 == 0 || input_q16 == kQ16One || curve_q8_8 == 256) { + return input_q16; + } + + const uint64_t denominator = + static_cast(curve_q8_8) * + (kQ16One - input_q16) + + static_cast(256) * input_q16; + const uint64_t numerator = + static_cast(input_q16) * 256u * kQ16One; + const uint64_t curved = (numerator + denominator / 2u) / denominator; + return curved > kQ16One ? kQ16One + : static_cast(curved); +} + +bool is_default_stick_configuration( + const ControllerProfileStickConfiguration& configuration) { + return configuration.center_x == 0 && configuration.center_y == 0 && + configuration.inner_deadzone == 0 && + configuration.outer_saturation == 32767 && + configuration.curve_q8_8 == 256 && !configuration.invert_x && + !configuration.invert_y; +} + +bool is_default_trigger_configuration( + const ControllerProfileTriggerConfiguration& configuration) { + return configuration.lower_deadzone == 0 && + configuration.upper_saturation == UINT16_MAX && + configuration.curve_q8_8 == 256; +} + +int16_t scale_stick_axis(int32_t adjusted_axis, uint32_t magnitude, + uint32_t response_q16, bool invert) { + if (adjusted_axis == 0 || magnitude == 0 || response_q16 == 0) { + return 0; + } + + bool negative = adjusted_axis < 0; + if (invert) { + negative = !negative; + } + const uint32_t axis_magnitude = absolute_axis(adjusted_axis); + const uint32_t output_limit = + negative ? static_cast(-static_cast(INT16_MIN)) + : static_cast(INT16_MAX); + const uint64_t numerator = + static_cast(axis_magnitude) * output_limit * + response_q16; + const uint64_t denominator = + static_cast(magnitude) * kQ16One; + uint32_t output_magnitude = static_cast( + (numerator + denominator / 2u) / denominator); + if (output_magnitude > output_limit) { + output_magnitude = output_limit; + } + + if (!negative) { + return static_cast(output_magnitude); + } + if (output_magnitude == + static_cast(-static_cast(INT16_MIN))) { + return INT16_MIN; + } + return static_cast(-static_cast(output_magnitude)); +} + +void transform_stick(const ControllerProfileStickConfiguration& configuration, + int16_t input_x, int16_t input_y, int16_t* output_x, + int16_t* output_y) { + if (is_default_stick_configuration(configuration)) { + *output_x = input_x; + *output_y = input_y; + return; + } + + const int32_t adjusted_x = + clamp_centered_axis(input_x, configuration.center_x); + const int32_t adjusted_y = + clamp_centered_axis(input_y, configuration.center_y); + const uint32_t magnitude_x = absolute_axis(adjusted_x); + const uint32_t magnitude_y = absolute_axis(adjusted_y); + const uint32_t magnitude = + magnitude_x > magnitude_y ? magnitude_x : magnitude_y; + + uint32_t response_q16 = 0; + if (magnitude <= configuration.inner_deadzone) { + response_q16 = 0; + } else if (magnitude >= configuration.outer_saturation) { + response_q16 = kQ16One; + } else { + const uint32_t input_range = + static_cast(configuration.outer_saturation) - + configuration.inner_deadzone; + const uint32_t input_offset = + magnitude - configuration.inner_deadzone; + const uint32_t normalized_q16 = static_cast( + (static_cast(input_offset) * kQ16One + + input_range / 2u) / + input_range); + response_q16 = + apply_curve_q16(normalized_q16, configuration.curve_q8_8); + } + + *output_x = scale_stick_axis(adjusted_x, magnitude, response_q16, + configuration.invert_x); + *output_y = scale_stick_axis(adjusted_y, magnitude, response_q16, + configuration.invert_y); +} + +uint16_t transform_trigger( + uint16_t input, + const ControllerProfileTriggerConfiguration& configuration) { + if (is_default_trigger_configuration(configuration)) { + return input; + } + if (input <= configuration.lower_deadzone) { + return 0; + } + if (input >= configuration.upper_saturation) { + return UINT16_MAX; + } + + const uint32_t input_range = + static_cast(configuration.upper_saturation) - + configuration.lower_deadzone; + const uint32_t input_offset = + static_cast(input) - configuration.lower_deadzone; + if (configuration.curve_q8_8 == 256) { + return static_cast( + (static_cast(input_offset) * UINT16_MAX + + input_range / 2u) / + input_range); + } + + const uint32_t normalized_q16 = static_cast( + (static_cast(input_offset) * kQ16One + + input_range / 2u) / + input_range); + const uint32_t curved_q16 = + apply_curve_q16(normalized_q16, configuration.curve_q8_8); + return static_cast( + (static_cast(curved_q16) * UINT16_MAX + + kQ16One / 2u) / + kQ16One); +} + +} // namespace + +uint16_t controller_profile_extract_button_mask(const ControllerState& state) { + uint16_t mask = 0; + mask |= state.button_south + ? button_bit(ControllerProfileLogicalButton::kSouth) + : 0; + mask |= state.button_east + ? button_bit(ControllerProfileLogicalButton::kEast) + : 0; + mask |= state.button_west + ? button_bit(ControllerProfileLogicalButton::kWest) + : 0; + mask |= state.button_north + ? button_bit(ControllerProfileLogicalButton::kNorth) + : 0; + mask |= state.button_left_shoulder + ? button_bit(ControllerProfileLogicalButton::kLeftShoulder) + : 0; + mask |= state.button_right_shoulder + ? button_bit(ControllerProfileLogicalButton::kRightShoulder) + : 0; + mask |= state.button_select + ? button_bit(ControllerProfileLogicalButton::kSelect) + : 0; + mask |= state.button_start + ? button_bit(ControllerProfileLogicalButton::kStart) + : 0; + mask |= state.button_system + ? button_bit(ControllerProfileLogicalButton::kSystem) + : 0; + mask |= state.button_capture + ? button_bit(ControllerProfileLogicalButton::kCapture) + : 0; + mask |= state.button_left_stick + ? button_bit(ControllerProfileLogicalButton::kLeftStick) + : 0; + mask |= state.button_right_stick + ? button_bit(ControllerProfileLogicalButton::kRightStick) + : 0; + mask |= state.dpad_up + ? button_bit(ControllerProfileLogicalButton::kDpadUp) + : 0; + mask |= state.dpad_down + ? button_bit(ControllerProfileLogicalButton::kDpadDown) + : 0; + mask |= state.dpad_left + ? button_bit(ControllerProfileLogicalButton::kDpadLeft) + : 0; + mask |= state.dpad_right + ? button_bit(ControllerProfileLogicalButton::kDpadRight) + : 0; + return mask; +} + +void controller_profile_apply_button_mask(uint16_t button_mask, + ControllerState* state) { + if (state == nullptr) { + return; + } + state->button_south = + (button_mask & button_bit(ControllerProfileLogicalButton::kSouth)) != 0; + state->button_east = + (button_mask & button_bit(ControllerProfileLogicalButton::kEast)) != 0; + state->button_west = + (button_mask & button_bit(ControllerProfileLogicalButton::kWest)) != 0; + state->button_north = + (button_mask & button_bit(ControllerProfileLogicalButton::kNorth)) != 0; + state->button_left_shoulder = + (button_mask & + button_bit(ControllerProfileLogicalButton::kLeftShoulder)) != 0; + state->button_right_shoulder = + (button_mask & + button_bit(ControllerProfileLogicalButton::kRightShoulder)) != 0; + state->button_select = + (button_mask & button_bit(ControllerProfileLogicalButton::kSelect)) != 0; + state->button_start = + (button_mask & button_bit(ControllerProfileLogicalButton::kStart)) != 0; + state->button_system = + (button_mask & button_bit(ControllerProfileLogicalButton::kSystem)) != 0; + state->button_capture = + (button_mask & button_bit(ControllerProfileLogicalButton::kCapture)) != 0; + state->button_left_stick = + (button_mask & + button_bit(ControllerProfileLogicalButton::kLeftStick)) != 0; + state->button_right_stick = + (button_mask & + button_bit(ControllerProfileLogicalButton::kRightStick)) != 0; + state->dpad_up = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadUp)) != 0; + state->dpad_down = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadDown)) != 0; + state->dpad_left = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadLeft)) != 0; + state->dpad_right = + (button_mask & button_bit(ControllerProfileLogicalButton::kDpadRight)) != 0; +} + +uint16_t controller_profile_map_button_mask( + uint16_t input_button_mask, const ControllerProfile& profile) { + uint16_t output_button_mask = 0; + for (uint8_t input = 0; + input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) { + if ((input_button_mask & static_cast(1u << input)) == 0) { + continue; + } + const uint8_t output = profile.button_map[input]; + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + output_button_mask |= static_cast(1u << output); + } + } + return output_button_mask; +} + +ControllerProfileTransformResult controller_profile_transform( + const ControllerState& input, const ControllerProfile& profile) { + ControllerProfileTransformResult result{}; + result.state = input; + const uint16_t input_button_mask = + controller_profile_extract_button_mask(input); + controller_profile_apply_button_mask( + controller_profile_map_button_mask(input_button_mask, profile), + &result.state); + + transform_stick(profile.sticks[0], input.left_stick_x, + input.left_stick_y, &result.state.left_stick_x, + &result.state.left_stick_y); + transform_stick(profile.sticks[1], input.right_stick_x, + input.right_stick_y, &result.state.right_stick_x, + &result.state.right_stick_y); + result.state.left_trigger = transform_trigger(input.left_trigger, + profile.triggers[0]); + result.state.right_trigger = transform_trigger(input.right_trigger, + profile.triggers[1]); + result.left_trigger_digital_threshold = + profile.triggers[0].digital_threshold; + result.right_trigger_digital_threshold = + profile.triggers[1].digital_threshold; + return result; +} + +uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, + uint8_t scale) { + const uint32_t scaled = + (static_cast(magnitude) * scale + UINT8_MAX / 2u) / + UINT8_MAX; + return scaled > UINT8_MAX ? UINT8_MAX + : static_cast(scaled); +} + +ControllerRumbleOutput controller_profile_scale_host_rumble( + const ControllerRumbleOutput& input, const ControllerProfile& profile) { + return { + controller_profile_scale_rumble_magnitude( + input.low_frequency_magnitude, profile.strong_rumble_scale), + controller_profile_scale_rumble_magnitude( + input.high_frequency_magnitude, profile.weak_rumble_scale), + }; +} + +ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( + const ControllerProfile& profile) { + return profile.confirmation_policy; +} diff --git a/controller_profile_transform.h b/controller_profile_transform.h new file mode 100644 index 0000000..54c4904 --- /dev/null +++ b/controller_profile_transform.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "controller_profile.h" +#include "controller_state.h" +#include "switch_haptics.h" + +struct ControllerProfileTransformResult { + ControllerState state{}; + uint16_t left_trigger_digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + uint16_t right_trigger_digital_threshold = + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; +}; + +uint16_t controller_profile_extract_button_mask(const ControllerState& state); +void controller_profile_apply_button_mask(uint16_t button_mask, + ControllerState* state); +uint16_t controller_profile_map_button_mask( + uint16_t input_button_mask, const ControllerProfile& profile); + +ControllerProfileTransformResult controller_profile_transform( + const ControllerState& input, const ControllerProfile& profile); + +uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, + uint8_t scale); +ControllerRumbleOutput controller_profile_scale_host_rumble( + const ControllerRumbleOutput& input, const ControllerProfile& profile); +ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( + const ControllerProfile& profile); 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/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index c564e0a..d53f424 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index 92fb02e..aec706b 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 353f88f..b7db55b 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 2bb4624..84e05c1 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/pico_profile_storage.cpp b/pico_profile_storage.cpp new file mode 100644 index 0000000..683270f --- /dev/null +++ b/pico_profile_storage.cpp @@ -0,0 +1,150 @@ +#include "pico_profile_storage.h" + +#include + +#include "configuration_storage.h" +#include "hardware/flash.h" +#include "pico/btstack_flash_bank.h" +#include "pico/flash.h" +#include "pico/platform.h" + +extern "C" char __flash_binary_end; + +namespace { + +constexpr size_t kConfigurationStorageSize = + CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE; +constexpr uint32_t kConfigurationStorageOffset = + PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize; +constexpr uint32_t kProfileStorageOffset = + kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; + +static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE, + "profile storage sector size does not match Pico flash"); +static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE, + "profile storage page size does not match Pico flash"); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= + kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE, + "profile storage offset underflows flash"); +static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <= + kConfigurationStorageOffset, + "profile storage overlaps adapter configuration storage"); +static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <= + PICO_FLASH_BANK_STORAGE_OFFSET, + "adapter configuration storage overlaps BTstack bonds"); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + + PICO_FLASH_BANK_TOTAL_SIZE <= + PICO_FLASH_SIZE_BYTES, + "BTstack storage exceeds flash"); + +struct FlashBankReplacement { + uint8_t bank; + const uint8_t* payload; + size_t payload_size; + const uint8_t* header; + bool replaced; +}; + +void perform_flash_bank_replacement(void* context) { + auto* replacement = + static_cast(context); + replacement->replaced = false; + const uint32_t bank_offset = + kProfileStorageOffset + + replacement->bank * PROFILE_STORAGE_BANK_SIZE; + + for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE; + offset += FLASH_SECTOR_SIZE) { + flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE); + } + + uint8_t final_page[FLASH_PAGE_SIZE]{}; + for (size_t offset = 0; offset < replacement->payload_size; + offset += FLASH_PAGE_SIZE) { + const size_t remaining = + replacement->payload_size - offset; + const uint8_t* page = &replacement->payload[offset]; + if (remaining < FLASH_PAGE_SIZE) { + memcpy(final_page, page, remaining); + page = final_page; + } + flash_range_program( + bank_offset + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, + page, FLASH_PAGE_SIZE); + } + + const auto* stored_payload = reinterpret_cast( + XIP_BASE + bank_offset + + PROFILE_STORAGE_RECORD_HEADER_SIZE); + if (memcmp(stored_payload, replacement->payload, + replacement->payload_size) != 0) { + return; + } + + flash_range_program(bank_offset, replacement->header, + PROFILE_STORAGE_RECORD_HEADER_SIZE); + const auto* stored_header = reinterpret_cast( + XIP_BASE + bank_offset); + replacement->replaced = + memcmp(stored_header, replacement->header, + PROFILE_STORAGE_RECORD_HEADER_SIZE) == 0; +} + +bool storage_region_available() { + const uintptr_t binary_end = + reinterpret_cast(&__flash_binary_end) - XIP_BASE; + return binary_end <= kProfileStorageOffset; +} + +bool read_storage(void*, uint8_t bank, size_t offset, uint8_t* output, + size_t size) { + if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || + offset > PROFILE_STORAGE_BANK_SIZE || + size > PROFILE_STORAGE_BANK_SIZE - offset || + !storage_region_available()) { + return false; + } + const uintptr_t address = + XIP_BASE + kProfileStorageOffset + + bank * PROFILE_STORAGE_BANK_SIZE + offset; + memcpy(output, reinterpret_cast(address), size); + return true; +} + +bool replace_storage_bank(void*, uint8_t bank, + const uint8_t* payload, + size_t payload_size, + const uint8_t* header, + size_t header_size) { + if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || + header == nullptr || + payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE || + !storage_region_available()) { + return false; + } + FlashBankReplacement replacement{ + bank, + payload, + payload_size, + header, + false, + }; + return flash_safe_execute( + perform_flash_bank_replacement, &replacement, + UINT32_MAX) == PICO_OK && + replacement.replaced; +} + +} // namespace + +ProfileStorageIo pico_profile_storage_io() { + return { + nullptr, + PROFILE_STORAGE_BANK_SIZE, + FLASH_SECTOR_SIZE, + FLASH_PAGE_SIZE, + read_storage, + replace_storage_bank, + }; +} diff --git a/pico_profile_storage.h b/pico_profile_storage.h new file mode 100644 index 0000000..3fd8b42 --- /dev/null +++ b/pico_profile_storage.h @@ -0,0 +1,5 @@ +#pragma once + +#include "profile_storage.h" + +ProfileStorageIo pico_profile_storage_io(); diff --git a/profile_service.cpp b/profile_service.cpp new file mode 100644 index 0000000..5f17da6 --- /dev/null +++ b/profile_service.cpp @@ -0,0 +1,660 @@ +#include "profile_service.h" + +#include + +#include "pico/critical_section.h" +#include "pico_profile_storage.h" +#include "profile_storage.h" + +namespace { + +constexpr uint32_t kMinimumCommitIntervalMs = 1000; + +enum class PendingCommandType : uint8_t { + kNone = 0, + kReset = 1, + kActivate = 2, +}; + +struct PendingCommand { + PendingCommandType type = PendingCommandType::kNone; + uint32_t transaction_id = 0; + ControllerIdentity identity{}; + uint8_t profile_index = 0; +}; + +struct ProfileTransaction { + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ConfigurationTransactionSnapshot snapshot{}; + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; +}; + +struct PublishedActiveProfile { + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ControllerProfile profile{}; +}; + +critical_section_t g_lock; +bool g_prepared = false; +ProfileStorage g_storage; +ControllerProfileDatabase g_database; +ProfileServiceMetadata g_metadata; +uint32_t g_published_generation = 0; +ProfileServiceListSnapshot g_list; +ProfileServiceSelectedSnapshot g_selected; +PublishedActiveProfile + g_active_profiles[PROFILE_SERVICE_LIST_CAPACITY]{}; +uint8_t g_active_profile_count = 0; +ProfileTransaction g_transaction; +PendingCommand g_command; +PendingCommand g_internal_activation; +alignas(4) uint8_t + g_encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{}; +bool g_identity_dirty = false; +bool g_has_committed = false; +uint32_t g_last_commit_ms = 0; + +bool valid_identity(const ControllerIdentity& identity) { + uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; + return (controller_identity_is_global(identity) || identity.stable) && + controller_identity_encode(identity, encoded, sizeof(encoded)); +} + +void refresh_list_locked() { + g_list = {}; + g_list.metadata = g_metadata; + g_list.count = 1; + g_list.rows[0].identity = controller_identity_global(); + g_list.rows[0].active_profile = + g_database.fallback_active_profile; + for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { + if (!entry.used || g_list.count >= PROFILE_SERVICE_LIST_CAPACITY) { + continue; + } + ProfileServiceListRow& row = g_list.rows[g_list.count++]; + row.identity = entry.identity; + row.active_profile = entry.active_profile; + } +} + +void refresh_active_profiles_locked() { + g_active_profile_count = 1; + g_active_profiles[0].identity = controller_identity_global(); + g_active_profiles[0].profile_index = + g_database.fallback_active_profile; + g_active_profiles[0].profile = + g_database.fallback_profiles[g_database.fallback_active_profile]; + for (const ControllerProfileDatabaseEntry& entry : g_database.entries) { + if (!entry.used || + g_active_profile_count >= PROFILE_SERVICE_LIST_CAPACITY) { + continue; + } + PublishedActiveProfile& active = + g_active_profiles[g_active_profile_count++]; + active.identity = entry.identity; + active.profile_index = entry.active_profile; + active.profile = entry.profiles[entry.active_profile]; + } +} + +void refresh_selected_locked() { + g_selected.metadata = g_metadata; + const ControllerProfile* profile = controller_profile_database_get( + g_database, g_selected.identity, g_selected.profile_index); + if (profile == nullptr) { + g_selected.valid = false; + g_selected.status = ConfigurationTransactionStatus::kMalformed; + return; + } + g_selected.profile = *profile; + g_selected.valid = true; + g_selected.status = ConfigurationTransactionStatus::kCommitted; +} + +void refresh_metadata_locked(ProfileServiceState state) { + const ProfileStorageSnapshot& stored = g_storage.snapshot(); + g_metadata.state = state; + g_metadata.generation = stored.valid ? stored.generation : 0; + g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; + refresh_active_profiles_locked(); + refresh_list_locked(); + refresh_selected_locked(); + __atomic_store_n(&g_published_generation, g_metadata.generation, + __ATOMIC_RELEASE); +} + +ConfigurationTransactionStatus database_result_status( + ControllerProfileDatabaseResult result) { + switch (result) { + case ControllerProfileDatabaseResult::kOk: + return ConfigurationTransactionStatus::kPending; + case ControllerProfileDatabaseResult::kFull: + return ConfigurationTransactionStatus::kTooLarge; + case ControllerProfileDatabaseResult::kInvalidArgument: + return ConfigurationTransactionStatus::kMalformed; + } + return ConfigurationTransactionStatus::kStorageError; +} + +bool mutation_ready(uint32_t now_ms) { + return !g_has_committed || + static_cast(now_ms - g_last_commit_ms) >= + kMinimumCommitIntervalMs; +} + +void finish_mutation(ConfigurationTransactionStatus status, + bool clear_command) { + const ProfileStorageSnapshot& stored = g_storage.snapshot(); + critical_section_enter_blocking(&g_lock); + g_transaction.snapshot.status = status; + g_transaction.snapshot.stored_generation = + stored.valid ? stored.generation : 0; + g_transaction.snapshot.stored_crc = + stored.valid ? stored.payload_crc : 0; + if (clear_command) { + g_command = {}; + } + refresh_metadata_locked( + status == ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); +} + +void finish_internal_activation( + ConfigurationTransactionStatus status) { + critical_section_enter_blocking(&g_lock); + g_internal_activation = {}; + refresh_metadata_locked( + status == ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); +} + +} // namespace + +void profile_service_prepare() { + if (g_prepared) { + return; + } + critical_section_init(&g_lock); + g_metadata = {}; + __atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED); + g_list = {}; + g_selected = {}; + g_active_profiles[0] = {}; + g_active_profile_count = 0; + g_selected.identity = controller_identity_global(); + g_selected.profile_index = 0; + g_transaction = {}; + g_command = {}; + g_internal_activation = {}; + g_identity_dirty = false; + g_has_committed = false; + g_last_commit_ms = 0; + g_prepared = true; +} + +void profile_service_initialize_on_storage_core() { + if (!g_prepared) { + profile_service_prepare(); + } + const bool initialized = + g_storage.initialize(pico_profile_storage_io(), &g_database); + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(initialized ? ProfileServiceState::kReady + : ProfileServiceState::kStorageError); + critical_section_exit(&g_lock); +} + +bool profile_service_observe_identity_on_storage_core( + const ControllerIdentity& identity) { + if (!g_prepared || !identity.stable || + controller_identity_is_global(identity) || + !valid_identity(identity)) { + return false; + } + critical_section_enter_blocking(&g_lock); + if (g_metadata.state != ProfileServiceState::kReady) { + critical_section_exit(&g_lock); + return false; + } + ControllerProfileDatabaseEntry* entry = + controller_profile_database_find(&g_database, identity); + if (entry != nullptr) { + critical_section_exit(&g_lock); + return true; + } + const ControllerProfileDatabaseResult result = + controller_profile_database_ensure(&g_database, identity, &entry); + if (result == ControllerProfileDatabaseResult::kOk) { + g_identity_dirty = true; + refresh_list_locked(); + } + critical_section_exit(&g_lock); + return result == ControllerProfileDatabaseResult::kOk; +} + + +void profile_service_task_on_storage_core(uint32_t now_ms) { + PendingCommand command{}; + bool process_write = false; + bool process_internal_activation = false; + bool process_identity = false; + ControllerIdentity write_identity{}; + uint8_t write_profile_index = 0; + uint8_t write_payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + + critical_section_enter_blocking(&g_lock); + if (mutation_ready(now_ms)) { + if (g_command.type != PendingCommandType::kNone) { + command = g_command; + } else if (g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + process_write = true; + write_identity = g_transaction.identity; + write_profile_index = g_transaction.profile_index; + memcpy(write_payload, g_transaction.payload, + sizeof(write_payload)); + } else if (g_internal_activation.type != + PendingCommandType::kNone) { + command = g_internal_activation; + process_internal_activation = true; + } else if (g_identity_dirty) { + process_identity = true; + } + } + critical_section_exit(&g_lock); + + if (!process_write && !process_identity && + !process_internal_activation && + command.type == PendingCommandType::kNone) { + return; + } + + ControllerProfileDatabaseResult database_result = + ControllerProfileDatabaseResult::kInvalidArgument; + if (process_identity) { + database_result = ControllerProfileDatabaseResult::kOk; + } else if (process_write) { + ControllerProfile profile{}; + if (controller_profile_decode(write_payload, sizeof(write_payload), + &profile)) { + critical_section_enter_blocking(&g_lock); + database_result = controller_profile_database_set( + &g_database, write_identity, write_profile_index, profile); + critical_section_exit(&g_lock); + } + } else if (command.type == PendingCommandType::kReset) { + critical_section_enter_blocking(&g_lock); + database_result = controller_profile_database_reset( + &g_database, command.identity, command.profile_index); + critical_section_exit(&g_lock); + } else if (command.type == PendingCommandType::kActivate) { + critical_section_enter_blocking(&g_lock); + database_result = controller_profile_database_activate( + &g_database, command.identity, command.profile_index); + critical_section_exit(&g_lock); + } + + if (database_result != ControllerProfileDatabaseResult::kOk) { + const ConfigurationTransactionStatus error_status = + database_result_status(database_result); + if (process_internal_activation) { + finish_internal_activation(error_status); + } else { + finish_mutation(error_status, !process_write); + } + return; + } + + const ProfileStorageResult storage_result = g_storage.commit( + g_database, g_encoded_database, sizeof(g_encoded_database)); + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kStorageError; + if (storage_result == ProfileStorageResult::kOk) { + status = ConfigurationTransactionStatus::kCommitted; + g_has_committed = true; + g_last_commit_ms = now_ms; + } else if (storage_result == ProfileStorageResult::kUnchanged) { + status = ConfigurationTransactionStatus::kUnchanged; + } else { + critical_section_enter_blocking(&g_lock); + const bool restored = + g_storage.initialize(pico_profile_storage_io(), &g_database); + critical_section_exit(&g_lock); + if (!restored) { + status = ConfigurationTransactionStatus::kStorageError; + } + } + if (process_identity) { + critical_section_enter_blocking(&g_lock); + g_identity_dirty = false; + refresh_metadata_locked( + status == ConfigurationTransactionStatus::kStorageError + ? ProfileServiceState::kStorageError + : ProfileServiceState::kReady); + critical_section_exit(&g_lock); + return; + } + critical_section_enter_blocking(&g_lock); + g_identity_dirty = false; + critical_section_exit(&g_lock); + if (process_internal_activation) { + finish_internal_activation(status); + } else { + finish_mutation(status, !process_write); + } +} + +ConfigurationTransactionStatus profile_service_select( + const ControllerIdentity& identity, uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kCommitted; + if (!valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + status = ConfigurationTransactionStatus::kMalformed; + g_selected.metadata = g_metadata; + g_selected.identity = identity; + g_selected.profile_index = profile_index; + g_selected.valid = false; + g_selected.status = status; + } else if (g_metadata.state != ProfileServiceState::kReady) { + status = g_metadata.state == ProfileServiceState::kLoading + ? ConfigurationTransactionStatus::kPending + : ConfigurationTransactionStatus::kStorageError; + g_selected.metadata = g_metadata; + g_selected.identity = identity; + g_selected.profile_index = profile_index; + g_selected.valid = false; + g_selected.status = status; + } else { + g_selected.identity = identity; + g_selected.profile_index = profile_index; + refresh_selected_locked(); + status = g_selected.status; + } + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus profile_service_begin( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index, uint16_t schema_version, size_t payload_size, + uint32_t payload_crc) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT || payload_size == 0) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kMalformed; + } else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kUnsupportedSchema; + } else if (payload_size > CONTROLLER_PROFILE_ENCODED_SIZE) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kTooLarge; + } else if (payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kMalformed; + } else { + g_transaction.snapshot.expected_size = + static_cast(payload_size); + g_transaction.snapshot.expected_crc = payload_crc; + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kReceiving; + } + const ConfigurationTransactionStatus status = + g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus profile_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size) { + critical_section_enter_blocking(&g_lock); + if (g_transaction.snapshot.status != + ConfigurationTransactionStatus::kReceiving) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + if (transaction_id != g_transaction.snapshot.transaction_id || + data == nullptr || size == 0 || + offset != g_transaction.snapshot.received_size || + offset > g_transaction.snapshot.expected_size || + size > g_transaction.snapshot.expected_size - offset) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kOutOfOrder; + } else { + memcpy(&g_transaction.payload[offset], data, size); + g_transaction.snapshot.received_size = + static_cast(offset + size); + } + const ConfigurationTransactionStatus status = + g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus profile_service_commit( + uint32_t transaction_id) { + critical_section_enter_blocking(&g_lock); + if (g_transaction.snapshot.status != + ConfigurationTransactionStatus::kReceiving || + transaction_id != g_transaction.snapshot.transaction_id || + g_transaction.snapshot.received_size != + g_transaction.snapshot.expected_size) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kOutOfOrder; + } else if (profile_storage_crc32( + g_transaction.payload, + g_transaction.snapshot.expected_size) != + g_transaction.snapshot.expected_crc) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kBadCrc; + } else { + ControllerProfile profile{}; + g_transaction.snapshot.status = + controller_profile_decode( + g_transaction.payload, + g_transaction.snapshot.expected_size, &profile) + ? ConfigurationTransactionStatus::kPending + : ConfigurationTransactionStatus::kMalformed; + } + const ConfigurationTransactionStatus status = + g_transaction.snapshot.status; + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus profile_service_reset( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT)) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; + g_command.type = PendingCommandType::kReset; + g_command.identity = identity; + g_command.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus profile_service_activate( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + if (transaction_id == 0 || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; + g_command.type = PendingCommandType::kActivate; + g_command.identity = identity; + g_command.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + if ((transaction_id & 0x80000000u) == 0 || + !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_internal_activation.type = PendingCommandType::kActivate; + g_internal_activation.transaction_id = transaction_id; + g_internal_activation.identity = identity; + g_internal_activation.profile_index = profile_index; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + +void profile_service_list_snapshot(ProfileServiceListSnapshot* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_list; + critical_section_exit(&g_lock); +} + +void profile_service_selected_snapshot( + ProfileServiceSelectedSnapshot* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_selected; + critical_section_exit(&g_lock); +} + +void profile_service_transaction_snapshot( + ProfileServiceTransactionSnapshot* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + output->identity = g_transaction.identity; + output->profile_index = g_transaction.profile_index; + output->transaction = g_transaction.snapshot; + critical_section_exit(&g_lock); +} + +uint32_t profile_service_database_generation() { + return __atomic_load_n(&g_published_generation, __ATOMIC_ACQUIRE); +} + +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output) { + if (output == nullptr) { + return; + } + *output = {}; + if (!valid_identity(identity)) { + return; + } + + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + if (g_metadata.state == ProfileServiceState::kReady && + g_active_profile_count != 0) { + const PublishedActiveProfile* active = &g_active_profiles[0]; + for (uint8_t index = 1; index < g_active_profile_count; ++index) { + if (controller_identity_equal( + g_active_profiles[index].identity, identity)) { + active = &g_active_profiles[index]; + break; + } + } + output->profile_index = active->profile_index; + output->profile = active->profile; + output->valid = true; + } + critical_section_exit(&g_lock); +} diff --git a/profile_service.h b/profile_service.h new file mode 100644 index 0000000..c922795 --- /dev/null +++ b/profile_service.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include "configuration_transaction.h" +#include "controller_profile.h" + +constexpr uint8_t PROFILE_SERVICE_LIST_CAPACITY = + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; + +enum class ProfileServiceState : uint8_t { + kLoading = 0, + kReady = 1, + kStorageError = 2, +}; + +struct ProfileServiceMetadata { + ProfileServiceState state = ProfileServiceState::kLoading; + uint32_t generation = 0; + uint32_t payload_crc = 0; +}; + +struct ProfileServiceListRow { + ControllerIdentity identity{}; + uint8_t active_profile = 0; +}; + +struct ProfileServiceListSnapshot { + ProfileServiceMetadata metadata{}; + uint8_t count = 0; + ProfileServiceListRow rows[PROFILE_SERVICE_LIST_CAPACITY]{}; +}; + +struct ProfileServiceSelectedSnapshot { + ProfileServiceMetadata metadata{}; + bool valid = false; + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ControllerProfile profile{}; +}; + +struct ProfileServiceActiveProfileSnapshot { + ProfileServiceMetadata metadata{}; + bool valid = false; + uint8_t profile_index = 0; + ControllerProfile profile{}; +}; + +struct ProfileServiceTransactionSnapshot { + ProfileServiceMetadata metadata{}; + ControllerIdentity identity{}; + uint8_t profile_index = 0; + ConfigurationTransactionSnapshot transaction{}; +}; + +void profile_service_prepare(); +void profile_service_initialize_on_storage_core(); +void profile_service_task_on_storage_core(uint32_t now_ms); +bool profile_service_observe_identity_on_storage_core( + const ControllerIdentity& identity); + +ConfigurationTransactionStatus profile_service_select( + const ControllerIdentity& identity, uint8_t profile_index); +ConfigurationTransactionStatus profile_service_begin( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index, uint16_t schema_version, size_t payload_size, + uint32_t payload_crc); +ConfigurationTransactionStatus profile_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size); +ConfigurationTransactionStatus profile_service_commit( + uint32_t transaction_id); +ConfigurationTransactionStatus profile_service_reset( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index); +ConfigurationTransactionStatus profile_service_activate( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index); +// Queue a controller-originated activation without replacing the host-visible +// transaction snapshot. transaction_id must be nonzero with its high bit set. +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index); + +void profile_service_list_snapshot(ProfileServiceListSnapshot* output); +void profile_service_selected_snapshot( + ProfileServiceSelectedSnapshot* output); +void profile_service_transaction_snapshot( + ProfileServiceTransactionSnapshot* output); +uint32_t profile_service_database_generation(); +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output); diff --git a/profile_storage.cpp b/profile_storage.cpp new file mode 100644 index 0000000..75a7250 --- /dev/null +++ b/profile_storage.cpp @@ -0,0 +1,252 @@ +#include "profile_storage.h" + +#include + +namespace { + +constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'P', 'F'}; +constexpr uint16_t kRecordFormatVersion = 1; +constexpr size_t kHeaderFieldsSize = 24; +constexpr size_t kHeaderCrcOffset = 20; + +uint16_t storage_read_u16(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8); +} + +uint32_t storage_read_u32(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); +} + +void storage_write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void storage_write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +uint32_t crc32_update(uint32_t crc, const uint8_t* data, size_t size) { + for (size_t index = 0; index < size; ++index) { + crc ^= data[index]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ + (0xedb88320u & + static_cast( + -static_cast(crc & 1u))); + } + } + return crc; +} + +bool generation_is_newer(uint32_t candidate, uint32_t current) { + return static_cast(candidate - current) > 0; +} + +struct DatabaseReadContext { + const ProfileStorageIo* io; + uint8_t bank; +}; + +bool read_database(void* context, size_t offset, uint8_t* output, + size_t size) { + const auto* read_context = + static_cast(context); + return read_context->io->read( + read_context->io->context, read_context->bank, + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, output, size); +} + +} // namespace + +uint32_t profile_storage_crc32(const uint8_t* data, size_t size) { + if (data == nullptr && size != 0) { + return 0; + } + return ~crc32_update(0xffffffffu, data, size); +} + +bool ProfileStorage::initialize(const ProfileStorageIo& io, + ControllerProfileDatabase* database) { + io_ = io; + snapshot_ = {}; + initialized_ = + database != nullptr && io_.read != nullptr && + io_.replace_bank != nullptr && + io_.bank_size >= PROFILE_STORAGE_BANK_SIZE && + io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE && + io_.page_size == PROFILE_STORAGE_PAGE_SIZE && + io_.bank_size % io_.sector_size == 0 && + io_.sector_size % io_.page_size == 0; + if (!initialized_) { + return false; + } + + controller_profile_database_default(database); + BankHeader headers[PROFILE_STORAGE_BANK_COUNT]{}; + bool valid[PROFILE_STORAGE_BANK_COUNT]{}; + for (uint8_t bank = 0; bank < PROFILE_STORAGE_BANK_COUNT; ++bank) { + valid[bank] = read_header(bank, &headers[bank]) && + validate_payload(bank, headers[bank].payload_crc); + } + + uint8_t first = 0; + uint8_t second = 1; + if (valid[1] && + (!valid[0] || generation_is_newer(headers[1].generation, + headers[0].generation))) { + first = 1; + second = 0; + } + const uint8_t order[PROFILE_STORAGE_BANK_COUNT] = {first, second}; + for (uint8_t candidate : order) { + if (!valid[candidate] || !decode_bank(candidate, database)) { + continue; + } + snapshot_.valid = true; + snapshot_.generation = headers[candidate].generation; + snapshot_.payload_crc = headers[candidate].payload_crc; + snapshot_.active_bank = candidate; + return true; + } + controller_profile_database_default(database); + return true; +} + +ProfileStorageResult ProfileStorage::commit( + const ControllerProfileDatabase& database, + uint8_t* encoded_database, size_t encoded_database_size) { + if (!initialized_ || !controller_profile_database_validate(database) || + encoded_database == nullptr || + encoded_database_size < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + !controller_profile_database_encode_range( + database, 0, encoded_database, + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) { + return ProfileStorageResult::kInvalidArgument; + } + if (snapshot_.valid && + payload_matches_encoded( + snapshot_.active_bank, encoded_database, + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) { + return ProfileStorageResult::kUnchanged; + } + + const uint32_t crc = profile_storage_crc32( + encoded_database, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + const uint8_t target_bank = + snapshot_.valid ? snapshot_.active_bank ^ 1u : 0; + const uint32_t generation = + snapshot_.valid ? snapshot_.generation + 1u : 1u; + uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{}; + memcpy(header, kRecordMagic, sizeof(kRecordMagic)); + storage_write_u16(&header[4], kRecordFormatVersion); + storage_write_u16( + &header[6], CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION); + storage_write_u32(&header[8], generation); + storage_write_u32( + &header[12], CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + storage_write_u32(&header[16], crc); + storage_write_u32( + &header[kHeaderCrcOffset], + profile_storage_crc32(header, kHeaderCrcOffset)); + + if (!io_.replace_bank( + io_.context, target_bank, encoded_database, + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE, header, + sizeof(header))) { + return ProfileStorageResult::kIoError; + } + + snapshot_.valid = true; + snapshot_.generation = generation; + snapshot_.payload_crc = crc; + snapshot_.active_bank = target_bank; + return ProfileStorageResult::kOk; +} + +const ProfileStorageSnapshot& ProfileStorage::snapshot() const { + return snapshot_; +} + +bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const { + uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{}; + if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || + !io_.read(io_.context, bank, 0, header, sizeof(header)) || + memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || + storage_read_u16(&header[4]) != kRecordFormatVersion || + (storage_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + storage_read_u16(&header[6]) != + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) || + storage_read_u32(&header[12]) != + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + profile_storage_crc32(header, kHeaderCrcOffset) != + storage_read_u32(&header[kHeaderCrcOffset])) { + return false; + } + for (size_t offset = kHeaderFieldsSize; + offset < sizeof(header); ++offset) { + if (header[offset] != 0) { + return false; + } + } + output->generation = storage_read_u32(&header[8]); + output->payload_crc = storage_read_u32(&header[16]); + return true; +} + +bool ProfileStorage::validate_payload(uint8_t bank, + uint32_t expected_crc) const { + uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{}; + uint32_t crc = 0xffffffffu; + for (size_t offset = 0; + offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE; + offset += sizeof(page)) { + const size_t size = + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(page) + ? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset + : sizeof(page); + if (!io_.read(io_.context, bank, + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, + page, size)) { + return false; + } + crc = crc32_update(crc, page, size); + } + return ~crc == expected_crc; +} + +bool ProfileStorage::decode_bank( + uint8_t bank, ControllerProfileDatabase* database) const { + DatabaseReadContext context{&io_, bank}; + return controller_profile_database_decode(read_database, &context, + database); +} + +bool ProfileStorage::payload_matches_encoded( + uint8_t bank, const uint8_t* payload, + size_t payload_size) const { + uint8_t stored[PROFILE_STORAGE_PAGE_SIZE]{}; + for (size_t offset = 0; offset < payload_size; + offset += sizeof(stored)) { + const size_t size = + payload_size - offset < sizeof(stored) + ? payload_size - offset + : sizeof(stored); + if (!io_.read( + io_.context, bank, + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset, + stored, size) || + memcmp(stored, &payload[offset], size) != 0) { + return false; + } + } + return true; +} diff --git a/profile_storage.h b/profile_storage.h new file mode 100644 index 0000000..7e2d0bc --- /dev/null +++ b/profile_storage.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +#include "controller_profile.h" + +constexpr uint8_t PROFILE_STORAGE_BANK_COUNT = 2; +constexpr size_t PROFILE_STORAGE_SECTOR_SIZE = 4096; +constexpr size_t PROFILE_STORAGE_SECTORS_PER_BANK = 5; +constexpr size_t PROFILE_STORAGE_BANK_SIZE = + PROFILE_STORAGE_SECTOR_SIZE * PROFILE_STORAGE_SECTORS_PER_BANK; +constexpr size_t PROFILE_STORAGE_TOTAL_SIZE = + PROFILE_STORAGE_BANK_COUNT * PROFILE_STORAGE_BANK_SIZE; +constexpr size_t PROFILE_STORAGE_PAGE_SIZE = 256; +constexpr size_t PROFILE_STORAGE_RECORD_HEADER_SIZE = + PROFILE_STORAGE_PAGE_SIZE; + +static_assert(PROFILE_STORAGE_BANK_SIZE == 20 * 1024); +static_assert(PROFILE_STORAGE_TOTAL_SIZE == 40 * 1024); +static_assert(PROFILE_STORAGE_RECORD_HEADER_SIZE + + CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE <= + PROFILE_STORAGE_BANK_SIZE, + "profile database does not fit a storage bank"); + +enum class ProfileStorageResult : uint8_t { + kOk = 0, + kUnchanged = 1, + kInvalidArgument = 2, + kIoError = 3, +}; + +struct ProfileStorageIo { + void* context = nullptr; + size_t bank_size = 0; + size_t sector_size = 0; + size_t page_size = 0; + bool (*read)(void* context, uint8_t bank, size_t offset, + uint8_t* output, size_t size) = nullptr; + // Replaces one bank and verifies the payload before publishing the + // complete header page. + bool (*replace_bank)(void* context, uint8_t bank, + const uint8_t* payload, size_t payload_size, + const uint8_t* header, size_t header_size) = nullptr; +}; + +struct ProfileStorageSnapshot { + bool valid = false; + uint32_t generation = 0; + uint32_t payload_crc = 0; + uint8_t active_bank = 0; +}; + +uint32_t profile_storage_crc32(const uint8_t* data, size_t size); + +class ProfileStorage { +public: + bool initialize(const ProfileStorageIo& io, + ControllerProfileDatabase* database); + ProfileStorageResult commit( + const ControllerProfileDatabase& database, + uint8_t* encoded_database, + size_t encoded_database_size); + const ProfileStorageSnapshot& snapshot() const; + +private: + struct BankHeader { + uint32_t generation = 0; + uint32_t payload_crc = 0; + }; + + bool read_header(uint8_t bank, BankHeader* output) const; + bool validate_payload(uint8_t bank, uint32_t expected_crc) const; + bool decode_bank(uint8_t bank, + ControllerProfileDatabase* database) const; + bool payload_matches_encoded(uint8_t bank, + const uint8_t* payload, + size_t payload_size) const; + + ProfileStorageIo io_{}; + ProfileStorageSnapshot snapshot_{}; + bool initialized_ = false; +}; diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 3d7169d..aa71e66 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Manage switch-pico persistent configuration and pairings over USB EP0.""" +"""Manage switch-pico persistent configuration, profiles, and pairings.""" from __future__ import annotations import argparse +import json import secrets import struct import sys @@ -11,6 +12,7 @@ import time import zlib from collections.abc import Iterable, Sequence from dataclasses import dataclass +from pathlib import Path from typing import Any, Protocol, cast import usb.core @@ -22,9 +24,10 @@ PROTOCOL_VERSION = 1 REQUEST_HEADER_SIZE = 16 RESPONSE_HEADER_SIZE = 20 MAXIMUM_REQUEST_SIZE = 64 -MAXIMUM_RESPONSE_SIZE = 152 +MAXIMUM_RESPONSE_SIZE = 293 MAXIMUM_CHUNK_SIZE = 40 USB_TIMEOUT_MS = 1000 +DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0 OP_INFO = 0x01 OP_CONFIGURATION_READ = 0x10 @@ -36,6 +39,15 @@ OP_TRANSACTION_STATUS = 0x15 OP_PAIRING_READ = 0x20 OP_PAIRING_REFRESH = 0x21 OP_PAIRING_CLEAR = 0x22 +OP_PROFILE_LIST = 0x30 +OP_PROFILE_SELECT = 0x31 +OP_PROFILE_READ = 0x32 +OP_PROFILE_BEGIN = 0x33 +OP_PROFILE_CHUNK = 0x34 +OP_PROFILE_COMMIT = 0x35 +OP_PROFILE_RESET = 0x36 +OP_PROFILE_ACTIVATE = 0x37 +OP_PROFILE_TRANSACTION_STATUS = 0x38 STATUS_OK = 0 STATUS_PENDING = 1 @@ -55,8 +67,53 @@ PAIRING_WINDOW_SECONDS_MIN = 10 PAIRING_WINDOW_SECONDS_MAX = 300 PAIRING_RECORD_SIZE = 8 PAIRING_RECORD_CAPACITY = 16 +TRANSPORT_UNKNOWN = 0 TRANSPORT_CLASSIC = 1 TRANSPORT_BLE = 2 +PROFILE_LEGACY_SCHEMA_VERSION = 1 +PROFILE_SCHEMA_VERSION = 2 +PROFILE_SIZE = 256 +PROFILE_CAPACITY = 4 +PROFILE_IDENTITY_CAPACITY = 16 +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_STEP_SIZE = 19 +PROFILE_MAXIMUM_WAIT_MS = 10000 +PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 +PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934 + +LOGICAL_BUTTONS = ( + "south", + "east", + "west", + "north", + "left_shoulder", + "right_shoulder", + "select", + "start", + "system", + "capture", + "left_stick", + "right_stick", + "dpad_up", + "dpad_down", + "dpad_left", + "dpad_right", +) +RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") +TURBO_MODES = ("off", "turbo", "auto_burst") +MACRO_STEP_TYPES = ("state", "end") +MACRO_OVERRIDE_NAMES = ( + "buttons", + "left_stick", + "right_stick", + "left_trigger", + "right_trigger", +) +MACRO_OVERRIDE_MASK = (1 << len(MACRO_OVERRIDE_NAMES)) - 1 class ConfigManagerError(RuntimeError): @@ -152,6 +209,961 @@ class PairingSnapshot: records: tuple[PairingRecord, ...] +def _require_int(value: Any, name: str, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise ConfigManagerError( + f"{name} must be an integer from {minimum} to {maximum}" + ) + return value + + +def _require_bool(value: Any, name: str) -> bool: + if type(value) is not bool: + raise ConfigManagerError(f"{name} must be true or false") + return value + + +def _require_object( + value: Any, fields: Sequence[str], name: str +) -> dict[str, Any]: + if type(value) is not dict: + raise ConfigManagerError(f"{name} must be a JSON object") + expected = set(fields) + actual = set(value) + missing = sorted(expected - actual) + unknown = sorted(actual - expected) + if missing or unknown: + details: list[str] = [] + if missing: + details.append("missing " + ", ".join(missing)) + if unknown: + details.append("unknown " + ", ".join(unknown)) + raise ConfigManagerError(f"invalid {name}: {'; '.join(details)}") + return value + + +def _require_enum(value: Any, choices: Sequence[str], name: str) -> int: + if type(value) is not str or value not in choices: + raise ConfigManagerError( + f"{name} must be one of {', '.join(choices)}" + ) + return choices.index(value) + + +def _button_index(value: Any, name: str) -> int: + if value is None: + return PROFILE_NONE_BUTTON + if type(value) is not str or value not in LOGICAL_BUTTONS: + raise ConfigManagerError( + f"{name} must be a logical button name or null" + ) + return LOGICAL_BUTTONS.index(value) + + +def _button_name(value: int) -> str | None: + if value == PROFILE_NONE_BUTTON: + return None + return LOGICAL_BUTTONS[value] + + +def _button_mask_from_json(value: Any, name: str) -> int: + if type(value) is not list: + raise ConfigManagerError(f"{name} must be a JSON array") + mask = 0 + for entry in value: + index = _button_index(entry, name) + if index == PROFILE_NONE_BUTTON: + raise ConfigManagerError(f"{name} cannot contain null") + bit = 1 << index + if mask & bit: + raise ConfigManagerError(f"{name} contains a duplicate button") + mask |= bit + return mask + + +def _button_mask_to_json(mask: int) -> list[str]: + return [ + name + for index, name in enumerate(LOGICAL_BUTTONS) + if mask & (1 << index) + ] + + +@dataclass(frozen=True) +class ControllerIdentity: + stable: bool + transport: int + address_type: int + address: bytes + vendor_id: int + product_id: int + + def __post_init__(self) -> None: + _require_bool(self.stable, "identity stable") + _require_int(self.transport, "identity transport", 0, 2) + _require_int(self.address_type, "identity address_type", 0, 0xFF) + if type(self.address) is not bytes or len(self.address) != 6: + raise ConfigManagerError("identity address must contain six bytes") + _require_int(self.vendor_id, "identity vendor_id", 0, 0xFFFF) + _require_int(self.product_id, "identity product_id", 0, 0xFFFF) + if self.stable: + if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE): + raise ConfigManagerError( + "stable identity transport must be Classic or BLE" + ) + elif ( + self.transport != TRANSPORT_UNKNOWN + or self.address_type != 0 + or self.address != bytes(6) + or self.vendor_id != 0 + or self.product_id != 0 + ): + raise ConfigManagerError( + "unstable identity must be the all-zero global fallback" + ) + + @classmethod + def global_fallback(cls) -> ControllerIdentity: + return cls(False, TRANSPORT_UNKNOWN, 0, bytes(6), 0, 0) + + @classmethod + def from_bytes(cls, payload: bytes) -> ControllerIdentity: + payload = bytes(payload) + if len(payload) != CONTROLLER_IDENTITY_SIZE: + raise ConfigManagerError("invalid controller identity size") + stable, transport, address_type, reserved = payload[:4] + if stable not in (0, 1) or reserved != 0: + raise ConfigManagerError("invalid controller identity encoding") + vendor_id, product_id = struct.unpack_from(" bytes: + return ( + bytes( + [ + int(self.stable), + self.transport, + self.address_type, + 0, + ] + ) + + self.address + + struct.pack(" bool: + return not self.stable + + @property + def address_text(self) -> str: + return ":".join(f"{octet:02X}" for octet in self.address) + + @property + def transport_text(self) -> str: + if self.transport == TRANSPORT_CLASSIC: + return "Classic" + if self.transport == TRANSPORT_BLE: + return "BLE" + return "Unknown" + + +@dataclass(frozen=True) +class ProfileListEntry: + identity: ControllerIdentity + active_profile_index: int + + def __post_init__(self) -> None: + if not isinstance(self.identity, ControllerIdentity): + raise ConfigManagerError( + "profile-list identity must be a ControllerIdentity" + ) + _require_int( + self.active_profile_index, + "active profile index", + 0, + PROFILE_CAPACITY - 1, + ) + + +@dataclass(frozen=True) +class StickConfig: + center_x: int + center_y: int + inner_deadzone: int + outer_saturation: int + curve_q8_8: int + invert_x: bool + invert_y: bool + + def __post_init__(self) -> None: + _require_int(self.center_x, "stick center_x", -0x8000, 0x7FFF) + _require_int(self.center_y, "stick center_y", -0x8000, 0x7FFF) + _require_int( + self.inner_deadzone, "stick inner_deadzone", 0, 0x7FFF + ) + _require_int( + self.outer_saturation, "stick outer_saturation", 1, 0x7FFF + ) + if self.inner_deadzone >= self.outer_saturation: + raise ConfigManagerError( + "stick inner_deadzone must be below outer_saturation" + ) + _require_int(self.curve_q8_8, "stick curve_q8_8", 1, 0xFFFF) + _require_bool(self.invert_x, "stick invert_x") + _require_bool(self.invert_y, "stick invert_y") + + @classmethod + def from_bytes(cls, payload: bytes) -> StickConfig: + if len(payload) != 16 or payload[11:] != bytes(5): + raise ConfigManagerError("invalid stick configuration encoding") + center_x, center_y, inner, outer, curve_q8_8, flags = struct.unpack( + " bytes: + flags = int(self.invert_x) | (int(self.invert_y) << 1) + return struct.pack( + " dict[str, Any]: + return { + "center_x": self.center_x, + "center_y": self.center_y, + "inner_deadzone": self.inner_deadzone, + "outer_saturation": self.outer_saturation, + "curve_q8_8": self.curve_q8_8, + "invert_x": self.invert_x, + "invert_y": self.invert_y, + } + + @classmethod + def from_json_object(cls, value: Any, name: str) -> StickConfig: + fields = ( + "center_x", + "center_y", + "inner_deadzone", + "outer_saturation", + "curve_q8_8", + "invert_x", + "invert_y", + ) + obj = _require_object(value, fields, name) + return cls( + _require_int(obj["center_x"], f"{name}.center_x", -0x8000, 0x7FFF), + _require_int(obj["center_y"], f"{name}.center_y", -0x8000, 0x7FFF), + _require_int( + obj["inner_deadzone"], + f"{name}.inner_deadzone", + 0, + 0x7FFF, + ), + _require_int( + obj["outer_saturation"], + f"{name}.outer_saturation", + 1, + 0x7FFF, + ), + _require_int( + obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF + ), + _require_bool(obj["invert_x"], f"{name}.invert_x"), + _require_bool(obj["invert_y"], f"{name}.invert_y"), + ) + + +@dataclass(frozen=True) +class TriggerConfig: + lower_deadzone: int + upper_saturation: int + curve_q8_8: int + digital_threshold: int + + def __post_init__(self) -> None: + _require_int( + self.lower_deadzone, "trigger lower_deadzone", 0, 0xFFFF + ) + _require_int( + self.upper_saturation, "trigger upper_saturation", 1, 0xFFFF + ) + if self.lower_deadzone >= self.upper_saturation: + raise ConfigManagerError( + "trigger lower_deadzone must be below upper_saturation" + ) + _require_int(self.curve_q8_8, "trigger curve_q8_8", 1, 0xFFFF) + _require_int( + self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF + ) + + @classmethod + def from_bytes(cls, payload: bytes) -> TriggerConfig: + if len(payload) != 10 or payload[8:] != b"\x00\x00": + raise ConfigManagerError("invalid trigger configuration encoding") + return cls(*struct.unpack(" bytes: + return struct.pack( + " dict[str, int]: + return { + "lower_deadzone": self.lower_deadzone, + "upper_saturation": self.upper_saturation, + "curve_q8_8": self.curve_q8_8, + "digital_threshold": self.digital_threshold, + } + + @classmethod + def from_json_object(cls, value: Any, name: str) -> TriggerConfig: + fields = ( + "lower_deadzone", + "upper_saturation", + "curve_q8_8", + "digital_threshold", + ) + obj = _require_object(value, fields, name) + return cls( + _require_int( + obj["lower_deadzone"], + f"{name}.lower_deadzone", + 0, + 0xFFFF, + ), + _require_int( + obj["upper_saturation"], + f"{name}.upper_saturation", + 1, + 0xFFFF, + ), + _require_int( + obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF + ), + _require_int( + obj["digital_threshold"], + f"{name}.digital_threshold", + 0, + 0xFFFF, + ), + ) + +def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: + if ( + trigger.digital_threshold + != PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD + ): + return trigger + return TriggerConfig( + trigger.lower_deadzone, + trigger.upper_saturation, + trigger.curve_q8_8, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + ) + + +@dataclass(frozen=True) +class MacroStep: + step_type: int + override_flags: int + duration_ms: int + output_button_mask: int + left_stick_x: int + left_stick_y: int + right_stick_x: int + right_stick_y: int + left_trigger: int + right_trigger: int + + def __post_init__(self) -> None: + _require_int(self.step_type, "macro step type", 0, 1) + _require_int( + self.override_flags, "macro override flags", 0, MACRO_OVERRIDE_MASK + ) + _require_int( + self.duration_ms, + "macro duration_ms", + 0, + PROFILE_MAXIMUM_WAIT_MS, + ) + _require_int( + self.output_button_mask, "macro output button mask", 0, 0xFFFF + ) + for name in ( + "left_stick_x", + "left_stick_y", + "right_stick_x", + "right_stick_y", + ): + _require_int( + getattr(self, name), f"macro {name}", -0x8000, 0x7FFF + ) + _require_int(self.left_trigger, "macro left_trigger", 0, 0xFFFF) + _require_int(self.right_trigger, "macro right_trigger", 0, 0xFFFF) + if self.step_type == 1 and any( + ( + self.override_flags, + self.duration_ms, + self.output_button_mask, + self.left_stick_x, + self.left_stick_y, + self.right_stick_x, + self.right_stick_y, + self.left_trigger, + self.right_trigger, + ) + ): + raise ConfigManagerError("end macro step must otherwise be zero") + if self.step_type == 0: + if ( + not self.override_flags & 1 + and self.output_button_mask != 0 + ): + raise ConfigManagerError( + "macro buttons require the buttons override" + ) + if ( + not self.override_flags & 2 + and (self.left_stick_x != 0 or self.left_stick_y != 0) + ): + raise ConfigManagerError( + "macro left stick values require the left_stick override" + ) + if ( + not self.override_flags & 4 + and (self.right_stick_x != 0 or self.right_stick_y != 0) + ): + raise ConfigManagerError( + "macro right stick values require the right_stick override" + ) + if not self.override_flags & 8 and self.left_trigger != 0: + raise ConfigManagerError( + "macro left trigger requires the left_trigger override" + ) + if not self.override_flags & 16 and self.right_trigger != 0: + raise ConfigManagerError( + "macro right trigger requires the right_trigger override" + ) + + @classmethod + def end(cls) -> MacroStep: + return cls(1, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + @classmethod + def from_bytes(cls, payload: bytes) -> MacroStep: + if len(payload) != PROFILE_MACRO_STEP_SIZE or payload[18] != 0: + raise ConfigManagerError("invalid macro step encoding") + return cls(*struct.unpack(" bytes: + return struct.pack( + " dict[str, Any]: + return { + "type": MACRO_STEP_TYPES[self.step_type], + "overrides": [ + name + for index, name in enumerate(MACRO_OVERRIDE_NAMES) + if self.override_flags & (1 << index) + ], + "duration_ms": self.duration_ms, + "output_buttons": _button_mask_to_json( + self.output_button_mask + ), + "left_stick": { + "x": self.left_stick_x, + "y": self.left_stick_y, + }, + "right_stick": { + "x": self.right_stick_x, + "y": self.right_stick_y, + }, + "triggers": { + "left": self.left_trigger, + "right": self.right_trigger, + }, + } + + @classmethod + def from_json_object(cls, value: Any, name: str) -> MacroStep: + fields = ( + "type", + "overrides", + "duration_ms", + "output_buttons", + "left_stick", + "right_stick", + "triggers", + ) + obj = _require_object(value, fields, name) + overrides = obj["overrides"] + if type(overrides) is not list: + raise ConfigManagerError(f"{name}.overrides must be a JSON array") + override_flags = 0 + for override in overrides: + index = _require_enum( + override, MACRO_OVERRIDE_NAMES, f"{name}.overrides" + ) + bit = 1 << index + if override_flags & bit: + raise ConfigManagerError( + f"{name}.overrides contains a duplicate" + ) + override_flags |= bit + left = _require_object( + obj["left_stick"], ("x", "y"), f"{name}.left_stick" + ) + right = _require_object( + obj["right_stick"], ("x", "y"), f"{name}.right_stick" + ) + triggers = _require_object( + obj["triggers"], ("left", "right"), f"{name}.triggers" + ) + return cls( + _require_enum(obj["type"], MACRO_STEP_TYPES, f"{name}.type"), + override_flags, + _require_int( + obj["duration_ms"], + f"{name}.duration_ms", + 0, + PROFILE_MAXIMUM_WAIT_MS, + ), + _button_mask_from_json( + obj["output_buttons"], f"{name}.output_buttons" + ), + _require_int( + left["x"], f"{name}.left_stick.x", -0x8000, 0x7FFF + ), + _require_int( + left["y"], f"{name}.left_stick.y", -0x8000, 0x7FFF + ), + _require_int( + right["x"], f"{name}.right_stick.x", -0x8000, 0x7FFF + ), + _require_int( + right["y"], f"{name}.right_stick.y", -0x8000, 0x7FFF + ), + _require_int( + triggers["left"], + f"{name}.triggers.left", + 0, + 0xFFFF, + ), + _require_int( + triggers["right"], + f"{name}.triggers.right", + 0, + 0xFFFF, + ), + ) + + +@dataclass(frozen=True) +class ControllerProfile: + button_map: tuple[int, ...] + left_stick: StickConfig + right_stick: StickConfig + left_trigger: TriggerConfig + right_trigger: TriggerConfig + weak_rumble_scale: int + strong_rumble_scale: int + confirmation_policy: int + switching_chord: int + macro_trigger: int + macro_cancel: int + macro_steps: tuple[MacroStep, ...] + turbo_modes: tuple[int, ...] + + def __post_init__(self) -> None: + if type(self.button_map) is not tuple or len(self.button_map) != len( + LOGICAL_BUTTONS + ): + raise ConfigManagerError( + "button map must contain 16 logical mappings" + ) + for mapping in self.button_map: + if type(mapping) is not int or ( + mapping != PROFILE_NONE_BUTTON + and not 0 <= mapping < len(LOGICAL_BUTTONS) + ): + raise ConfigManagerError("invalid logical button mapping") + if not isinstance(self.left_stick, StickConfig) or not isinstance( + self.right_stick, StickConfig + ): + raise ConfigManagerError("profile sticks must be StickConfig values") + if not isinstance(self.left_trigger, TriggerConfig) or not isinstance( + self.right_trigger, TriggerConfig + ): + raise ConfigManagerError( + "profile triggers must be TriggerConfig values" + ) + _require_int( + self.weak_rumble_scale, "weak rumble scale", 0, 0xFF + ) + _require_int( + self.strong_rumble_scale, "strong rumble scale", 0, 0xFF + ) + _require_int( + self.confirmation_policy, + "confirmation policy", + 0, + len(RUMBLE_POLICIES) - 1, + ) + _require_int(self.switching_chord, "switching chord", 0, 0xFFFF) + for value, name in ( + (self.macro_trigger, "macro trigger"), + (self.macro_cancel, "macro cancel"), + ): + if type(value) is not int or ( + value != PROFILE_NONE_BUTTON + and not 0 <= value < len(LOGICAL_BUTTONS) + ): + raise ConfigManagerError(f"invalid {name}") + 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) + ): + 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") + if type(self.turbo_modes) is not tuple or len( + self.turbo_modes + ) != len(LOGICAL_BUTTONS): + raise ConfigManagerError("Turbo modes must contain 16 entries") + for mode in self.turbo_modes: + _require_int(mode, "Turbo mode", 0, len(TURBO_MODES) - 1) + + @classmethod + def default(cls) -> ControllerProfile: + stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False) + trigger = TriggerConfig( + 0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD + ) + return cls( + button_map=tuple(range(len(LOGICAL_BUTTONS))), + left_stick=stick, + right_stick=stick, + left_trigger=trigger, + right_trigger=trigger, + weak_rumble_scale=0xFF, + strong_rumble_scale=0xFF, + confirmation_policy=3, + switching_chord=0, + macro_trigger=PROFILE_NONE_BUTTON, + macro_cancel=PROFILE_NONE_BUTTON, + macro_steps=(MacroStep.end(),), + turbo_modes=(0,) * len(LOGICAL_BUTTONS), + ) + + @classmethod + def from_bytes(cls, payload: bytes) -> ControllerProfile: + payload = bytes(payload) + if len(payload) != PROFILE_SIZE: + raise ConfigManagerError("invalid profile size") + version, size = struct.unpack_from(" bytes: + payload = bytearray(PROFILE_SIZE) + struct.pack_into( + " dict[str, Any]: + return { + "schema_version": PROFILE_SCHEMA_VERSION, + "size": PROFILE_SIZE, + "button_map": { + name: _button_name(self.button_map[index]) + for index, name in enumerate(LOGICAL_BUTTONS) + }, + "sticks": { + "left": self.left_stick.to_json_object(), + "right": self.right_stick.to_json_object(), + }, + "triggers": { + "left": self.left_trigger.to_json_object(), + "right": self.right_trigger.to_json_object(), + }, + "rumble": { + "weak_scale": self.weak_rumble_scale, + "strong_scale": self.strong_rumble_scale, + "confirmation_policy": RUMBLE_POLICIES[ + self.confirmation_policy + ], + }, + "switching_chord": _button_mask_to_json(self.switching_chord), + "macro": { + "trigger": _button_name(self.macro_trigger), + "cancel": _button_name(self.macro_cancel), + "steps": [ + step.to_json_object() for step in self.macro_steps + ], + }, + "turbo": { + name: TURBO_MODES[self.turbo_modes[index]] + for index, name in enumerate(LOGICAL_BUTTONS) + }, + } + + def to_json(self) -> str: + return json.dumps(self.to_json_object(), indent=2) + "\n" + + @classmethod + def from_json_object(cls, value: Any) -> ControllerProfile: + fields = ( + "schema_version", + "size", + "button_map", + "sticks", + "triggers", + "rumble", + "switching_chord", + "macro", + "turbo", + ) + obj = _require_object(value, fields, "profile") + schema_version = _require_int( + obj["schema_version"], "profile.schema_version", 0, 0xFFFF + ) + if ( + schema_version + not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION) + or _require_int(obj["size"], "profile.size", 0, 0xFFFF) + != PROFILE_SIZE + ): + raise ConfigManagerError("unsupported profile schema") + button_map = _require_object( + obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" + ) + sticks = _require_object( + obj["sticks"], ("left", "right"), "profile.sticks" + ) + triggers = _require_object( + obj["triggers"], ("left", "right"), "profile.triggers" + ) + rumble = _require_object( + obj["rumble"], + ("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" + ) + right_trigger = TriggerConfig.from_json_object( + triggers["right"], "profile.triggers.right" + ) + if schema_version == PROFILE_LEGACY_SCHEMA_VERSION: + left_trigger = _migrate_legacy_trigger_threshold(left_trigger) + right_trigger = _migrate_legacy_trigger_threshold(right_trigger) + return cls( + button_map=tuple( + _button_index( + button_map[name], f"profile.button_map.{name}" + ) + for name in LOGICAL_BUTTONS + ), + left_stick=StickConfig.from_json_object( + sticks["left"], "profile.sticks.left" + ), + right_stick=StickConfig.from_json_object( + sticks["right"], "profile.sticks.right" + ), + left_trigger=left_trigger, + right_trigger=right_trigger, + weak_rumble_scale=_require_int( + rumble["weak_scale"], + "profile.rumble.weak_scale", + 0, + 0xFF, + ), + strong_rumble_scale=_require_int( + rumble["strong_scale"], + "profile.rumble.strong_scale", + 0, + 0xFF, + ), + confirmation_policy=_require_enum( + rumble["confirmation_policy"], + RUMBLE_POLICIES, + "profile.rumble.confirmation_policy", + ), + switching_chord=_button_mask_from_json( + obj["switching_chord"], "profile.switching_chord" + ), + macro_trigger=_button_index( + macro["trigger"], "profile.macro.trigger" + ), + macro_cancel=_button_index( + macro["cancel"], "profile.macro.cancel" + ), + macro_steps=tuple( + MacroStep.from_json_object( + step, f"profile.macro.steps[{index}]" + ) + for index, step in enumerate(steps) + ), + turbo_modes=tuple( + _require_enum( + turbo[name], TURBO_MODES, f"profile.turbo.{name}" + ) + for name in LOGICAL_BUTTONS + ), + ) + + @classmethod + def from_json(cls, payload: str) -> ControllerProfile: + try: + value = json.loads(payload, object_pairs_hook=_unique_json_object) + except json.JSONDecodeError as exc: + raise ConfigManagerError(f"invalid profile JSON: {exc.msg}") from exc + return cls.from_json_object(value) + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ConfigManagerError(f"duplicate JSON field {key}") + result[key] = value + return result + + def _crc32(payload: bytes) -> int: return zlib.crc32(payload) & 0xFFFFFFFF @@ -362,6 +1374,203 @@ def reset_configuration(device: UsbDevice, timeout: float) -> TransactionStatus: return _wait_for_transaction(device, transaction_id, timeout) +def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: + _raise_status(envelope) + if envelope.schema_version not in ( + PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_SCHEMA_VERSION, + ): + raise ConfigManagerError("unsupported profile-list schema") + if not envelope.payload: + raise ConfigManagerError("short profile-list payload") + count = envelope.payload[0] + if ( + not 1 <= count <= PROFILE_LIST_CAPACITY + or len(envelope.payload) != 1 + count * PROFILE_LIST_ROW_SIZE + ): + raise ConfigManagerError("invalid profile-list count") + entries: list[ProfileListEntry] = [] + identities: set[ControllerIdentity] = set() + for index in range(count): + offset = 1 + index * PROFILE_LIST_ROW_SIZE + identity = ControllerIdentity.from_bytes( + envelope.payload[offset : offset + CONTROLLER_IDENTITY_SIZE] + ) + active_profile_index = envelope.payload[offset + 14] + if envelope.payload[offset + 15] != 0: + raise ConfigManagerError("profile-list reserved field is nonzero") + if index == 0 and not identity.is_global_fallback: + raise ConfigManagerError( + "profile list does not begin with global fallback" + ) + if index != 0 and identity.is_global_fallback: + raise ConfigManagerError("duplicate global fallback profile entry") + if identity in identities: + raise ConfigManagerError("duplicate identity in profile list") + identities.add(identity) + entries.append(ProfileListEntry(identity, active_profile_index)) + return tuple(entries) + + +def list_profiles(device: UsbDevice) -> tuple[ProfileListEntry, ...]: + return parse_profile_list(_control_in(device, OP_PROFILE_LIST)) + + +def _validate_profile_index(profile_index: int) -> None: + _require_int( + profile_index, + "profile index", + 0, + PROFILE_CAPACITY - 1, + ) + + +def select_profile( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int, +) -> None: + _validate_profile_index(profile_index) + _control_out( + device, + OP_PROFILE_SELECT, + identity.to_bytes() + bytes((profile_index,)), + ) + + +def read_selected_profile(device: UsbDevice) -> ControllerProfile: + envelope = _control_in(device, OP_PROFILE_READ) + _raise_status(envelope) + if envelope.schema_version not in ( + PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_SCHEMA_VERSION, + ): + raise ConfigManagerError("unsupported profile schema") + return ControllerProfile.from_bytes(envelope.payload) + + +def read_profile( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int, +) -> ControllerProfile: + select_profile(device, identity, profile_index) + return read_selected_profile(device) + + +def read_profile_transaction_status(device: UsbDevice) -> TransactionStatus: + envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS) + _raise_status(envelope, pending_ok=True) + if ( + envelope.schema_version != PROFILE_SCHEMA_VERSION + or len(envelope.payload) != 20 + ): + raise ConfigManagerError("invalid profile transaction-status payload") + values = struct.unpack(" TransactionStatus: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS) + if ( + envelope.schema_version != PROFILE_SCHEMA_VERSION + or len(envelope.payload) != 20 + ): + raise ConfigManagerError( + "invalid profile transaction-status payload" + ) + values = struct.unpack(" TransactionStatus: + _validate_profile_index(profile_index) + if not isinstance(profile, ControllerProfile): + raise ConfigManagerError("profile must be a ControllerProfile") + payload = profile.to_bytes() + transaction_id = secrets.randbits(32) or 1 + begin = ( + struct.pack(" TransactionStatus: + wire_index = PROFILE_NONE_BUTTON + if profile_index is not None: + _validate_profile_index(profile_index) + wire_index = profile_index + transaction_id = secrets.randbits(32) or 1 + _control_out( + device, + OP_PROFILE_RESET, + struct.pack(" TransactionStatus: + _validate_profile_index(profile_index) + transaction_id = secrets.randbits(32) or 1 + _control_out( + device, + OP_PROFILE_ACTIVATE, + struct.pack(" PairingSnapshot: _raise_status(envelope, pending_ok=True) if len(envelope.payload) < 4: @@ -484,18 +1693,107 @@ def _print_pairings(snapshot: PairingSnapshot) -> None: print("Warning: additional pairings did not fit in the response.") +def _print_profiles(entries: Sequence[ProfileListEntry]) -> None: + for index, entry in enumerate(entries): + identity = entry.identity + if identity.is_global_fallback: + description = "global fallback" + else: + description = ( + f"{identity.transport_text} {identity.address_text} " + f"address-type {identity.address_type} " + f"VID:PID {identity.vendor_id:04X}:{identity.product_id:04X}" + ) + print( + f"{index}: {description} " + f"(active profile {entry.active_profile_index + 1})" + ) + + +def _resolve_profile_identity( + entries: Sequence[ProfileListEntry], identity_index: int +) -> ControllerIdentity: + if not 0 <= identity_index < len(entries): + raise ConfigManagerError( + f"identity index {identity_index} is out of range; " + f"use profiles list to see indices 0 through {len(entries) - 1}" + ) + return entries[identity_index].identity + + +def _load_profile(path: Path) -> ControllerProfile: + try: + payload = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigManagerError( + f"could not read profile JSON {path}: {exc}" + ) from exc + return ControllerProfile.from_json(payload) + + +def _save_profile(path: Path, profile: ControllerProfile) -> None: + try: + path.write_text(profile.to_json(), encoding="utf-8") + except OSError as exc: + raise ConfigManagerError( + f"could not write profile JSON {path}: {exc}" + ) from exc + + +def _profile_number(value: str) -> int: + try: + number = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "profile must be a number from 1 to 4" + ) from exc + if not 1 <= number <= PROFILE_CAPACITY: + raise argparse.ArgumentTypeError("profile must be a number from 1 to 4") + return number - 1 + + +def _identity_index(value: str) -> int: + try: + index = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "identity must be a non-negative list index" + ) from exc + if index < 0: + raise argparse.ArgumentTypeError( + "identity must be a non-negative list index" + ) + return index + + +def _add_identity_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--identity", + type=_identity_index, + default=0, + metavar="N", + help="identity index from profiles list (default: 0)", + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="switch-pico-config", - description="Manage switch-pico persistent configuration and pairings.", + description=( + "Manage switch-pico persistent configuration, profiles, " + "and pairings." + ), ) parser.add_argument("--bus", type=int, help="USB bus number") parser.add_argument("--address", type=int, help="USB device address") parser.add_argument( "--timeout", type=float, - default=3.0, - help="operation timeout in seconds (default: 3)", + default=DEFAULT_OPERATION_TIMEOUT_SECONDS, + help=( + "operation timeout in seconds " + f"(default: {DEFAULT_OPERATION_TIMEOUT_SECONDS:g})" + ), ) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("status", help="show firmware and configuration status") @@ -513,6 +1811,49 @@ def build_parser() -> argparse.ArgumentParser: config_reset = config_commands.add_parser("reset", help="restore defaults") config_reset.add_argument("--yes", action="store_true") + profiles = commands.add_parser( + "profiles", help="list, import, export, or select controller profiles" + ) + profile_commands = profiles.add_subparsers( + dest="profile_command", required=True + ) + profile_commands.add_parser( + "list", help="list profile identities and active profiles" + ) + profile_export = profile_commands.add_parser( + "export", help="export a profile as JSON" + ) + profile_export.add_argument( + "profile_index", type=_profile_number, metavar="PROFILE" + ) + profile_export.add_argument("path", type=Path, metavar="PATH") + _add_identity_argument(profile_export) + profile_import = profile_commands.add_parser( + "import", help="import a profile from JSON" + ) + profile_import.add_argument( + "profile_index", type=_profile_number, metavar="PROFILE" + ) + profile_import.add_argument("path", type=Path, metavar="PATH") + _add_identity_argument(profile_import) + profile_reset = profile_commands.add_parser( + "reset", help="reset one or all profiles" + ) + profile_reset.add_argument( + "profile_index", + type=lambda value: None if value == "all" else _profile_number(value), + metavar="PROFILE|all", + ) + _add_identity_argument(profile_reset) + profile_reset.add_argument("--yes", action="store_true") + profile_activate = profile_commands.add_parser( + "activate", help="activate a profile" + ) + profile_activate.add_argument( + "profile_index", type=_profile_number, metavar="PROFILE" + ) + _add_identity_argument(profile_activate) + pairings = commands.add_parser("pairings", help="list or clear pairings") pairing_commands = pairings.add_subparsers( dest="pairing_command", required=True @@ -528,14 +1869,32 @@ def main(argv: Sequence[str] | None = None) -> int: if args.timeout <= 0: print("error: --timeout must be positive", file=sys.stderr) return 2 - if args.command == "config" and args.config_command == "reset" and not args.yes: + if ( + args.command == "config" + and args.config_command == "reset" + and not args.yes + ): print("error: config reset requires --yes", file=sys.stderr) return 2 - if args.command == "pairings" and args.pairing_command == "clear" and not args.yes: + if ( + args.command == "pairings" + and args.pairing_command == "clear" + and not args.yes + ): print("error: pairings clear requires --yes", file=sys.stderr) return 2 + if ( + args.command == "profiles" + and args.profile_command == "reset" + and not args.yes + ): + print("error: profiles reset requires --yes", file=sys.stderr) + return 2 + imported_profile: ControllerProfile | None = None try: + if args.command == "profiles" and args.profile_command == "import": + imported_profile = _load_profile(args.path) device = find_pico(args.bus, args.address, args.timeout) if args.command == "status": info = read_info(device) @@ -581,6 +1940,53 @@ def main(argv: Sequence[str] | None = None) -> int: "Reset configuration at generation " f"{status.stored_generation}." ) + elif args.command == "profiles": + entries = list_profiles(device) + if args.profile_command == "list": + _print_profiles(entries) + else: + identity = _resolve_profile_identity(entries, args.identity) + if args.profile_command == "export": + profile = read_profile( + device, identity, args.profile_index + ) + _save_profile(args.path, profile) + print( + f"Exported profile {args.profile_index + 1} " + f"for identity {args.identity} to {args.path}." + ) + elif args.profile_command == "import": + status = write_profile( + device, + identity, + args.profile_index, + cast(ControllerProfile, imported_profile), + args.timeout, + ) + print( + f"Stored profile {args.profile_index + 1} " + f"for identity {args.identity} at generation " + f"{status.stored_generation} " + f"(CRC {status.stored_crc:08x})." + ) + elif args.profile_command == "reset": + reset_profile( + device, identity, args.profile_index, args.timeout + ) + target = ( + "all profiles" + if args.profile_index is None + else f"profile {args.profile_index + 1}" + ) + print(f"Reset {target} for identity {args.identity}.") + else: + activate_profile( + device, identity, args.profile_index, args.timeout + ) + print( + f"Activated profile {args.profile_index + 1} " + f"for identity {args.identity}." + ) elif args.pairing_command == "list": _print_pairings(refresh_pairings(device, args.timeout)) else: diff --git a/switch-pico.cpp b/switch-pico.cpp index 361021f..d315996 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -7,6 +7,7 @@ #include "hardware/uart.h" #else #include "bluepad32_input_backend.h" +#include "controller_profile_runtime.h" #include "bootsel_pairing_button.h" #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY #include "adapter_host_probe.h" @@ -33,6 +34,10 @@ #ifdef SWITCH_PICO_BLUEPAD32 static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == BLUEPAD32_INPUT_BACKEND_SLOT_COUNT); +static_assert(CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT == + BLUEPAD32_INPUT_BACKEND_SLOT_COUNT); +static_assert(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); static bool g_last_ready[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; static ControllerState g_user_states[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; @@ -80,7 +85,11 @@ static void on_rumble_from_switch(uint8_t instance, if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { return; } - bluepad32_input_backend_queue_rumble(instance, rumble); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(instance, &snapshot); + bluepad32_input_backend_queue_rumble( + instance, controller_profile_runtime_scale_host_rumble( + instance, snapshot, rumble)); #else if (instance != SWITCH_HID_INSTANCE) { return; @@ -217,6 +226,7 @@ int main() { #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_init(); + controller_profile_runtime_reset(); #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY adapter_host_probe_init(); #endif @@ -241,13 +251,19 @@ int main() { switch_pro_set_rumble_callback(instance, on_rumble_from_switch); g_user_states[instance] = neutral_input(); - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); } #else switch_pro_init(instance); switch_pro_set_rumble_callback(instance, on_rumble_from_switch); g_user_states[instance] = neutral_input(); - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); #endif } #else @@ -255,7 +271,9 @@ int main() { switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE, on_rumble_from_switch); g_user_state = neutral_input(); - switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state); + switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); #endif #ifdef SWITCH_PICO_BLUEPAD32 @@ -288,25 +306,62 @@ 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) { - bluepad32_input_backend_snapshot(instance, - &g_user_states[instance]); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(instance, &snapshot); + const ControllerProfileTransformResult transformed = + controller_profile_runtime_transform( + instance, snapshot, now_ms, output_mode); + ControllerProfileRuntimeProfileChangeEvent + initial_profile_indication{}; + if (controller_profile_runtime_take_initial_profile_indication( + instance, &initial_profile_indication)) { + bluepad32_input_backend_queue_profile_feedback( + instance, + initial_profile_indication.connection_generation, + initial_profile_indication.active_profile_number, + initial_profile_indication.policy); + } else { + ControllerProfileRuntimeProfileChangeEvent + profile_change{}; + if (controller_profile_runtime_take_profile_change( + instance, &profile_change)) { + bluepad32_input_backend_queue_profile_feedback( + instance, profile_change.connection_generation, + profile_change.active_profile_number, + profile_change.policy); + } + } + 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); } else { - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + transformed.left_trigger_digital_threshold, + transformed.right_trigger_digital_threshold); sent = switch_pro_task(instance); } if (sent) { bluepad32_input_backend_report_sent(instance); } #else - switch_pro_set_input(instance, g_user_states[instance]); + switch_pro_set_input( + instance, g_user_states[instance], + transformed.left_trigger_digital_threshold, + transformed.right_trigger_digital_threshold); if (switch_pro_task(instance)) { bluepad32_input_backend_report_sent(instance); } @@ -316,7 +371,9 @@ int main() { bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; ControllerState state = g_user_state; - switch_pro_set_input(SWITCH_HID_INSTANCE, state); + switch_pro_set_input(SWITCH_HID_INSTANCE, state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); (void)switch_pro_task(SWITCH_HID_INSTANCE); #endif log_usb_state(); diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index 631ad2b..fe8a1f2 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -43,6 +43,10 @@ struct MotionQuaternion { struct SwitchProContext { ControllerState input_state{}; + uint16_t left_trigger_threshold = + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + uint16_t right_trigger_threshold = + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; uint8_t report_buffer[SWITCH_PRO_ENDPOINT_SIZE]{}; SwitchProReport switch_report{}; uint8_t last_report_counter = 0; @@ -420,6 +424,8 @@ static ControllerState make_neutral_state() { static void reset_context_runtime(SwitchProContext& context, uint32_t now, bool ready_before_mount) { context.input_state = make_neutral_state(); + context.left_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + context.right_trigger_threshold = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; memset(context.report_buffer, 0x00, sizeof(context.report_buffer)); context.switch_report = {}; context.switch_report.reportID = 0x30; @@ -747,7 +753,7 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonRightSL = 0; inputs.buttonR = state.button_right_shoulder; inputs.buttonZR = - state.right_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + state.right_trigger >= context.right_trigger_threshold; inputs.buttonMinus = state.button_select; inputs.buttonPlus = state.button_start; inputs.buttonThumbR = state.button_right_stick; @@ -758,7 +764,7 @@ static void update_switch_report_from_state(SwitchProContext& context) { inputs.buttonLeftSL = 0; inputs.buttonL = state.button_left_shoulder; inputs.buttonZL = - state.left_trigger >= SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; + state.left_trigger >= context.left_trigger_threshold; uint16_t left_x = scale16To12(controller_axis_to_unsigned(state.left_stick_x)); @@ -813,10 +819,14 @@ void switch_pro_init(uint8_t instance) { to_ms_since_boot(get_absolute_time()), true); } -void switch_pro_set_input(uint8_t instance, const ControllerState& state) { +void switch_pro_set_input(uint8_t instance, const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold) { SwitchProContext* context = context_for(instance); if (context != nullptr) { context->input_state = state; + context->left_trigger_threshold = left_trigger_threshold; + context->right_trigger_threshold = right_trigger_threshold; } } diff --git a/switch_pro_driver.h b/switch_pro_driver.h index bb1d9ba..5cc51f5 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -27,8 +27,11 @@ constexpr uint16_t SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD = // Initialize one HID instance before entering the main loop. void switch_pro_init(uint8_t instance); -// Update the desired controller state for one HID instance. -void switch_pro_set_input(uint8_t instance, const ControllerState& state); +// Update the desired controller state and digital trigger thresholds for one +// HID instance. +void switch_pro_set_input(uint8_t instance, const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold); // Drive one Switch Pro USB state machine; returns true only when a regular // 0x30 input report was successfully queued. diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 4f0d280..b380457 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -25,6 +25,7 @@ bool ssp_auto_accept = true; uint8_t accepted_stk_methods = 0xff; uint16_t link_supervision_timeout = 0; btstack_packet_handler_t pairing_event_handler = nullptr; +btstack_packet_handler_t identity_event_handler = nullptr; int confirmation_accepts = 0; int confirmation_rejections = 0; int passkey_accepts = 0; @@ -39,10 +40,15 @@ int ble_bond_count = 0; bool flash_core_init_result = true; int flash_core_init_calls = 0; int core1_launch_calls = 0; +bool expect_configuration_timer_prearmed = false; +uint32_t expected_configuration_timer_add_count = 0; int cyw43_init_calls = 0; int uni_init_calls = 0; int device_disconnect_calls = 0; uni_hid_device_t* last_disconnected_device = nullptr; +uni_hid_device_t* lookup_devices[8]{}; +size_t lookup_device_count = 0; +gap_connection_type_t gap_connection_types[256]{}; struct CoreStopped {}; @@ -82,11 +88,27 @@ uni_hid_device_t device( result.idx = idx; result.gamepad = gamepad; result.conn.protocol = protocol; + result.conn.handle = static_cast(0x40 + idx); result.conn.btaddr[5] = static_cast(idx + 1); + result.vendor_id = static_cast(0x1000 + idx); + result.product_id = static_cast(0x2000 + idx); result.report_parser.play_dual_rumble = play_rumble; + gap_connection_types[result.conn.handle] = + protocol == UNI_BT_CONN_PROTOCOL_BR_EDR + ? GAP_CONNECTION_ACL + : protocol == UNI_BT_CONN_PROTOCOL_BLE + ? GAP_CONNECTION_LE + : GAP_CONNECTION_INVALID; return result; } +void register_lookup_device(uni_hid_device_t* candidate) { + require(lookup_device_count < + sizeof(lookup_devices) / sizeof(lookup_devices[0]), + "test BLE lookup registry overflow"); + lookup_devices[lookup_device_count++] = candidate; +} + } // namespace @@ -98,6 +120,16 @@ int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) { return device == nullptr ? -1 : device->idx; } +uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle( + hci_con_handle_t handle) { + for (size_t index = 0; index < lookup_device_count; ++index) { + if (lookup_devices[index]->conn.handle == handle) { + return lookup_devices[index]; + } + } + return nullptr; +} + void uni_hid_device_disconnect(uni_hid_device_t* device) { ++device_disconnect_calls; last_disconnected_device = device; @@ -147,6 +179,15 @@ void uni_bt_del_keys_unsafe() { ble_bond_count = 0; } +gap_connection_type_t gap_get_connection_type( + hci_con_handle_t connection_handle) { + return connection_handle < + sizeof(gap_connection_types) / + sizeof(gap_connection_types[0]) + ? gap_connection_types[connection_handle] + : GAP_CONNECTION_INVALID; +} + int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) { iterator->index = 0; return 1; @@ -226,6 +267,11 @@ void hci_add_event_handler( pairing_event_handler = callback_handler->callback; } +void sm_add_event_handler( + btstack_packet_callback_registration_t* callback_handler) { + identity_event_handler = callback_handler->callback; +} + uint8_t hci_event_packet_get_type(const uint8_t* packet) { return packet[0]; } @@ -246,6 +292,108 @@ void hci_event_user_passkey_request_get_bd_addr( copy_event_address(packet, address); } +hci_con_handle_t sm_event_handle(const uint8_t* packet) { + return static_cast(packet[2]) | + static_cast(packet[3] << 8); +} + +void copy_sm_event_address(const uint8_t* packet, size_t offset, + bd_addr_t address) { + for (size_t index = 0; index < sizeof(bd_addr_t); ++index) { + address[index] = packet[offset + sizeof(bd_addr_t) - 1 - index]; + } +} + +hci_con_handle_t sm_event_identity_resolving_started_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +hci_con_handle_t sm_event_identity_resolving_failed_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +hci_con_handle_t sm_event_identity_resolving_succeeded_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +uint8_t sm_event_identity_resolving_succeeded_get_addr_type( + const uint8_t* packet) { + return packet[4]; +} + +void sm_event_identity_resolving_succeeded_get_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 5, address); +} + +uint8_t sm_event_identity_resolving_succeeded_get_identity_addr_type( + const uint8_t* packet) { + return packet[11]; +} + +void sm_event_identity_resolving_succeeded_get_identity_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 12, address); +} + +hci_con_handle_t sm_event_identity_created_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +void sm_event_identity_created_get_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 5, address); +} + +uint8_t sm_event_identity_created_get_identity_addr_type( + const uint8_t* packet) { + return packet[11]; +} + +void sm_event_identity_created_get_identity_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 12, address); +} + +hci_con_handle_t sm_event_reencryption_started_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +uint8_t sm_event_reencryption_started_get_addr_type( + const uint8_t* packet) { + return packet[4]; +} + +void sm_event_reencryption_started_get_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 5, address); +} + +hci_con_handle_t sm_event_reencryption_complete_get_handle( + const uint8_t* packet) { + return sm_event_handle(packet); +} + +uint8_t sm_event_reencryption_complete_get_addr_type( + const uint8_t* packet) { + return packet[4]; +} + +void sm_event_reencryption_complete_get_address( + const uint8_t* packet, bd_addr_t address) { + copy_sm_event_address(packet, 5, address); +} + +uint8_t sm_event_reencryption_complete_get_status( + const uint8_t* packet) { + return packet[11]; +} + void uni_platform_set_custom(uni_platform* platform) { installed_platform = platform; @@ -284,10 +432,38 @@ uint32_t btstack_run_loop_get_time_ms() { } +#include "../controller_identity.cpp" #include "../bluepad32_input_backend.cpp" +ControllerIdentity observed_profile_identities[8]{}; +size_t observed_profile_identity_count = 0; void configuration_service_prepare() {} void configuration_service_initialize_on_storage_core() {} -void configuration_service_task_on_storage_core(uint32_t) {} +void configuration_service_task_on_storage_core(uint32_t) { + if (expect_configuration_timer_prearmed) { + require(g_configuration_timer.add_count == + expected_configuration_timer_add_count, + "configuration work ran before its timer was rearmed"); + } +} +void profile_service_prepare() {} +void profile_service_initialize_on_storage_core() {} +void profile_service_task_on_storage_core(uint32_t) { + if (expect_configuration_timer_prearmed) { + require(g_configuration_timer.add_count == + expected_configuration_timer_add_count, + "profile work ran before its timer was rearmed"); + } +} +bool profile_service_observe_identity_on_storage_core( + const ControllerIdentity& identity) { + require(observed_profile_identity_count < + sizeof(observed_profile_identities) / + sizeof(observed_profile_identities[0]), + "profile identity observation fixture overflow"); + observed_profile_identities[observed_profile_identity_count++] = + identity; + return true; +} void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { *output = {}; output->state = ConfigurationServiceState::kReady; @@ -319,6 +495,15 @@ SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance) { namespace { +bool read_controller_state(uint8_t slot, ControllerState* output) { + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(slot, &snapshot); + if (output != nullptr) { + *output = snapshot.state; + } + return snapshot.active; +} + void start_backend() { bluepad32_input_backend_init(); platform_on_init_complete(); @@ -327,8 +512,9 @@ void start_backend() { link_supervision_timeout == kClassicLinkSupervisionTimeout && !bondable && accepted_stk_methods == 0 && - !ssp_auto_accept && pairing_event_handler != nullptr, - "initialization must configure liveness and pairing policy"); + !ssp_auto_accept && pairing_event_handler != nullptr && + identity_event_handler != nullptr, + "initialization must register Classic and BLE identity policy"); } void start_pairing_backend() { start_backend(); @@ -344,6 +530,114 @@ void dispatch_pairing_event(uint8_t event_type) { pairing_event_handler(HCI_EVENT_PACKET, 0, packet, sizeof(packet)); } +void write_event_address(uint8_t* packet, size_t offset, + const bd_addr_t address) { + for (size_t index = 0; index < sizeof(bd_addr_t); ++index) { + packet[offset + index] = + address[sizeof(bd_addr_t) - 1 - index]; + } +} + +void dispatch_identity_event(uint8_t event_type, + const uni_hid_device_t& controller, + uint8_t identity_address_type, + const bd_addr_t identity_address, + uint8_t status = ERROR_CODE_SUCCESS) { + uint8_t packet[20]{}; + size_t packet_size = 0; + packet[0] = event_type; + packet[2] = static_cast(controller.conn.handle); + packet[3] = static_cast(controller.conn.handle >> 8); + switch (event_type) { + case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED: + packet_size = sizeof(packet); + packet[4] = BD_ADDR_TYPE_LE_RANDOM; + write_event_address(packet, 5, controller.conn.btaddr); + packet[11] = identity_address_type; + write_event_address(packet, 12, identity_address); + break; + case SM_EVENT_IDENTITY_CREATED: + packet_size = sizeof(packet); + packet[4] = identity_address_type; + write_event_address(packet, 5, identity_address); + packet[11] = identity_address_type; + write_event_address(packet, 12, identity_address); + break; + case SM_EVENT_REENCRYPTION_STARTED: + packet_size = 11; + packet[4] = identity_address_type; + write_event_address(packet, 5, identity_address); + break; + case SM_EVENT_REENCRYPTION_COMPLETE: + packet_size = 12; + packet[4] = identity_address_type; + write_event_address(packet, 5, identity_address); + packet[11] = status; + break; + default: + require(false, "unsupported identity event fixture"); + } + packet[1] = static_cast(packet_size - 2); + identity_event_handler( + HCI_EVENT_PACKET, 0, packet, + static_cast(packet_size)); +} + +void require_identity(const ControllerIdentity& actual, bool stable, + ControllerTransport transport, uint8_t address_type, + const bd_addr_t address, uint16_t vendor_id, + uint16_t product_id, const char* message) { + ControllerIdentity expected{}; + expected.stable = stable; + expected.transport = transport; + expected.address_type = address_type; + memcpy(expected.address, address, sizeof(expected.address)); + expected.vendor_id = vendor_id; + expected.product_id = product_id; + require(controller_identity_equal(actual, expected), message); +} + +void test_identity_encoding_contract() { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kBle; + identity.address_type = BD_ADDR_TYPE_LE_RANDOM_IDENTITY; + const bd_addr_t address = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15}; + memcpy(identity.address, address, sizeof(address)); + identity.vendor_id = 0x1234; + identity.product_id = 0xabcd; + + uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; + const uint8_t expected[CONTROLLER_IDENTITY_ENCODED_SIZE] = { + 1, 2, 3, 0, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x34, 0x12, 0xcd, 0xab}; + require(controller_identity_encode(identity, encoded, sizeof(encoded)) && + memcmp(encoded, expected, sizeof(expected)) == 0, + "controller identity wire encoding changed"); + + ControllerIdentity decoded{}; + require(controller_identity_decode(encoded, sizeof(encoded), &decoded) && + controller_identity_equal(identity, decoded), + "controller identity wire round trip failed"); + decoded.product_id ^= 1; + require(!controller_identity_equal(identity, decoded), + "controller identity equality must include every field"); + encoded[3] = 1; + require(!controller_identity_decode(encoded, sizeof(encoded), &decoded), + "controller identity decoder must reject a nonzero reserved byte"); + + const ControllerIdentity global = controller_identity_global(); + memset(encoded, 0xff, sizeof(encoded)); + require(controller_identity_is_global(global) && + controller_identity_encode(global, encoded, + sizeof(encoded)), + "global identity helper must produce an encodable fallback"); + for (uint8_t byte : encoded) { + require(byte == 0, + "global fallback identity must encode as all zeroes"); + } +} + @@ -376,7 +670,7 @@ void test_ready_order(bool reverse) { for (int ready = 0; ready <= position; ++ready) { expected_active = expected_active || order[ready] == candidate; } - require(bluepad32_input_backend_snapshot(candidate, &snapshot) == + require(read_controller_state(candidate, &snapshot) == expected_active, "only ready indexed slots may become active"); } @@ -403,7 +697,7 @@ void test_ready_order(bool reverse) { for (int candidate = 0; candidate < kSlotCount; ++candidate) { ControllerState snapshot{}; - require(bluepad32_input_backend_snapshot(candidate, &snapshot) == + require(read_controller_state(candidate, &snapshot) == (candidate != slot), "disconnect must preserve every surviving slot"); } @@ -444,7 +738,7 @@ void test_rejections() { collision_data.gamepad.buttons = BUTTON_B; platform_on_controller_data(&collision, &collision_data); ControllerState snapshot{}; - require(bluepad32_input_backend_snapshot(0, &snapshot), + require(read_controller_state(0, &snapshot), "occupied slot must stay active"); require(!snapshot.button_east, "mismatched device input must not enter the occupied slot"); @@ -453,13 +747,13 @@ void test_rejections() { slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; slot_zero_data.gamepad.accel[0] = 8192; platform_on_controller_data(&slot_zero, &slot_zero_data); - require(bluepad32_input_backend_snapshot(0, &snapshot) && + require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == 3, "valid slot input must remain observable"); - require(!bluepad32_input_backend_snapshot(4, &snapshot), + require(!read_controller_state(4, &snapshot), "public snapshot must reject slot 4"); bluepad32_input_backend_report_sent(4); - require(bluepad32_input_backend_snapshot(0, &snapshot) && + require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == 3, "slot 4 acknowledgement must not consume slot 0 IMU"); bluepad32_input_backend_queue_rumble(4, ControllerRumbleOutput{1, 2}); @@ -469,8 +763,22 @@ void test_rejections() { } void test_independent_lifecycle() { + test_identity_encoding_contract(); start_pairing_backend(); + uni_hid_device_t invalid_transport = + device(6, true, UNI_BT_CONN_PROTOCOL_BLE); + invalid_transport.conn.handle = 0xffff; + require(controller_identity_is_global( + identity_for_device(&invalid_transport)), + "invalid GAP handles must remain on the global identity"); + uni_hid_device_t sco_transport = + device(7, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + gap_connection_types[sco_transport.conn.handle] = GAP_CONNECTION_SCO; + require(controller_identity_is_global( + identity_for_device(&sco_transport)), + "SCO links must remain on the global identity"); + uni_hid_device_t aborted = device(0); const uint32_t aborted_generation = g_slots[0].connection_generation; platform_on_device_connected(&aborted); @@ -500,7 +808,56 @@ void test_independent_lifecycle() { "pre-ready disconnect must restart Classic and BLE scans"); uni_hid_device_t devices[kSlotCount] = { - device(0), device(1), device(2), device(3)}; + device(0, true, UNI_BT_CONN_PROTOCOL_NONE), + device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR), + device(2, true, UNI_BT_CONN_PROTOCOL_NONE), + device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR)}; + gap_connection_types[devices[0].conn.handle] = GAP_CONNECTION_ACL; + gap_connection_types[devices[1].conn.handle] = GAP_CONNECTION_LE; + gap_connection_types[devices[2].conn.handle] = GAP_CONNECTION_LE; + gap_connection_types[devices[3].conn.handle] = GAP_CONNECTION_LE; + const bd_addr_t classic_address = + {0x10, 0x11, 0x12, 0x13, 0x14, 0x15}; + const bd_addr_t resolved_connection_address = + {0x41, 0x21, 0x22, 0x23, 0x24, 0x25}; + const bd_addr_t created_connection_address = + {0x42, 0x31, 0x32, 0x33, 0x34, 0x35}; + const bd_addr_t reencrypted_connection_address = + {0x43, 0x41, 0x42, 0x43, 0x44, 0x45}; + memcpy(devices[0].conn.btaddr, classic_address, + sizeof(classic_address)); + memcpy(devices[1].conn.btaddr, resolved_connection_address, + sizeof(resolved_connection_address)); + memcpy(devices[2].conn.btaddr, created_connection_address, + sizeof(created_connection_address)); + memcpy(devices[3].conn.btaddr, reencrypted_connection_address, + sizeof(reencrypted_connection_address)); + require( + devices[0].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE && + gap_get_connection_type(devices[0].conn.handle) == + GAP_CONNECTION_ACL && + devices[1].conn.protocol == UNI_BT_CONN_PROTOCOL_BR_EDR && + gap_get_connection_type(devices[1].conn.handle) == + GAP_CONNECTION_LE && + devices[2].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE && + gap_get_connection_type(devices[2].conn.handle) == + GAP_CONNECTION_LE, + "identity fixtures must expose authoritative GAP transports over " + "missing or stale cached protocols"); + const bd_addr_t resolved_address = + {0x20, 0x21, 0x22, 0x23, 0x24, 0x25}; + const bd_addr_t reencrypted_address = + {0x30, 0x31, 0x32, 0x33, 0x34, 0x35}; + dispatch_identity_event( + SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, devices[1], + BD_ADDR_TYPE_LE_PUBLIC, resolved_address); + register_lookup_device(&devices[3]); + dispatch_identity_event( + SM_EVENT_REENCRYPTION_STARTED, devices[3], + BD_ADDR_TYPE_LE_RANDOM, reencrypted_address); + dispatch_identity_event( + SM_EVENT_REENCRYPTION_COMPLETE, devices[3], + BD_ADDR_TYPE_LE_RANDOM, reencrypted_address); for (int slot = 0; slot < kSlotCount; ++slot) { platform_on_device_connected(&devices[slot]); require(g_slots[slot].device == &devices[slot] && @@ -539,6 +896,85 @@ void test_independent_lifecycle() { !scanning_enabled && !incoming_connections, "four ready lifecycle devices must stop connection policy"); + Bluepad32SlotSnapshot lifecycle_snapshots[kSlotCount]{}; + uint32_t baseline_connection_generations[kSlotCount]{}; + for (int slot = 0; slot < kSlotCount; ++slot) { + bluepad32_input_backend_snapshot( + static_cast(slot), &lifecycle_snapshots[slot]); + require(lifecycle_snapshots[slot].active, + "ready slot snapshot must publish active state"); + baseline_connection_generations[slot] = + lifecycle_snapshots[slot].connection_generation; + } + require_identity( + lifecycle_snapshots[0].identity, true, + ControllerTransport::kClassic, BD_ADDR_TYPE_UNKNOWN, + devices[0].conn.btaddr, devices[0].vendor_id, + devices[0].product_id, + "Classic snapshot identity must use the connected device address"); + require_identity( + lifecycle_snapshots[1].identity, true, + ControllerTransport::kBle, BD_ADDR_TYPE_LE_PUBLIC, + resolved_address, devices[1].vendor_id, devices[1].product_id, + "resolved BLE snapshot must use the stable identity address"); + require(controller_identity_is_global( + lifecycle_snapshots[2].identity), + "unresolved BLE snapshot must use the global unstable identity"); + require_identity( + lifecycle_snapshots[3].identity, true, + ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM, + reencrypted_address, devices[3].vendor_id, + devices[3].product_id, + "reencrypted BLE snapshot must use the bonded identity address"); + require(observed_profile_identity_count == 3 && + controller_identity_equal( + observed_profile_identities[0], + lifecycle_snapshots[1].identity) && + controller_identity_equal( + observed_profile_identities[1], + lifecycle_snapshots[3].identity) && + controller_identity_equal( + observed_profile_identities[2], + lifecycle_snapshots[0].identity), + "only stable ready identities must be enrolled for profiles"); + size_t classic_identity_observations = 0; + for (size_t index = 0; index < observed_profile_identity_count; + ++index) { + if (controller_identity_equal( + observed_profile_identities[index], + lifecycle_snapshots[0].identity)) { + ++classic_identity_observations; + } + } + require(classic_identity_observations == 1, + "active GAP ACL with no cached protocol must enroll its stable " + "Classic identity exactly once"); + require(baseline_connection_generations[0] == + first_pending_generation + 1 && + baseline_connection_generations[1] == + baseline_connection_generations[2] && + baseline_connection_generations[2] == + baseline_connection_generations[3], + "connection generations must isolate each slot lifecycle"); + + const bd_addr_t created_address = + {0x40, 0x41, 0x42, 0x43, 0x44, 0x45}; + register_lookup_device(&devices[2]); + dispatch_identity_event( + SM_EVENT_IDENTITY_CREATED, devices[2], + BD_ADDR_TYPE_LE_RANDOM, created_address); + bluepad32_input_backend_snapshot(2, &lifecycle_snapshots[2]); + require_identity( + lifecycle_snapshots[2].identity, true, + ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM, + created_address, devices[2].vendor_id, devices[2].product_id, + "new BLE identity event must update an active slot snapshot"); + require(observed_profile_identity_count == 4 && + controller_identity_equal( + observed_profile_identities[3], + lifecycle_snapshots[2].identity), + "late BLE identity creation must enroll the stable identity"); + const uint32_t buttons[kSlotCount] = { BUTTON_B, BUTTON_A, BUTTON_X, BUTTON_Y}; uni_controller_t data[kSlotCount]{}; @@ -552,7 +988,7 @@ void test_independent_lifecycle() { ControllerState states[kSlotCount]{}; for (int slot = 0; slot < kSlotCount; ++slot) { - require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + require(read_controller_state(slot, &states[slot]) && states[slot].motion_sample_count == 3, "every slot must expose independent input and IMU"); } @@ -571,13 +1007,13 @@ void test_independent_lifecycle() { bluepad32_input_backend_report_sent(3); for (int slot = 0; slot < kSlotCount; ++slot) { - require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + require(read_controller_state(slot, &states[slot]) && states[slot].motion_sample_count == (slot == 3 ? 0 : 3), "slot 3 acknowledgement must not consume slots 0-2 IMU"); } for (int slot = 0; slot < 3; ++slot) { bluepad32_input_backend_report_sent(slot); - require(bluepad32_input_backend_snapshot(slot, &states[slot]) && + require(read_controller_state(slot, &states[slot]) && states[slot].motion_sample_count == 0, "each slot acknowledgement must consume only its own IMU"); } @@ -605,24 +1041,43 @@ void test_independent_lifecycle() { bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66}); const uint32_t disconnected_generation = - g_slots[3].connection_generation; + baseline_connection_generations[3]; const int starts_before_slot_three_disconnect = scan_starts; platform_on_device_disconnected(&devices[3]); require(scan_starts == starts_before_slot_three_disconnect + 1 && scanning_enabled && incoming_connections, "slot 3 disconnect must resume scanning and incoming connections"); - require(!bluepad32_input_backend_snapshot(3, &states[3]) && + require(!read_controller_state(3, &states[3]) && !states[3].button_north && states[3].left_stick_x == 0, "slot 3 disconnect must publish protocol-neutral state"); - require(bluepad32_input_backend_snapshot(0, &states[0]) && + bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]); + require(!lifecycle_snapshots[3].active && + lifecycle_snapshots[3].connection_generation == + disconnected_generation + 1 && + controller_identity_is_global( + lifecycle_snapshots[3].identity) && + !lifecycle_snapshots[3].state.button_north && + lifecycle_snapshots[3].state.left_stick_x == 0, + "disconnect snapshot must atomically publish neutral state, " + "cleared identity, and a new connection generation"); + for (int survivor = 0; survivor < 3; ++survivor) { + bluepad32_input_backend_snapshot( + static_cast(survivor), + &lifecycle_snapshots[survivor]); + require(lifecycle_snapshots[survivor].active && + lifecycle_snapshots[survivor].connection_generation == + baseline_connection_generations[survivor], + "disconnect generation must not leak into surviving slots"); + } + require(read_controller_state(0, &states[0]) && states[0].button_east && - bluepad32_input_backend_snapshot(1, &states[1]) && + read_controller_state(1, &states[1]) && states[1].button_south && - bluepad32_input_backend_snapshot(2, &states[2]) && + read_controller_state(2, &states[2]) && states[2].button_west, "slot 3 disconnect must preserve slots 0-2"); platform_on_controller_data(&devices[0], &data[0]); - require(bluepad32_input_backend_snapshot(0, &states[0]) && + require(read_controller_state(0, &states[0]) && states[0].button_east, "slot 0 input must continue while slot 3 is disconnected"); const int slot_zero_calls_while_scanning = devices[0].rumble_calls; @@ -633,16 +1088,39 @@ void test_independent_lifecycle() { devices[0].last_high == 116, "slot 0 rumble must continue while slot 3 is disconnected"); - uni_hid_device_t slot_three_replacement = device(3); + uni_hid_device_t slot_three_replacement = + device(3, true, UNI_BT_CONN_PROTOCOL_BLE); + memcpy(slot_three_replacement.conn.btaddr, + devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr)); + require( + slot_three_replacement.conn.handle == devices[3].conn.handle && + gap_get_connection_type(slot_three_replacement.conn.handle) == + GAP_CONNECTION_LE && + memcmp(slot_three_replacement.conn.btaddr, + devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr)) == + 0, + "replacement isolation fixture must reuse the active BLE handle " + "and address"); require(platform_on_device_ready(&slot_three_replacement) == UNI_ERROR_SUCCESS, "slot 3 replacement must bind to the freed indexed slot"); process_rumble_timer(&g_rumble_timer); require(slot_three_replacement.rumble_calls == 0, "slot 3 replacement must not receive disconnected device rumble"); + bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]); + require(lifecycle_snapshots[3].active && + lifecycle_snapshots[3].connection_generation == + disconnected_generation + 1 && + controller_identity_is_global( + lifecycle_snapshots[3].identity), + "replacement must keep the new generation and cannot inherit " + "the disconnected BLE identity"); + require(observed_profile_identity_count == 4, + "unstable replacement must not be enrolled for profiles"); g_slots[3].pending_rumble = { - 3, disconnected_generation, ControllerRumbleOutput{77, 88}}; + 3, disconnected_generation, ControllerRumbleOutput{77, 88}, + kXInputHostRumbleDurationMs}; g_slots[3].rumble_pending = true; process_rumble_timer(&g_rumble_timer); require(slot_three_replacement.rumble_calls == 0, @@ -653,14 +1131,14 @@ void test_independent_lifecycle() { replacement_data.gamepad.buttons = BUTTON_Y; replacement_data.gamepad.accel[0] = 9000; platform_on_controller_data(&slot_three_replacement, &replacement_data); - require(bluepad32_input_backend_snapshot(3, &states[3]) && + require(read_controller_state(3, &states[3]) && states[3].button_north && states[3].motion_sample_count == 3, "replacement input and IMU must populate only slot 3"); - require(bluepad32_input_backend_snapshot(0, &states[0]) && + require(read_controller_state(0, &states[0]) && states[0].button_east && - bluepad32_input_backend_snapshot(1, &states[1]) && + read_controller_state(1, &states[1]) && states[1].button_south && - bluepad32_input_backend_snapshot(2, &states[2]) && + read_controller_state(2, &states[2]) && states[2].button_west, "slot 3 replacement must not disturb slots 0-2"); @@ -706,15 +1184,14 @@ void test_independent_lifecycle() { require(scan_starts == starts_before_disconnect + 1 && scanning_enabled && incoming_connections, "disconnecting slots 0-2 must resume connection policy"); - require(!bluepad32_input_backend_snapshot(slot, &states[slot]) && + require(!read_controller_state(slot, &states[slot]) && states[slot].left_stick_x == 0, "disconnect must publish protocol-neutral state"); for (int survivor = 0; survivor < kSlotCount; ++survivor) { if (survivor == slot) { continue; } - require(bluepad32_input_backend_snapshot(survivor, - &states[survivor]), + require(read_controller_state(survivor, &states[survivor]), "disconnect must preserve all three survivors"); } require(platform_on_device_ready(&replacements[slot]) == @@ -877,102 +1354,585 @@ void test_slot_lighting() { "controller without RGB support did not receive its slot LED"); } -void require_south_button_mapping(const ControllerState& state, - bool swapped, - const char* message) { - require(state.button_east == swapped && state.button_south == !swapped && - !state.button_north && !state.button_west, - message); -} - -void test_abxy_hotkey() { +void test_profile_chord_remains_raw() { start_pairing_backend(); - uni_hid_device_t slot_zero = device(0); - uni_hid_device_t slot_one = device(1); - require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS && - platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS, - "ABXY test controllers did not become ready"); + uni_hid_device_t controller = device(0); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "raw profile chord controller did not become ready"); uni_controller_t input{}; input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - input.gamepad.buttons = BUTTON_A; - platform_on_controller_data(&slot_zero, &input); - ControllerState snapshot{}; - require(bluepad32_input_backend_snapshot(0, &snapshot), - "slot 0 ABXY state was not published"); - require_south_button_mapping( - snapshot, kDefaultSwapAbxy, - "slot 0 did not start in the configured ABXY layout"); - input.gamepad.buttons = - kAbxyHotkeyButtonMask | BUTTON_A; - input.gamepad.misc_buttons = kAbxyHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - require(bluepad32_input_backend_snapshot(0, &snapshot), - "toggled slot 0 state was not published"); - require_south_button_mapping( - snapshot, !kDefaultSwapAbxy, - "hotkey did not toggle slot 0 ABXY mapping"); - require(!snapshot.button_left_shoulder && !snapshot.button_right_shoulder && - !snapshot.button_select && !snapshot.button_start, - "hotkey chord leaked into the Switch report"); - - bluepad32_input_backend_queue_rumble( - 0, ControllerRumbleOutput{0x11, 0x22}); + BUTTON_A | BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + input.gamepad.misc_buttons = + MISC_BUTTON_SELECT | MISC_BUTTON_START; + platform_on_controller_data(&controller, &input); + ControllerState snapshot{}; + require(read_controller_state(0, &snapshot) && + snapshot.button_south && !snapshot.button_east && + snapshot.button_left_shoulder && + snapshot.button_right_shoulder && + snapshot.button_select && snapshot.button_start, + "Core 1 suppressed the profile chord or mutated ABXY mapping"); process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 1 && - slot_zero.last_high == kAbxyFeedbackWeakMagnitude && - slot_zero.last_low == kAbxyFeedbackStrongMagnitude && - slot_zero.last_high == UINT8_MAX && - slot_zero.last_low == UINT8_MAX && - g_slots[0].rumble_pending, - "ABXY confirmation was not full-strength or did not take priority"); - - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].swap_abxy == !kDefaultSwapAbxy && - slot_zero.rumble_calls == 1, - "held hotkey toggled or rumbled more than once"); - - input.gamepad = {}; - platform_on_controller_data(&slot_zero, &input); - input.gamepad.buttons = kAbxyHotkeyButtonMask | BUTTON_A; - input.gamepad.misc_buttons = kAbxyHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].swap_abxy == kDefaultSwapAbxy && - slot_zero.rumble_calls == 2, - "released hotkey did not re-arm for a second toggle"); - - now_ms = kAbxyFeedbackDurationMs - 1; - process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 2 && g_slots[0].rumble_pending, - "host rumble interrupted ABXY confirmation"); - now_ms = kAbxyFeedbackDurationMs; - process_rumble_timer(&g_rumble_timer); - require(slot_zero.rumble_calls == 3 && - slot_zero.last_high == 0x22 && - slot_zero.last_low == 0x11 && - !g_slots[0].rumble_pending, - "deferred host rumble did not resume after confirmation"); - - uni_controller_t peer_input{}; - peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; - peer_input.gamepad.buttons = BUTTON_A; - platform_on_controller_data(&slot_one, &peer_input); - require(bluepad32_input_backend_snapshot(1, &snapshot), - "slot 1 ABXY state was not published"); - require_south_button_mapping( - snapshot, kDefaultSwapAbxy, - "slot 0 hotkey changed slot 1 layout"); - - platform_on_device_disconnected(&slot_zero); - uni_hid_device_t replacement = device(0); - require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && - g_slots[0].swap_abxy == kDefaultSwapAbxy && - !g_slots[0].abxy_hotkey_latched && + require(controller.rumble_calls == 0 && !g_slots[0].feedback_pending, - "disconnect did not reset slot 0 hotkey state"); + "legacy ABXY chord still produced local feedback"); +} + +void test_profile_feedback_scheduler() { + start_pairing_backend(); + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + devices[0].report_parser.set_lightbar_color = set_lightbar; + devices[1].report_parser.set_player_leds = set_player_leds; + devices[2].report_parser.set_lightbar_color = set_lightbar; + devices[3].report_parser.set_player_leds = set_player_leds; + + uint32_t generations[kSlotCount]{}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(platform_on_device_ready(&devices[slot]) == + UNI_ERROR_SUCCESS, + "profile feedback controller did not become ready"); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(slot, &snapshot); + generations[slot] = snapshot.connection_generation; + require(devices[slot].rumble_calls == 0 && + !g_slots[slot].profile_feedback.active && + g_slots[slot].pending_profile_feedback_count == 0, + "initial controller/profile load scheduled confirmation feedback"); + bluepad32_input_backend_queue_profile_feedback( + slot, generations[slot], static_cast(slot + 1u), + ControllerProfileConfirmationPolicy::kRumble); + bluepad32_input_backend_queue_rumble( + slot, ControllerRumbleOutput{ + static_cast(0x10u + slot), + static_cast(0x20u + slot)}); + } + + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 1 && + devices[slot].last_rumble_duration_ms == + kProfileFeedbackPhaseDurationMs && + devices[slot].last_high == UINT8_MAX && + devices[slot].last_low == UINT8_MAX && + g_slots[slot].rumble_pending && + g_slots[slot].profile_feedback.active, + "profile pulse sequence did not start at full strength"); + } + + now_ms = 74; + process_rumble_timer(&g_rumble_timer); + now_ms = 75; + process_rumble_timer(&g_rumble_timer); + for (const uni_hid_device_t& controller : devices) { + require(controller.rumble_calls == 1, + "profile pulse did not retain a 75 ms on phase"); + } + + now_ms = 149; + process_rumble_timer(&g_rumble_timer); + now_ms = 150; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 2 && + devices[0].last_high == 0x20 && + devices[0].last_low == 0x10 && + !g_slots[0].rumble_pending, + "one-pulse confirmation did not defer host rumble through its off phase"); + for (uint8_t slot = 1; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 2 && + devices[slot].last_high == UINT8_MAX && + g_slots[slot].rumble_pending, + "second profile pulse did not start after 75 ms off"); + } + + now_ms = 225; + process_rumble_timer(&g_rumble_timer); + now_ms = 300; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == 3 && + devices[1].last_high == 0x21 && + !g_slots[1].rumble_pending && + devices[2].rumble_calls == 3 && + devices[3].rumble_calls == 3, + "two/three/four-pulse sequences diverged at 300 ms"); + + now_ms = 375; + process_rumble_timer(&g_rumble_timer); + now_ms = 450; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == 4 && + devices[2].last_high == 0x22 && + !g_slots[2].rumble_pending && + devices[3].rumble_calls == 4 && + devices[3].last_high == UINT8_MAX, + "three/four-pulse sequences diverged at 450 ms"); + + now_ms = 525; + process_rumble_timer(&g_rumble_timer); + now_ms = 600; + process_rumble_timer(&g_rumble_timer); + require(devices[3].rumble_calls == 5 && + devices[3].last_high == 0x23 && + !g_slots[3].rumble_pending && + devices[0].lightbar_calls == 1 && + devices[1].player_led_calls == 1 && + devices[2].lightbar_calls == 1 && + devices[3].player_led_calls == 1, + "four-pulse confirmation did not release host rumble at 600 ms"); + + const int slot_zero_lightbar_calls = devices[0].lightbar_calls; + const int slot_one_player_led_calls_before_slot_zero = + devices[1].player_led_calls; + const int slot_two_lightbar_calls_before_slot_zero = + devices[2].lightbar_calls; + const int slot_three_player_led_calls_before_slot_zero = + devices[3].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kNone); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 700; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 3 && + devices[0].last_high == 0x41 && + devices[0].lightbar_calls == + slot_zero_lightbar_calls && + !g_slots[0].profile_feedback.active, + "none policy scheduled profile rumble or lighting"); + + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kLed); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x32, 0x42}); + now_ms = 800; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 3 && + devices[0].lightbar_calls == + slot_zero_lightbar_calls + 1 && + devices[0].lightbar_red == + kProfileLightbarPalette[1].red && + devices[0].lightbar_green == + kProfileLightbarPalette[1].green && + devices[0].lightbar_blue == + kProfileLightbarPalette[1].blue && + observed_status_led_on && + g_slots[0].rumble_pending, + "LED policy did not set transient profile color and first " + "onboard blink"); + now_ms = 875; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + devices[0].rumble_calls == 3, + "onboard profile blink did not enter its 75 ms off phase"); + now_ms = 950; + process_rumble_timer(&g_rumble_timer); + require(observed_status_led_on, + "second onboard profile blink did not start"); + now_ms = 1025; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + g_slots[0].rumble_pending, + "host rumble interrupted the final onboard off phase"); + now_ms = 1100; + process_rumble_timer(&g_rumble_timer); + const SwitchRgbColor slot_zero_color = + switch_pro_get_slot_light_color(0); + require(devices[0].rumble_calls == 4 && + devices[0].last_high == 0x42 && + !g_slots[0].rumble_pending && + devices[0].lightbar_calls == + slot_zero_lightbar_calls + 2 && + devices[0].lightbar_red == slot_zero_color.red && + devices[0].lightbar_green == slot_zero_color.green && + devices[0].lightbar_blue == slot_zero_color.blue && + devices[1].player_led_calls == + slot_one_player_led_calls_before_slot_zero && + devices[2].lightbar_calls == + slot_two_lightbar_calls_before_slot_zero && + devices[3].player_led_calls == + slot_three_player_led_calls_before_slot_zero, + "LED-only sequence did not restore its slot color in " + "isolation after the final gap"); + + const int slot_one_player_led_calls = + devices[1].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 1, generations[1], 3, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + now_ms = 1200; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == 4 && + devices[1].last_high == UINT8_MAX && + devices[1].player_led_calls == + slot_one_player_led_calls + 1 && + devices[1].player_leds == 0x07 && + observed_status_led_on, + "combined policy did not drive rumble, onboard LED, and player LEDs"); + for (uint32_t deadline = 1275; deadline <= 1650; + deadline += 75) { + now_ms = deadline; + process_rumble_timer(&g_rumble_timer); + } + require(devices[1].rumble_calls == + (host_rumble_duration_ms() == + kXInputHostRumbleDurationMs + ? 7 + : 6) && + !g_slots[1].profile_feedback.active && + devices[1].player_led_calls == + slot_one_player_led_calls + 2 && + devices[1].player_leds == (1u << 1u), + "combined three-pulse sequence did not terminate and " + "restore slot player lighting"); + + const int old_rumble_calls = devices[2].rumble_calls; + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 1, + ControllerProfileConfirmationPolicy::kLed); + require(g_slots[2].pending_profile_feedback_count == 2, + "two queued profile events did not fill the bounded FIFO"); + platform_on_device_disconnected(&devices[2]); + uni_hid_device_t replacement = device(2); + replacement.report_parser.set_lightbar_color = set_lightbar; + require(platform_on_device_ready(&replacement) == + UNI_ERROR_SUCCESS, + "replacement feedback controller did not become ready"); + now_ms = 1700; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == old_rumble_calls && + replacement.rumble_calls == 0 && + replacement.lightbar_calls == 1 && + !g_slots[2].profile_feedback.active && + g_slots[2].pending_profile_feedback_count == 0, + "slot replacement accepted stale queued profile feedback"); + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + now_ms = 1705; + process_rumble_timer(&g_rumble_timer); + require(replacement.rumble_calls == 0 && + replacement.lightbar_calls == 1, + "stale generation feedback reached a replacement controller"); + + const int slot_three_rumble_calls = devices[3].rumble_calls; + const int slot_three_player_led_calls = + devices[3].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 3, generations[3], 1, + ControllerProfileConfirmationPolicy::kLed); + now_ms = 1800; + process_rumble_timer(&g_rumble_timer); + require(observed_status_led_on && + g_slots[3].profile_feedback.pulses_started == 1 && + devices[3].player_led_calls == + slot_three_player_led_calls + 1 && + devices[3].player_leds == 0x01, + "one-blink LED profile indication did not start"); + now_ms = 1875; + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on, + "one-blink LED profile indication did not turn off"); + now_ms = 1950; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[3].profile_feedback.active && + devices[3].rumble_calls == + slot_three_rumble_calls && + devices[3].player_led_calls == + slot_three_player_led_calls + 2 && + devices[3].player_leds == (1u << 3u), + "one-blink LED-only profile indication did not restore " + "slot lighting cleanly"); + + bluepad32_input_backend_queue_profile_feedback( + 3, generations[3], 4, + ControllerProfileConfirmationPolicy::kLed); + now_ms = 2000; + process_rumble_timer(&g_rumble_timer); + require(devices[3].player_leds == 0x0f, + "profile 4 player count was not shown during its sequence"); + for (uint8_t pulse = 1; pulse <= 4; ++pulse) { + require(observed_status_led_on && + g_slots[3].profile_feedback.on && + g_slots[3].profile_feedback.pulses_started == + pulse, + "four-blink LED sequence missed an on phase"); + now_ms = static_cast( + 2075u + static_cast(pulse - 1u) * 150u); + process_rumble_timer(&g_rumble_timer); + require(!observed_status_led_on && + !g_slots[3].profile_feedback.on, + "four-blink LED sequence missed an off phase"); + if (pulse != 4) { + now_ms += 75; + process_rumble_timer(&g_rumble_timer); + } + } + now_ms = 2600; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[3].profile_feedback.active && + devices[3].rumble_calls == + slot_three_rumble_calls && + devices[3].player_led_calls == + slot_three_player_led_calls + 4 && + devices[3].player_leds == (1u << 3u), + "four-blink LED-only profile indication did not restore " + "slot lighting"); + + Bluepad32SlotSnapshot replacement_snapshot{}; + bluepad32_input_backend_snapshot(2, &replacement_snapshot); + const int replacement_lightbar_calls = replacement.lightbar_calls; + bluepad32_input_backend_queue_profile_feedback( + 2, replacement_snapshot.connection_generation, 1, + ControllerProfileConfirmationPolicy::kLed); + now_ms = 2700; + process_rumble_timer(&g_rumble_timer); + require(replacement.lightbar_calls == + replacement_lightbar_calls + 1, + "current-generation profile lighting was not applied"); + now_ms = 2775; + process_rumble_timer(&g_rumble_timer); + platform_on_device_disconnected(&replacement); + uni_hid_device_t second_replacement = device(2); + second_replacement.report_parser.set_lightbar_color = + set_lightbar; + require(platform_on_device_ready(&second_replacement) == + UNI_ERROR_SUCCESS && + second_replacement.lightbar_calls == 1, + "second replacement did not receive steady slot lighting"); + now_ms = 2850; + process_rumble_timer(&g_rumble_timer); + require(second_replacement.lightbar_calls == 1 && + replacement.lightbar_calls == + replacement_lightbar_calls + 1 && + !g_slots[2].profile_feedback.active, + "stale final-gap restore touched a replacement connection"); + + const int fifo_lightbar_calls = devices[0].lightbar_calls; + const int fifo_rumble_calls = devices[0].rumble_calls; + const int isolated_slot_one_lighting = + devices[1].player_led_calls; + const int isolated_slot_two_lighting = + second_replacement.lightbar_calls; + const int isolated_slot_three_lighting = + devices[3].player_led_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 1, + ControllerProfileConfirmationPolicy::kLed); + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + require(g_slots[0].pending_profile_feedback_count == 2, + "initial and switched profile events were not queued"); + + now_ms = 3000; + process_rumble_timer(&g_rumble_timer); + require(g_slots[0].profile_feedback.active && + g_slots[0].profile_feedback.pulse_count == 1 && + !g_slots[0].profile_feedback.rumble_enabled && + g_slots[0].pending_profile_feedback_count == 1 && + devices[0].rumble_calls == fifo_rumble_calls && + devices[0].lightbar_calls == + fifo_lightbar_calls + 1 && + devices[0].lightbar_red == + kProfileLightbarPalette[0].red, + "LED-only initial event did not run first from the FIFO"); + + now_ms = 3075; + process_rumble_timer(&g_rumble_timer); + now_ms = 3150; + process_rumble_timer(&g_rumble_timer); + require(g_slots[0].profile_feedback.active && + g_slots[0].profile_feedback.pulse_count == 2 && + g_slots[0].profile_feedback.rumble_enabled && + g_slots[0].pending_profile_feedback_count == 0 && + devices[0].rumble_calls == fifo_rumble_calls + 1 && + devices[0].lightbar_calls == + fifo_lightbar_calls + 3 && + devices[0].lightbar_red == + kProfileLightbarPalette[1].red, + "switched profile event did not follow initial indication " + "after its final gap"); + + now_ms = 3225; + process_rumble_timer(&g_rumble_timer); + now_ms = 3300; + process_rumble_timer(&g_rumble_timer); + now_ms = 3375; + process_rumble_timer(&g_rumble_timer); + now_ms = 3450; + process_rumble_timer(&g_rumble_timer); + const SwitchRgbColor final_slot_zero_color = + switch_pro_get_slot_light_color(0); + const bool stateful_host_rumble = + host_rumble_duration_ms() == kXInputHostRumbleDurationMs; + require(!g_slots[0].profile_feedback.active && + devices[0].rumble_calls == + fifo_rumble_calls + + (stateful_host_rumble ? 3 : 2) && + (!stateful_host_rumble || + (devices[0].last_high == 0x42 && + devices[0].last_low == 0x32)) && + devices[0].lightbar_calls == + fifo_lightbar_calls + 4 && + devices[0].lightbar_red == + final_slot_zero_color.red && + devices[0].lightbar_green == + final_slot_zero_color.green && + devices[0].lightbar_blue == + final_slot_zero_color.blue && + devices[1].player_led_calls == + isolated_slot_one_lighting && + second_replacement.lightbar_calls == + isolated_slot_two_lighting && + devices[3].player_led_calls == + isolated_slot_three_lighting, + "ordered profile FIFO did not restore or remain slot-local"); +} + +void test_stateful_host_rumble_restore() { + start_pairing_backend(); + uni_hid_device_t devices[kSlotCount] = { + device(0), device(1), device(2), device(3)}; + uint32_t generations[kSlotCount]{}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(platform_on_device_ready(&devices[slot]) == + UNI_ERROR_SUCCESS, + "rumble restore controller did not become ready"); + Bluepad32SlotSnapshot snapshot{}; + bluepad32_input_backend_snapshot(slot, &snapshot); + generations[slot] = snapshot.connection_generation; + } + +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY + test_adapter_mode = AdapterUsbMode::kXInput; + const ControllerRumbleOutput desired[kSlotCount] = { + {0x11, 0x21}, {0x12, 0x22}, {0x13, 0x23}, {0x14, 0x24}}; + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + bluepad32_input_backend_queue_rumble(slot, desired[slot]); + } + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == 1 && + devices[slot].last_rumble_duration_ms == + kXInputHostRumbleDurationMs && + g_slots[slot].retained_host_rumble_valid, + "initial XInput rumble was not dispatched and retained"); + bluepad32_input_backend_queue_profile_feedback( + slot, generations[slot], static_cast(slot + 1u), + ControllerProfileConfirmationPolicy::kRumble); + } + now_ms = 10; + process_rumble_timer(&g_rumble_timer); + for (now_ms = 85; now_ms <= 610; now_ms += 75) { + process_rumble_timer(&g_rumble_timer); + } + for (uint8_t slot = 0; slot < kSlotCount; ++slot) { + require(devices[slot].rumble_calls == + static_cast(slot + 3u) && + devices[slot].last_low == + desired[slot].low_frequency_magnitude && + devices[slot].last_high == + desired[slot].high_frequency_magnitude && + devices[slot].last_rumble_duration_ms == + kXInputHostRumbleDurationMs && + !g_slots[slot].profile_feedback.active, + "XInput rumble did not resume after its profile pulse count"); + } + + const int stop_calls_before = devices[0].rumble_calls; + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x51, 0x61}); + now_ms = 700; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 2, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 705; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0, 0}); + for (now_ms = 780; now_ms <= 1005; now_ms += 75) { + process_rumble_timer(&g_rumble_timer); + } + require(devices[0].rumble_calls == stop_calls_before + 4 && + devices[0].last_rumble_duration_ms == 0 && + devices[0].last_low == 0 && + devices[0].last_high == 0 && + g_slots[0].retained_host_rumble_valid, + "newest XInput stop did not win during profile pulses"); + + test_adapter_mode = AdapterUsbMode::kSwitchProbe; + const int switch_calls_before = devices[1].rumble_calls; + bluepad32_input_backend_queue_rumble( + 1, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 1100; + process_rumble_timer(&g_rumble_timer); + require(!g_slots[1].retained_host_rumble_valid, + "Switch rumble retained stale XInput desired state"); + bluepad32_input_backend_queue_profile_feedback( + 1, generations[1], 1, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 1105; + process_rumble_timer(&g_rumble_timer); + now_ms = 1180; + process_rumble_timer(&g_rumble_timer); + now_ms = 1255; + process_rumble_timer(&g_rumble_timer); + require(devices[1].rumble_calls == switch_calls_before + 2 && + devices[1].last_rumble_duration_ms == + kProfileFeedbackPhaseDurationMs, + "finite Switch rumble resumed after profile feedback"); + + test_adapter_mode = AdapterUsbMode::kXInput; + bluepad32_input_backend_queue_rumble( + 2, ControllerRumbleOutput{0x71, 0x81}); + now_ms = 1300; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 2, generations[2], 3, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 1305; + process_rumble_timer(&g_rumble_timer); + const int old_device_calls = devices[2].rumble_calls; + platform_on_device_disconnected(&devices[2]); + uni_hid_device_t replacement = device(2); + require(platform_on_device_ready(&replacement) == + UNI_ERROR_SUCCESS, + "rumble restore replacement did not become ready"); + now_ms = 2000; + process_rumble_timer(&g_rumble_timer); + require(devices[2].rumble_calls == old_device_calls && + replacement.rumble_calls == 0 && + !g_slots[2].retained_host_rumble_valid && + !g_slots[2].profile_feedback.active, + "stale XInput rumble resumed on a replacement connection"); +#else + bluepad32_input_backend_queue_rumble( + 0, ControllerRumbleOutput{0x31, 0x41}); + now_ms = 0; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_queue_profile_feedback( + 0, generations[0], 1, + ControllerProfileConfirmationPolicy::kRumble); + now_ms = 5; + process_rumble_timer(&g_rumble_timer); + now_ms = 80; + process_rumble_timer(&g_rumble_timer); + now_ms = 155; + process_rumble_timer(&g_rumble_timer); + require(devices[0].rumble_calls == 2 && + !g_slots[0].retained_host_rumble_valid, + "bounded Switch rumble resumed after profile feedback"); +#endif } void test_motion_hotkey() { @@ -988,7 +1948,7 @@ void test_motion_hotkey() { input.gamepad.accel[0] = 8192; platform_on_controller_data(&slot_zero, &input); ControllerState snapshot{}; - require(bluepad32_input_backend_snapshot(0, &snapshot) && + require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), "slot 0 did not start with configured motion state"); @@ -997,12 +1957,17 @@ void test_motion_hotkey() { input.gamepad.buttons = kMotionHotkeyButtonMask; input.gamepad.misc_buttons = kMotionHotkeyMiscMask; platform_on_controller_data(&slot_zero, &input); - require(bluepad32_input_backend_snapshot(0, &snapshot) && + Bluepad32SlotSnapshot backend_snapshot{}; + bluepad32_input_backend_snapshot(0, &backend_snapshot); + require(read_controller_state(0, &snapshot) && + backend_snapshot.pre_hotkey_button_mask == + kMotionHotkeyLogicalButtonMask && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 0 : 3) && !snapshot.dpad_up && !snapshot.button_right_shoulder && !snapshot.button_start, - "motion chord did not toggle motion or suppress its inputs"); + "motion chord was not published pre-hotkey or suppressed " + "from normal output"); process_rumble_timer(&g_rumble_timer); require(slot_zero.rumble_calls == 1 && @@ -1030,7 +1995,7 @@ void test_motion_hotkey() { peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; peer_input.gamepad.accel[0] = 8192; platform_on_controller_data(&slot_one, &peer_input); - require(bluepad32_input_backend_snapshot(1, &snapshot) && + require(read_controller_state(1, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), "slot 0 motion chord changed slot 1 motion state"); @@ -1042,7 +2007,7 @@ void test_motion_hotkey() { input.gamepad.buttons = kMotionHotkeyButtonMask; input.gamepad.misc_buttons = kMotionHotkeyMiscMask; platform_on_controller_data(&slot_zero, &input); - require(bluepad32_input_backend_snapshot(0, &snapshot) && + require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), "released motion chord did not re-arm or restore motion"); @@ -1056,6 +2021,7 @@ void test_motion_hotkey() { require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && g_slots[0].motion_enabled == kDefaultMotionEnabled && !g_slots[0].motion_hotkey_latched && + g_slots[0].pre_hotkey_button_mask == 0 && !g_slots[0].feedback_pending, "disconnect did not reset slot 0 motion hotkey state"); } @@ -1078,7 +2044,7 @@ void test_protocol_neutral_analog_state() { platform_on_controller_data(&controller, &input); ControllerState state{}; - require(bluepad32_input_backend_snapshot(0, &state), + require(read_controller_state(0, &state), "analog state was not published"); require(state.left_stick_x == INT16_MIN && state.left_stick_y == 0 && @@ -1096,7 +2062,7 @@ void test_protocol_neutral_analog_state() { input.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R; platform_on_controller_data(&controller, &input); - require(bluepad32_input_backend_snapshot(0, &state) && + require(read_controller_state(0, &state) && state.left_trigger == UINT16_MAX && state.right_trigger == UINT16_MAX, "digital trigger buttons did not map to full analog range"); @@ -1194,6 +2160,19 @@ void test_clear_pairings() { "pairing reset request must execute only once"); } +void test_configuration_timer_rearms_before_storage_work() { + const uint32_t adds_before = g_configuration_timer.add_count; + expected_configuration_timer_add_count = adds_before + 1; + expect_configuration_timer_prearmed = true; + process_configuration_timer(&g_configuration_timer); + expect_configuration_timer_prearmed = false; + require(g_configuration_timer.add_count == + expected_configuration_timer_add_count && + g_configuration_timer.timeout_ms == + kConfigurationPollIntervalMs, + "configuration timer did not remain recurring"); +} + void test_flash_core_start_contract() { bluepad32_input_backend_init(); flash_core_init_result = false; @@ -1245,8 +2224,12 @@ int main(int argc, char** argv) { test_pairing_window_policy(); } else if (scenario == "slot-lighting") { test_slot_lighting(); - } else if (scenario == "abxy-hotkey") { - test_abxy_hotkey(); + } else if (scenario == "stateful-rumble") { + test_stateful_host_rumble_restore(); + } else if (scenario == "profile-chord-raw") { + test_profile_chord_remains_raw(); + } else if (scenario == "profile-feedback") { + test_profile_feedback_scheduler(); } else if (scenario == "motion-hotkey") { test_motion_hotkey(); } else if (scenario == "analog-state") { @@ -1255,6 +2238,8 @@ int main(int argc, char** argv) { test_host_rumble_mode_duration(); } else if (scenario == "clear-pairings") { test_clear_pairings(); + } else if (scenario == "configuration-timer") { + test_configuration_timer_rearms_before_storage_work(); } else if (scenario == "flash-core-start") { test_flash_core_start_contract(); } else if (scenario == "flash-core-failure") { diff --git a/tests/bluepad32_native_stubs/btstack_run_loop.h b/tests/bluepad32_native_stubs/btstack_run_loop.h index 5487f88..8505293 100644 --- a/tests/bluepad32_native_stubs/btstack_run_loop.h +++ b/tests/bluepad32_native_stubs/btstack_run_loop.h @@ -5,6 +5,7 @@ struct btstack_timer_source_t { void (*handler)(btstack_timer_source_t*); uint32_t timeout_ms; + uint32_t add_count; }; inline void btstack_run_loop_set_timer_handler( @@ -18,6 +19,8 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer, timer->timeout_ms = timeout_ms; } -inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {} +inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + ++timer->add_count; +} uint32_t btstack_run_loop_get_time_ms(); inline void btstack_run_loop_execute() {} diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 7ee86a8..6f35a99 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -6,6 +6,7 @@ typedef uint8_t bd_addr_t[6]; typedef uint8_t link_key_t[16]; typedef uint8_t sm_key_t[16]; typedef int link_key_type_t; +typedef uint16_t hci_con_handle_t; enum bd_addr_type_t { BD_ADDR_TYPE_LE_PUBLIC = 0, @@ -20,6 +21,13 @@ enum hci_link_type_t { HCI_LINK_TYPE_ACL = 1, }; +enum gap_connection_type_t { + GAP_CONNECTION_INVALID, + GAP_CONNECTION_ACL, + GAP_CONNECTION_SCO, + GAP_CONNECTION_LE, +}; + struct btstack_link_key_iterator_t { int index; }; @@ -29,6 +37,12 @@ enum { HCI_EVENT_PACKET = 4, HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33, HCI_EVENT_USER_PASSKEY_REQUEST = 0x34, + SM_EVENT_IDENTITY_RESOLVING_STARTED = 0xcd, + SM_EVENT_IDENTITY_RESOLVING_FAILED = 0xce, + SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED = 0xcf, + SM_EVENT_IDENTITY_CREATED = 0xd3, + SM_EVENT_REENCRYPTION_STARTED = 0xd6, + SM_EVENT_REENCRYPTION_COMPLETE = 0xd7, SM_STK_GENERATION_METHOD_JUST_WORKS = 0x01, SM_STK_GENERATION_METHOD_OOB = 0x02, SM_STK_GENERATION_METHOD_PASSKEY = 0x04, @@ -114,10 +128,13 @@ enum uni_bt_conn_protocol_t { struct uni_bt_conn_t { bd_addr_t btaddr; + hci_con_handle_t handle; uni_bt_conn_protocol_t protocol; }; struct uni_hid_device_t { + uint16_t vendor_id; + uint16_t product_id; uni_bt_conn_t conn; int idx; bool gamepad; @@ -154,6 +171,8 @@ struct uni_platform { bool uni_hid_device_is_gamepad(const uni_hid_device_t* device); int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device); void uni_hid_device_disconnect(uni_hid_device_t* device); +uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle( + hci_con_handle_t handle); void uni_bt_allow_incoming_connections(bool enabled); void uni_bt_start_scanning_and_autoconnect_unsafe(); void uni_bt_stop_scanning_unsafe(); @@ -162,6 +181,8 @@ void uni_bt_bredr_scan_stop(); void uni_bt_le_scan_start(); void uni_bt_le_scan_stop(); void uni_bt_del_keys_unsafe(); +gap_connection_type_t gap_get_connection_type( + hci_con_handle_t connection_handle); int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator); int gap_link_key_iterator_get_next( btstack_link_key_iterator_t* iterator, bd_addr_t address, @@ -181,10 +202,48 @@ int gap_ssp_passkey_response(const bd_addr_t address, uint32_t passkey); int gap_ssp_passkey_negative(const bd_addr_t address); void hci_add_event_handler( btstack_packet_callback_registration_t* callback_handler); +void sm_add_event_handler( + btstack_packet_callback_registration_t* callback_handler); uint8_t hci_event_packet_get_type(const uint8_t* packet); void hci_event_user_confirmation_request_get_bd_addr( const uint8_t* packet, bd_addr_t address); void hci_event_user_passkey_request_get_bd_addr( const uint8_t* packet, bd_addr_t address); +hci_con_handle_t sm_event_identity_resolving_started_get_handle( + const uint8_t* packet); +hci_con_handle_t sm_event_identity_resolving_failed_get_handle( + const uint8_t* packet); +hci_con_handle_t sm_event_identity_resolving_succeeded_get_handle( + const uint8_t* packet); +uint8_t sm_event_identity_resolving_succeeded_get_addr_type( + const uint8_t* packet); +void sm_event_identity_resolving_succeeded_get_address( + const uint8_t* packet, bd_addr_t address); +uint8_t sm_event_identity_resolving_succeeded_get_identity_addr_type( + const uint8_t* packet); +void sm_event_identity_resolving_succeeded_get_identity_address( + const uint8_t* packet, bd_addr_t address); +hci_con_handle_t sm_event_identity_created_get_handle( + const uint8_t* packet); +void sm_event_identity_created_get_address( + const uint8_t* packet, bd_addr_t address); +uint8_t sm_event_identity_created_get_identity_addr_type( + const uint8_t* packet); +void sm_event_identity_created_get_identity_address( + const uint8_t* packet, bd_addr_t address); +hci_con_handle_t sm_event_reencryption_started_get_handle( + const uint8_t* packet); +uint8_t sm_event_reencryption_started_get_addr_type( + const uint8_t* packet); +void sm_event_reencryption_started_get_address( + const uint8_t* packet, bd_addr_t address); +hci_con_handle_t sm_event_reencryption_complete_get_handle( + const uint8_t* packet); +uint8_t sm_event_reencryption_complete_get_addr_type( + const uint8_t* packet); +void sm_event_reencryption_complete_get_address( + const uint8_t* packet, bd_addr_t address); +uint8_t sm_event_reencryption_complete_get_status( + const uint8_t* packet); void uni_platform_set_custom(uni_platform* platform); int uni_init(int argc, const char** argv); diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h new file mode 100644 index 0000000..eb29b40 --- /dev/null +++ b/tests/controller_profile_legacy_fixtures.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include "controller_profile.h" + +constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +constexpr uint8_t + kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x75, 0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x30, 0x75, + 0x40, 0x9c, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x34, 0x12, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x01, 0xcd, 0xab, 0x00, 0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp new file mode 100644 index 0000000..a79f8de --- /dev/null +++ b/tests/controller_profile_runtime_test.cpp @@ -0,0 +1,898 @@ +#include "controller_profile_runtime.h" + +#include "controller_identity.h" +#include "controller_profile.h" +#include "profile_service.h" + +#include +#include +#include +#include + +namespace { + +struct FakeProfileRow { + ControllerIdentity identity{}; + uint8_t active_profile = 0; + ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{}; +}; +struct ActivationAttempt { + uint32_t transaction_id = 0; + ControllerIdentity identity{}; + uint8_t profile_index = 0; +}; + + +std::array rows{}; +uint32_t database_generation = 7; +uint32_t configuration_reset_generation = 3; +unsigned active_snapshot_count = 0; +std::array activation_attempts{}; +size_t activation_attempt_count = 0; +unsigned activation_busy_attempts = 0; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerIdentity make_identity(uint8_t value) { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kClassic; + identity.address[0] = value; + identity.address[5] = static_cast(value + 0x40u); + identity.vendor_id = static_cast(0x1000u + value); + identity.product_id = static_cast(0x2000u + value); + return identity; +} + +void prepare_profiles() { + database_generation = 7; + configuration_reset_generation = 3; + active_snapshot_count = 0; + activation_attempts = {}; + activation_attempt_count = 0; + activation_busy_attempts = 0; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + FakeProfileRow& row = rows[slot]; + row = {}; + row.identity = make_identity(static_cast(slot + 1u)); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + row.profiles[profile_index] = + controller_profile_default(row.identity, profile_index); + } + row.profiles[0].triggers[0].digital_threshold = + static_cast(1000u + slot); + row.profiles[0].triggers[1].digital_threshold = + static_cast(5000u + slot); + row.profiles[0].confirmation_policy = + slot == 0 ? ControllerProfileConfirmationPolicy::kLed + : ControllerProfileConfirmationPolicy::kRumble; + } + rows[0].profiles[0].strong_rumble_scale = 0; + rows[0].profiles[0].weak_rumble_scale = UINT8_MAX; + rows[1].profiles[0].strong_rumble_scale = UINT8_MAX; + rows[1].profiles[0].weak_rumble_scale = 0; + controller_profile_runtime_reset(); +} + +Bluepad32SlotSnapshot make_snapshot(uint8_t slot, + uint32_t connection_generation = 1) { + Bluepad32SlotSnapshot snapshot{}; + snapshot.active = true; + snapshot.connection_generation = connection_generation; + snapshot.identity = rows[slot].identity; + snapshot.state = controller_neutral_state(); + return snapshot; +} +void apply_button_masks(uint16_t pre_hotkey_mask, + uint16_t output_mask, + Bluepad32SlotSnapshot* snapshot) { + snapshot->pre_hotkey_button_mask = pre_hotkey_mask; + snapshot->state = controller_neutral_state(); + controller_profile_apply_button_mask(output_mask, &snapshot->state); +} +void apply_button_mask(uint16_t mask, Bluepad32SlotSnapshot* snapshot) { + apply_button_masks(mask, mask, snapshot); +} + +ControllerProfileRuntimeProfileChangeEvent take_profile_change( + uint8_t slot, bool* available) { + ControllerProfileRuntimeProfileChangeEvent event{}; + *available = + controller_profile_runtime_take_profile_change(slot, &event); + return event; +} + +ControllerProfileRuntimeProfileChangeEvent +take_initial_profile_indication(uint8_t slot, bool* available) { + ControllerProfileRuntimeProfileChangeEvent event{}; + *available = + controller_profile_runtime_take_initial_profile_indication( + slot, &event); + return event; +} + +constexpr uint16_t logical_button_bit( + ControllerProfileLogicalButton button) { + return static_cast( + 1u << static_cast(button)); +} + + +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 && + std::memcmp(first.motion_samples, second.motion_samples, + sizeof(first.motion_samples)) == 0; +} + +void test_four_slot_cache_and_unchanged_generation() { + prepare_profiles(); + std::array + snapshots{}; + std::array + transformed{}; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + snapshots[slot] = make_snapshot(slot); + transformed[slot] = + runtime_transform(slot, snapshots[slot]); + require(transformed[slot].left_trigger_digital_threshold == + static_cast(1000u + slot) && + transformed[slot].right_trigger_digital_threshold == + static_cast(5000u + slot), + "a slot did not receive its own cached profile"); + } + require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT, + "initial slot loads did not fetch exactly one profile each"); + + rows[0].profiles[0].triggers[0].digital_threshold = 65000; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + transformed[slot] = + runtime_transform(slot, snapshots[slot]); + } + require(active_snapshot_count == CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT, + "unchanged generations copied profiles on the report path"); + require(transformed[0].left_trigger_digital_threshold == 1000, + "an unchanged generation bypassed the slot cache"); + + snapshots[2].connection_generation = 2; + transformed[2] = + runtime_transform(2, snapshots[2]); + require(active_snapshot_count == + CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 1 && + transformed[2].left_trigger_digital_threshold == 1002, + "one slot connection generation did not refresh in isolation"); + + snapshots[3].identity = rows[1].identity; + transformed[3] = + runtime_transform(3, snapshots[3]); + require(active_snapshot_count == + CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT + 2 && + transformed[3].left_trigger_digital_threshold == 1001, + "an exact identity change did not refresh only its slot"); +} + +void test_activation_disconnect_and_default_preservation() { + prepare_profiles(); + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + snapshot.state.button_south = true; + ControllerProfileTransformResult transformed = + runtime_transform(0, snapshot); + require(transformed.state.button_south && + transformed.left_trigger_digital_threshold == 1000, + "initial active profile was not applied"); + + rows[0].active_profile = 1; + rows[0].profiles[1].button_map[ + static_cast(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kNorth); + rows[0].profiles[1].triggers[0].digital_threshold = 12345; + ++database_generation; + 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 = 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 = runtime_transform(0, snapshot); + require(active_snapshot_count == reads_before_disconnect, + "an unchanged inactive slot repeatedly cleared or fetched state"); + snapshot.active = true; + 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"); + + Bluepad32SlotSnapshot default_snapshot = make_snapshot(2); + rows[2].profiles[0] = + controller_profile_default(rows[2].identity, 0); + ++database_generation; + default_snapshot.state.button_east = true; + default_snapshot.state.dpad_left = true; + default_snapshot.state.left_trigger = 32123; + default_snapshot.state.right_trigger = 54321; + default_snapshot.state.left_stick_x = -12345; + default_snapshot.state.right_stick_y = 23456; + 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 = runtime_transform(2, default_snapshot); + require(transformed.state.button_east && transformed.state.dpad_left && + transformed.state.left_trigger == 32123 && + transformed.state.right_trigger == 54321 && + transformed.state.left_stick_x == -12345 && + transformed.state.right_stick_y == 23456 && + motion_equal(transformed.state, default_snapshot.state) && + transformed.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + transformed.right_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "default runtime profile changed serializer input or motion"); +} + +void test_analog_thresholds_rumble_and_local_confirmation() { + prepare_profiles(); + Bluepad32SlotSnapshot first = make_snapshot(0); + Bluepad32SlotSnapshot second = make_snapshot(1); + first.state.left_trigger = second.state.left_trigger = 23456; + first.state.right_trigger = second.state.right_trigger = 45678; + const ControllerProfileTransformResult first_output = + runtime_transform(0, first); + const ControllerProfileTransformResult second_output = + runtime_transform(1, second); + require(first_output.state.left_trigger == + second_output.state.left_trigger && + first_output.state.right_trigger == + second_output.state.right_trigger && + first_output.left_trigger_digital_threshold != + second_output.left_trigger_digital_threshold, + "digital thresholds changed XInput-visible analog trigger state"); + + const ControllerRumbleOutput host{91, 73}; + const ControllerRumbleOutput first_host = + controller_profile_runtime_scale_host_rumble(0, first, host); + const ControllerRumbleOutput second_host = + controller_profile_runtime_scale_host_rumble(1, second, host); + require(first_host.low_frequency_magnitude == 0 && + first_host.high_frequency_magnitude == 73 && + second_host.low_frequency_magnitude == 91 && + second_host.high_frequency_magnitude == 0, + "host rumble was not scaled through each slot profile"); + + const ControllerProfileRuntimeLocalConfirmation confirmation = + controller_profile_runtime_local_confirmation(0, first, host); + require(confirmation.rumble.low_frequency_magnitude == 91 && + confirmation.rumble.high_frequency_magnitude == 73 && + confirmation.policy == + ControllerProfileConfirmationPolicy::kLed, + "local confirmation was scaled or lost its profile policy"); +} + +void test_initial_profile_indication_once_per_connection() { + prepare_profiles(); + std::array + snapshots{}; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + rows[slot].active_profile = slot; + rows[slot].profiles[slot].confirmation_policy = + ControllerProfileConfirmationPolicy::kRumbleAndLed; + snapshots[slot] = make_snapshot(slot); + (void)runtime_transform(slot, snapshots[slot]); + } + + constexpr uint8_t kTakeOrder[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT] = { + 2, 0, 3, 1}; + for (const uint8_t slot : kTakeOrder) { + bool available = false; + const ControllerProfileRuntimeProfileChangeEvent event = + take_initial_profile_indication(slot, &available); + require(available && + event.connection_generation == 1 && + event.database_generation == database_generation && + event.active_profile_number == slot + 1u && + event.policy == + ControllerProfileConfirmationPolicy::kLed, + "initial profile 1..4 indication was not isolated, " + "LED-only, or generation-bound"); + (void)take_initial_profile_indication(slot, &available); + require(!available, + "initial profile indication repeated without a new " + "connection"); + (void)take_profile_change(slot, &available); + require(!available, + "initial profile resolution published switch feedback"); + } + + ++database_generation; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + (void)runtime_transform(slot, snapshots[slot]); + bool available = true; + (void)take_initial_profile_indication(slot, &available); + require(!available, + "database refresh repeated initial profile indication"); + } + + rows[2].identity = controller_identity_global(); + snapshots[0].identity = controller_identity_global(); + snapshots[0].connection_generation = 2; + (void)runtime_transform(0, snapshots[0]); + bool available = false; + const ControllerProfileRuntimeProfileChangeEvent unresolved_event = + take_initial_profile_indication(0, &available); + require(available && + unresolved_event.connection_generation == 2 && + unresolved_event.active_profile_number == 3, + "first committed unresolved-identity profile was not " + "indicated"); + + snapshots[0].identity = rows[1].identity; + (void)runtime_transform(0, snapshots[0]); + (void)take_initial_profile_indication(0, &available); + require(!available, + "identity promotion repeated initial profile indication"); + + snapshots[0].connection_generation = 3; + (void)runtime_transform(0, snapshots[0]); + const ControllerProfileRuntimeProfileChangeEvent reconnect_event = + take_initial_profile_indication(0, &available); + require(available && + reconnect_event.connection_generation == 3 && + reconnect_event.active_profile_number == 2 && + reconnect_event.policy == + ControllerProfileConfirmationPolicy::kLed, + "true reconnection did not publish one fresh LED-only " + "profile indication"); + + rows[1].profiles[1].confirmation_policy = + ControllerProfileConfirmationPolicy::kRumble; + snapshots[1].connection_generation = 2; + (void)runtime_transform(1, snapshots[1]); + (void)take_initial_profile_indication(1, &available); + require(!available, + "rumble-only initial policy disturbed controller feedback"); + + rows[3].profiles[3].confirmation_policy = + ControllerProfileConfirmationPolicy::kNone; + snapshots[3].connection_generation = 2; + (void)runtime_transform(3, snapshots[3]); + (void)take_initial_profile_indication(3, &available); + require(!available, + "disabled initial policy disturbed controller feedback"); +} + +void test_default_switching_retry_commit_and_feedback() { + prepare_profiles(); + ControllerProfile& initial_profile = rows[0].profiles[0]; + initial_profile.macro_trigger = static_cast( + ControllerProfileLogicalButton::kLeftShoulder); + initial_profile.macro_step_count = 2; + initial_profile.macro_steps[0].type = + ControllerProfileMacroStepType::kState; + 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); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(!event_available, + "initial profile load published confirmation feedback"); + + activation_busy_attempts = 2; + const uint16_t held_mask = static_cast( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD | + logical_button_bit(ControllerProfileLogicalButton::kSouth)); + apply_button_mask(held_mask, &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 1 && + rows[0].active_profile == 0 && + output.state.button_south && + !output.state.button_left_shoulder && + !output.state.button_right_shoulder && + !output.state.button_select && + !output.state.button_start && + !output.state.button_north, + "default chord was not consumed before a busy activation and synthetic trigger"); + output = runtime_transform(0, snapshot, 2); + output = runtime_transform(0, snapshot, 3); + require(activation_attempt_count == 3 && + activation_attempts[0].transaction_id == + activation_attempts[1].transaction_id && + activation_attempts[1].transaction_id == + activation_attempts[2].transaction_id && + (activation_attempts[0].transaction_id & 0x80000000u) != 0 && + activation_attempts[0].transaction_id != 0 && + activation_attempts[0].profile_index == 1 && + output.state.button_south && !output.state.button_north, + "held busy chord did not retry one internal activation transaction"); + + (void)runtime_transform(0, snapshot, 4); + require(activation_attempt_count == 3, + "accepted activation repeated while the chord remained held"); + (void)take_profile_change(0, &event_available); + require(!event_available, + "activation feedback was published before storage commit"); + + rows[0].active_profile = 1; + rows[0].profiles[1].button_map[ + static_cast(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kNorth); + rows[0].profiles[1].confirmation_policy = + ControllerProfileConfirmationPolicy::kLed; + ++database_generation; + output = runtime_transform(0, snapshot, 5); + const ControllerProfileRuntimeProfileChangeEvent event = + take_profile_change(0, &event_available); + require(event_available && event.connection_generation == 1 && + event.database_generation == database_generation && + event.active_profile_number == 2 && + event.policy == ControllerProfileConfirmationPolicy::kLed && + output.state.button_north && + !output.state.button_south && + !output.state.button_left_shoulder && + activation_attempt_count == 3, + "committed activation did not cancel, transform, and publish exactly one event"); + (void)take_profile_change(0, &event_available); + require(!event_available, + "committed profile change feedback was published twice"); + + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 6); + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshot); + (void)runtime_transform(0, snapshot, 7); + require(activation_attempt_count == 4 && + activation_attempts[3].profile_index == 2 && + activation_attempts[3].transaction_id != + activation_attempts[0].transaction_id && + (activation_attempts[3].transaction_id & 0x80000000u) != 0, + "release did not re-arm one activation for the next profile"); +} + +void test_identity_promotion_preserves_held_switching() { + prepare_profiles(); + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + snapshot.identity = controller_identity_global(); + apply_button_mask( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot); + + const ControllerProfileTransformResult unresolved = + runtime_transform(0, snapshot, 0); + require(activation_attempt_count == 1 && + controller_identity_is_global( + activation_attempts[0].identity) && + unresolved.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "unresolved BLE identity did not begin one activation"); + + snapshot.identity = rows[0].identity; + const ControllerProfileTransformResult promoted = + runtime_transform(0, snapshot, 1); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(activation_attempt_count == 1 && + promoted.left_trigger_digital_threshold == 1000 && + !event_available, + "identity-only BLE promotion re-armed a held activation or " + "failed to refresh its profile"); + + (void)runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1, + "promoted BLE identity repeated an accepted held activation"); + + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask( + CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot); + (void)runtime_transform(0, snapshot, 4); + require(activation_attempt_count == 2 && + controller_identity_equal( + activation_attempts[1].identity, + rows[0].identity), + "physical release did not re-arm the promoted identity"); +} + +void configure_motion_suppression_probe(ControllerProfile* profile) { + profile->macro_trigger = static_cast( + ControllerProfileLogicalButton::kDpadUp); + 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 = + logical_button_bit(ControllerProfileLogicalButton::kNorth); + profile->macro_steps[1].type = + ControllerProfileMacroStepType::kEnd; +} + +void test_switching_uses_pre_hotkey_buttons_only() { + constexpr uint16_t kMotionHotkeyLogicalMask = + static_cast( + logical_button_bit( + ControllerProfileLogicalButton::kDpadUp) | + logical_button_bit( + ControllerProfileLogicalButton::kRightShoulder) | + logical_button_bit( + ControllerProfileLogicalButton::kStart)); + constexpr uint16_t kCustomChord = + static_cast( + kMotionHotkeyLogicalMask | + logical_button_bit( + ControllerProfileLogicalButton::kSouth)); + + for (uint8_t custom = 0; custom < 2; ++custom) { + prepare_profiles(); + ControllerProfile& profile = rows[0].profiles[0]; + configure_motion_suppression_probe(&profile); + const uint16_t switching_chord = + custom != 0 ? kCustomChord + : CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD; + profile.switching_chord = custom != 0 ? kCustomChord : 0; + + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + const uint16_t pre_hotkey_mask = static_cast( + switching_chord | kMotionHotkeyLogicalMask); + const uint16_t output_mask = static_cast( + pre_hotkey_mask & ~kMotionHotkeyLogicalMask); + apply_button_masks( + pre_hotkey_mask, output_mask, &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 1 && + !output.state.dpad_up && + !output.state.button_right_shoulder && + !output.state.button_start && + !output.state.button_left_shoulder && + !output.state.button_select && + !output.state.button_south && + !output.state.button_north, + custom != 0 + ? "custom chord hidden by motion suppression" + : "default chord hidden by motion suppression"); + + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + !output.state.button_north, + "held pre-hotkey chord repeated activation or leaked " + "into synthetic input"); + } +} + +void test_custom_switching_chord_and_wrap() { + prepare_profiles(); + constexpr uint16_t kCustomChord = + static_cast( + logical_button_bit(ControllerProfileLogicalButton::kSouth) | + logical_button_bit(ControllerProfileLogicalButton::kCapture)); + rows[0].profiles[0].switching_chord = kCustomChord; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshot); + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 0 && + output.state.button_left_shoulder && + output.state.button_right_shoulder && + output.state.button_select && output.state.button_start, + "nonzero switching chord did not replace the default"); + + apply_button_mask(kCustomChord, &snapshot); + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 1 && + !output.state.button_south && + !output.state.button_capture, + "custom switching chord was not consumed or activated"); + + prepare_profiles(); + rows[0].active_profile = 3; + rows[0].profiles[3].switching_chord = kCustomChord; + snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 10); + bool event_available = true; + (void)take_profile_change(0, &event_available); + require(!event_available, + "initial profile 4 load published a change event"); + apply_button_mask(kCustomChord, &snapshot); + (void)runtime_transform(0, snapshot, 11); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 0, + "profile switching did not wrap profile 4 to profile 1"); +} +void test_switching_slot_isolation() { + prepare_profiles(); + std::array + snapshots{}; + for (uint8_t slot = 0; + slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { + snapshots[slot] = make_snapshot(slot); + (void)runtime_transform(slot, snapshots[slot], 0); + } + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshots[2]); + (void)runtime_transform(2, snapshots[2], 1); + snapshots[0].state.button_south = true; + const ControllerProfileTransformResult untouched = + runtime_transform(0, snapshots[0], 1); + require(activation_attempt_count == 1 && + controller_identity_equal( + activation_attempts[0].identity, + rows[2].identity) && + untouched.state.button_south, + "one slot's switching chord affected another slot"); + + apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, + &snapshots[3]); + (void)runtime_transform(2, snapshots[2], 2); + (void)runtime_transform(3, snapshots[3], 2); + require(activation_attempt_count == 2 && + controller_identity_equal( + activation_attempts[1].identity, + rows[3].identity), + "held switching state was shared between controller slots"); +} + + +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"); + bool event_available = false; + const ControllerProfileRuntimeProfileChangeEvent cancellation_event = + take_profile_change(0, &event_available); + require(event_available && + cancellation_event.active_profile_number == 2, + "profile commit feedback was not observed with cancellation"); + + 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; +} +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index) { + require(activation_attempt_count < activation_attempts.size(), + "activation attempt fixture overflow"); + activation_attempts[activation_attempt_count++] = { + transaction_id, identity, profile_index}; + if (activation_busy_attempts != 0) { + --activation_busy_attempts; + return ConfigurationTransactionStatus::kBusy; + } + return ConfigurationTransactionStatus::kPending; +} + + +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, + ProfileServiceActiveProfileSnapshot* output) { + if (output == nullptr) { + return; + } + ++active_snapshot_count; + *output = {}; + output->metadata.state = ProfileServiceState::kReady; + output->metadata.generation = database_generation; + for (const FakeProfileRow& row : rows) { + if (!controller_identity_equal(row.identity, identity)) { + continue; + } + output->valid = true; + output->profile_index = row.active_profile; + output->profile = row.profiles[row.active_profile]; + return; + } +} + +int main() { + test_four_slot_cache_and_unchanged_generation(); + test_activation_disconnect_and_default_preservation(); + test_analog_thresholds_rumble_and_local_confirmation(); + test_initial_profile_indication_once_per_connection(); + test_default_switching_retry_commit_and_feedback(); + test_identity_promotion_preserves_held_switching(); + test_switching_uses_pre_hotkey_buttons_only(); + test_custom_switching_chord_and_wrap(); + test_switching_slot_isolation(); + test_all_runtime_cancellation_causes(); + test_runtime_slot_synthetic_isolation(); + return 0; +} diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp new file mode 100644 index 0000000..91d1bac --- /dev/null +++ b/tests/controller_profile_test.cpp @@ -0,0 +1,288 @@ +#include "controller_identity.h" +#include "controller_profile.h" +#include "tests/controller_profile_legacy_fixtures.h" + +#include +#include +#include + +namespace { + +uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{}; +ControllerProfileDatabase database{}; +ControllerProfileDatabase decoded_database{}; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerIdentity identity(uint8_t suffix) { + ControllerIdentity value{}; + value.stable = true; + value.transport = ControllerTransport::kClassic; + value.address[5] = suffix; + value.vendor_id = 0x057e; + value.product_id = static_cast(0x2000u + suffix); + return value; +} + +bool read_encoded_database(void*, size_t offset, uint8_t* output, + size_t size) { + if (offset > sizeof(encoded_database) || + size > sizeof(encoded_database) - offset) { + return false; + } + memcpy(output, &encoded_database[offset], size); + return true; +} + +void test_profile_wire_schema() { + const ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "default profile did not encode"); + require(encoded[0] == 2 && encoded[1] == 0 && + encoded[2] == 0 && encoded[3] == 1, + "profile header is not little-endian v2/256"); + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + require(encoded[4 + index] == index, + "default direct mapping is not identity"); + } + require(encoded[26] == 0xff && encoded[27] == 0x7f && + encoded[30] == 0, + "default stick encoding changed"); + require(encoded[54] == 0xff && encoded[55] == 0xff && + encoded[58] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[59] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), + "default trigger encoding changed"); + require(encoded[72] == 0xff && encoded[73] == 0xff && + encoded[74] == 3 && encoded[78] == 0xff && + encoded[79] == 0xff && encoded[80] == 1, + "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"); + } + + ControllerProfile decoded{}; + require(controller_profile_decode(encoded, sizeof(encoded), &decoded), + "default profile did not decode"); + encoded[252] = 1; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "nonzero reserved profile byte was accepted"); + + ControllerProfile invalid = profile; + invalid.button_map[0] = 16; + require(!controller_profile_validate(invalid), + "invalid direct output was accepted"); + invalid = profile; + invalid.sticks[0].inner_deadzone = + invalid.sticks[0].outer_saturation; + require(!controller_profile_validate(invalid), + "empty stick range was accepted"); + ControllerProfile boundary = profile; + boundary.triggers[0].lower_deadzone = 30000; + boundary.triggers[0].upper_saturation = 40000; + boundary.triggers[0].digital_threshold = 0; + uint8_t boundary_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(boundary, boundary_encoded, + sizeof(boundary_encoded)) && + controller_profile_decode(boundary_encoded, + sizeof(boundary_encoded), + &decoded) && + decoded.triggers[0].digital_threshold == 0, + "current profile rejected zero transformed trigger threshold"); + boundary.triggers[0].digital_threshold = UINT16_MAX; + require(controller_profile_encode(boundary, boundary_encoded, + sizeof(boundary_encoded)) && + controller_profile_decode(boundary_encoded, + sizeof(boundary_encoded), + &decoded) && + decoded.triggers[0].digital_threshold == UINT16_MAX, + "current profile rejected maximum transformed trigger threshold"); + invalid = profile; + invalid.triggers[0].lower_deadzone = + invalid.triggers[0].upper_saturation; + require(!controller_profile_validate(invalid), + "empty raw trigger range was accepted"); + invalid.triggers[0].lower_deadzone = UINT16_MAX; + invalid.triggers[0].upper_saturation = UINT16_MAX - 1; + require(!controller_profile_validate(invalid), + "reversed raw trigger range was accepted"); + invalid = profile; + invalid.turbo_modes[0] = + static_cast(3); + 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.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; + require(!controller_profile_validate(invalid), + "macro without a final end was accepted"); +} + +void test_legacy_profile_migration() { + ControllerProfile migrated{}; + require(controller_profile_decode( + kLegacyDefaultProfile, sizeof(kLegacyDefaultProfile), + &migrated), + "legacy default profile did not decode"); + require(migrated.triggers[0].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + migrated.triggers[1].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "legacy inherited thresholds were not migrated"); + + 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 schema_byte = index == 0; + const bool threshold_byte = + (index >= 58 && index < 60) || + (index >= 68 && index < 70); + if (!schema_byte && !threshold_byte) { + require(encoded[index] == kLegacyDefaultProfile[index], + "legacy default profile changed an unrelated byte"); + } + } + require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION && + encoded[58] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[59] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && + encoded[68] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && + encoded[69] == + static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), + "migrated default profile did not encode as v2"); + + require(controller_profile_decode( + kLegacyNarrowRawRangeProfile, + sizeof(kLegacyNarrowRawRangeProfile), &migrated), + "legacy narrow-raw-range profile did not decode"); + require(migrated.triggers[0].lower_deadzone == 30000 && + migrated.triggers[0].upper_saturation == 40000 && + migrated.triggers[0].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + migrated.triggers[1].lower_deadzone == 30000 && + migrated.triggers[1].upper_saturation == 40000 && + migrated.triggers[1].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "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 schema_byte = index == 0; + const bool threshold_byte = + (index >= 58 && index < 60) || + (index >= 68 && index < 70); + if (!schema_byte && !threshold_byte) { + require( + encoded[index] == kLegacyNarrowRawRangeProfile[index], + "narrow-raw-range migration changed unrelated profile data"); + } + } + + require(controller_profile_decode( + kLegacyCustomThresholdProfile, + sizeof(kLegacyCustomThresholdProfile), &migrated), + "legacy custom-threshold profile did not decode"); + require(migrated.triggers[0].digital_threshold == 0x1234 && + migrated.triggers[1].digital_threshold == 0xabcd, + "legacy custom thresholds were not preserved"); + require(controller_profile_encode(migrated, encoded, sizeof(encoded)), + "legacy custom-threshold profile did not re-encode"); + for (size_t index = 1; index < sizeof(encoded); ++index) { + require(encoded[index] == kLegacyCustomThresholdProfile[index], + "legacy custom-threshold profile changed data"); + } + + ControllerProfile current = + controller_profile_default(controller_identity_global(), 0); + current.triggers[0].digital_threshold = 0x8000; + require(controller_profile_encode(current, encoded, sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), + &migrated) && + migrated.triggers[0].digital_threshold == 0x8000, + "v2 custom threshold matching the legacy default was migrated"); +} + +void test_database_round_trip_and_capacity() { + controller_profile_database_default(&database); + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) { + ControllerProfileDatabaseEntry* entry = nullptr; + require(controller_profile_database_ensure( + &database, identity(static_cast(index + 1)), + &entry) == ControllerProfileDatabaseResult::kOk && + entry != nullptr, + "stable identity was not added"); + entry->active_profile = index % CONTROLLER_PROFILE_COUNT; + } + ControllerProfileDatabaseEntry* rejected = nullptr; + require(controller_profile_database_ensure( + &database, identity(99), &rejected) == + ControllerProfileDatabaseResult::kFull, + "seventeenth stable identity was not rejected"); + require(controller_profile_database_find(database, identity(1)) != + nullptr, + "full-table rejection evicted an existing identity"); + + for (size_t offset = 0; offset < sizeof(encoded_database); + offset += CONTROLLER_PROFILE_ENCODED_SIZE) { + const size_t size = sizeof(encoded_database) - offset < + CONTROLLER_PROFILE_ENCODED_SIZE + ? sizeof(encoded_database) - offset + : CONTROLLER_PROFILE_ENCODED_SIZE; + require(controller_profile_database_encode_range( + database, offset, &encoded_database[offset], size), + "database range did not encode"); + } + require(encoded_database[4] == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && + encoded_database[5] == 0, + "database encoder did not emit v2"); + require(controller_profile_database_decode( + read_encoded_database, nullptr, &decoded_database), + "database did not decode"); + require(controller_profile_database_find( + decoded_database, identity(16)) != nullptr, + "last database identity did not round trip"); + + encoded_database[12] = 1; + require(!controller_profile_database_decode( + read_encoded_database, nullptr, &decoded_database), + "nonzero database header reservation was accepted"); +} + +} // namespace +int main() { + test_profile_wire_schema(); + test_legacy_profile_migration(); + test_database_round_trip_and_capacity(); + return 0; +} diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp new file mode 100644 index 0000000..e398638 --- /dev/null +++ b/tests/controller_profile_transform_test.cpp @@ -0,0 +1,385 @@ +#include "controller_identity.h" +#include "controller_profile.h" +#include "controller_profile_transform.h" + +#include +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +ControllerProfile default_profile() { + return controller_profile_default(controller_identity_global(), 0); +} + +bool states_equal(const ControllerState& left, const ControllerState& right) { + return left.dpad_up == right.dpad_up && + left.dpad_down == right.dpad_down && + left.dpad_left == right.dpad_left && + left.dpad_right == right.dpad_right && + left.button_south == right.button_south && + left.button_east == right.button_east && + left.button_west == right.button_west && + left.button_north == right.button_north && + left.button_left_shoulder == right.button_left_shoulder && + left.button_right_shoulder == right.button_right_shoulder && + left.button_select == right.button_select && + left.button_start == right.button_start && + left.button_system == right.button_system && + left.button_capture == right.button_capture && + left.button_left_stick == right.button_left_stick && + left.button_right_stick == right.button_right_stick && + left.left_trigger == right.left_trigger && + left.right_trigger == right.right_trigger && + left.left_stick_x == right.left_stick_x && + left.left_stick_y == right.left_stick_y && + left.right_stick_x == right.right_stick_x && + left.right_stick_y == right.right_stick_y && + left.motion_sample_count == right.motion_sample_count && + std::memcmp(left.motion_samples, right.motion_samples, + sizeof(left.motion_samples)) == 0; +} + +ControllerProfileTransformResult transform_left_stick( + const ControllerProfileStickConfiguration& configuration, int16_t x, + int16_t y) { + ControllerProfile profile = default_profile(); + profile.sticks[0] = configuration; + ControllerState state{}; + state.left_stick_x = x; + state.left_stick_y = y; + return controller_profile_transform(state, profile); +} + +uint16_t transform_left_trigger( + const ControllerProfileTriggerConfiguration& configuration, + uint16_t value) { + ControllerProfile profile = default_profile(); + profile.triggers[0] = configuration; + ControllerState state{}; + state.left_trigger = value; + return controller_profile_transform(state, profile).state.left_trigger; +} + +void test_button_masks_and_direct_mapping() { + ControllerState state{}; + controller_profile_apply_button_mask(UINT16_MAX, &state); + require(controller_profile_extract_button_mask(state) == UINT16_MAX, + "button mask application omitted a logical button"); + controller_profile_apply_button_mask(0, &state); + require(controller_profile_extract_button_mask(state) == 0, + "zero button mask did not clear every logical button"); + controller_profile_apply_button_mask(UINT16_MAX, nullptr); + + for (uint8_t input = 0; + input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) { + ControllerProfile profile = default_profile(); + const uint8_t output = static_cast( + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT - 1u - input); + profile.button_map[input] = output; + state = {}; + controller_profile_apply_button_mask( + static_cast(1u << input), &state); + const ControllerProfileTransformResult transformed = + controller_profile_transform(state, profile); + require(controller_profile_extract_button_mask(transformed.state) == + static_cast(1u << output), + "a logical button did not map directly to its output"); + } + + ControllerProfile profile = default_profile(); + profile.button_map[0] = 1; + profile.button_map[1] = 2; + require(controller_profile_map_button_mask(1u, profile) == 2u, + "button mapping recursively remapped an output"); + profile.button_map[1] = 1; + require(controller_profile_map_button_mask(3u, profile) == 2u, + "duplicate mapped outputs were not combined"); + profile.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON; + require(controller_profile_map_button_mask(1u, profile) == 0, + "disabled button mapping still produced output"); + + ControllerProfile invalid = default_profile(); + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + require(!controller_profile_validate(invalid), + "logical output 16 was accepted"); + invalid.button_map[0] = 0xfe; + require(!controller_profile_validate(invalid), + "logical output 0xfe was accepted"); + invalid.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON; + require(controller_profile_validate(invalid), + "disabled logical output 0xff was rejected"); +} + +void test_stick_center_boundaries_and_inversion() { + ControllerProfileStickConfiguration configuration{}; + configuration.center_x = 1234; + configuration.center_y = -2345; + configuration.inner_deadzone = 1000; + configuration.outer_saturation = 20000; + configuration.curve_q8_8 = 256; + ControllerProfileTransformResult transformed = transform_left_stick( + configuration, configuration.center_x, configuration.center_y); + require(transformed.state.left_stick_x == 0 && + transformed.state.left_stick_y == 0, + "center calibration did not precede stick shaping"); + + transformed = transform_left_stick( + configuration, + static_cast(configuration.center_x + + configuration.inner_deadzone), + configuration.center_y); + require(transformed.state.left_stick_x == 0 && + transformed.state.left_stick_y == 0, + "inner deadzone boundary was not neutral"); + transformed = transform_left_stick( + configuration, + static_cast(configuration.center_x + + configuration.inner_deadzone + 1), + configuration.center_y); + require(transformed.state.left_stick_x > 0, + "first value outside inner deadzone stayed neutral"); + + configuration.center_x = 0; + configuration.center_y = 0; + configuration.inner_deadzone = 0; + configuration.outer_saturation = 20000; + transformed = transform_left_stick(configuration, 19999, 0); + require(transformed.state.left_stick_x > 0 && + transformed.state.left_stick_x < INT16_MAX, + "value below outer saturation reached an endpoint"); + transformed = transform_left_stick(configuration, 20000, 0); + require(transformed.state.left_stick_x == INT16_MAX, + "positive outer saturation boundary missed endpoint"); + transformed = transform_left_stick(configuration, -20000, 0); + require(transformed.state.left_stick_x == INT16_MIN, + "negative outer saturation boundary missed endpoint"); + + configuration.outer_saturation = 32767; + configuration.invert_x = true; + configuration.invert_y = true; + transformed = transform_left_stick(configuration, INT16_MIN, 0); + require(transformed.state.left_stick_x == INT16_MAX, + "negative stick endpoint did not invert to positive endpoint"); + transformed = transform_left_stick(configuration, 0, INT16_MAX); + require(transformed.state.left_stick_y == INT16_MIN, + "positive stick endpoint did not invert to negative endpoint"); + + configuration.invert_x = false; + configuration.invert_y = false; + configuration.outer_saturation = 20000; + transformed = transform_left_stick(configuration, 15000, 15000); + require(transformed.state.left_stick_x < INT16_MAX && + transformed.state.left_stick_y < INT16_MAX, + "stick magnitude was not Chebyshev magnitude"); +} + +void test_stick_curves_and_monotonicity() { + ControllerProfileStickConfiguration linear{}; + linear.inner_deadzone = 0; + linear.outer_saturation = 32767; + linear.curve_q8_8 = 256; + ControllerProfileStickConfiguration slow = linear; + slow.curve_q8_8 = 512; + ControllerProfileStickConfiguration fast = linear; + fast.curve_q8_8 = 128; + + const int16_t linear_mid = + transform_left_stick(linear, 16384, 0).state.left_stick_x; + const int16_t slow_mid = + transform_left_stick(slow, 16384, 0).state.left_stick_x; + const int16_t fast_mid = + transform_left_stick(fast, 16384, 0).state.left_stick_x; + require(slow_mid < linear_mid && linear_mid < fast_mid, + "stick curve directions are reversed or ineffective"); + require(transform_left_stick(slow, 0, 0).state.left_stick_x == 0 && + transform_left_stick(fast, 32767, 0) + .state.left_stick_x == INT16_MAX, + "stick response curve did not preserve endpoints"); + + const uint16_t curves[] = {1, 128, 256, 512, UINT16_MAX}; + for (uint16_t curve : curves) { + ControllerProfileStickConfiguration configuration = linear; + configuration.curve_q8_8 = curve; + int16_t previous = 0; + for (int32_t input = 0; input <= INT16_MAX; ++input) { + const int16_t output = transform_left_stick( + configuration, + static_cast(input), 0) + .state.left_stick_x; + require(output >= previous, + "stick response was not monotonic"); + previous = output; + } + require(previous == INT16_MAX, + "monotonic stick response missed positive endpoint"); + } +} + +void test_trigger_boundaries_curves_and_thresholds() { + ControllerProfileTriggerConfiguration configuration{}; + configuration.lower_deadzone = 1000; + configuration.upper_saturation = 60000; + configuration.curve_q8_8 = 256; + configuration.digital_threshold = 32000; + require(transform_left_trigger(configuration, 999) == 0 && + transform_left_trigger(configuration, 1000) == 0, + "trigger lower deadzone boundary was not zero"); + require(transform_left_trigger(configuration, 1001) > 0, + "first trigger value above lower deadzone stayed zero"); + require(transform_left_trigger(configuration, 59999) < UINT16_MAX && + transform_left_trigger(configuration, 60000) == UINT16_MAX && + transform_left_trigger(configuration, UINT16_MAX) == + UINT16_MAX, + "trigger upper saturation boundary missed full scale"); + + ControllerProfileTriggerConfiguration slow = configuration; + slow.curve_q8_8 = 512; + ControllerProfileTriggerConfiguration fast = configuration; + fast.curve_q8_8 = 128; + const uint16_t midpoint = static_cast( + (static_cast(configuration.lower_deadzone) + + configuration.upper_saturation) / + 2u); + const uint16_t linear_mid = + transform_left_trigger(configuration, midpoint); + const uint16_t slow_mid = transform_left_trigger(slow, midpoint); + const uint16_t fast_mid = transform_left_trigger(fast, midpoint); + require(slow_mid < linear_mid && linear_mid < fast_mid, + "trigger curve directions are reversed or ineffective"); + require(transform_left_trigger(slow, configuration.lower_deadzone) == 0 && + transform_left_trigger(fast, + configuration.upper_saturation) == + UINT16_MAX, + "trigger response curve did not preserve endpoints"); + + uint16_t previous = 0; + for (uint32_t input = 0; input <= UINT16_MAX; ++input) { + const uint16_t output = transform_left_trigger( + slow, static_cast(input)); + require(output >= previous, + "trigger response was not monotonic"); + previous = output; + } + + ControllerProfile profile = default_profile(); + profile.triggers[0] = slow; + profile.triggers[0].digital_threshold = 12345; + profile.triggers[1].digital_threshold = 54321; + ControllerState state{}; + state.left_trigger = midpoint; + const ControllerProfileTransformResult transformed = + controller_profile_transform(state, profile); + require(transformed.left_trigger_digital_threshold == 12345 && + transformed.right_trigger_digital_threshold == 54321, + "profile-owned digital thresholds were not returned"); +} + +void test_default_whole_state_equivalence() { + ControllerState input{}; + input.dpad_up = true; + input.dpad_right = true; + input.button_south = true; + input.button_west = true; + input.button_left_shoulder = true; + input.button_select = true; + input.button_system = true; + input.button_capture = true; + input.button_right_stick = true; + input.left_trigger = 0; + input.right_trigger = UINT16_MAX; + input.left_stick_x = INT16_MIN; + input.left_stick_y = INT16_MAX; + input.right_stick_x = INT16_MAX; + input.right_stick_y = INT16_MIN; + input.motion_sample_count = CONTROLLER_MOTION_SAMPLE_CAPACITY; + input.motion_samples[0] = + {INT16_MIN, -30000, -1, 0, 1, INT16_MAX}; + input.motion_samples[1] = {1, 2, 3, 4, 5, 6}; + input.motion_samples[2] = + {INT16_MAX, 30000, 1, 0, -1, INT16_MIN}; + + const ControllerProfile profile = default_profile(); + const ControllerProfileTransformResult transformed = + controller_profile_transform(input, profile); + require(states_equal(input, transformed.state), + "default profile changed whole controller state or motion"); + require(transformed.left_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + transformed.right_trigger_digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD == 22934, + "default digital threshold changed"); + + for (uint32_t trigger = 0; trigger <= UINT16_MAX; ++trigger) { + ControllerState trigger_state{}; + trigger_state.left_trigger = static_cast(trigger); + trigger_state.right_trigger = static_cast(trigger); + const ControllerState output = + controller_profile_transform(trigger_state, profile).state; + require(output.left_trigger == trigger_state.left_trigger && + output.right_trigger == trigger_state.right_trigger, + "default profile lost full trigger analog precision"); + } +} + +void test_rumble_scaling_and_confirmation_policy() { + ControllerProfile profile = default_profile(); + for (uint16_t magnitude = 0; magnitude <= UINT8_MAX; ++magnitude) { + const ControllerRumbleOutput input{ + static_cast(magnitude), + static_cast(UINT8_MAX - magnitude), + }; + const ControllerRumbleOutput output = + controller_profile_scale_host_rumble(input, profile); + require(output.low_frequency_magnitude == + input.low_frequency_magnitude && + output.high_frequency_magnitude == + input.high_frequency_magnitude, + "default rumble scaling was not bit-exact identity"); + } + + profile.strong_rumble_scale = 0; + profile.weak_rumble_scale = 0; + ControllerRumbleOutput output = controller_profile_scale_host_rumble( + {UINT8_MAX, UINT8_MAX}, profile); + require(output.low_frequency_magnitude == 0 && + output.high_frequency_magnitude == 0, + "zero rumble scales did not mute both bands"); + + profile.strong_rumble_scale = 128; + profile.weak_rumble_scale = 64; + output = controller_profile_scale_host_rumble({200, 201}, profile); + require(output.low_frequency_magnitude == 100 && + output.high_frequency_magnitude == 50, + "strong/weak mid-scale rumble mapping was incorrect"); + require(controller_profile_scale_rumble_magnitude(UINT8_MAX, + UINT8_MAX) == + UINT8_MAX, + "full rumble scaling did not saturate at uint8 maximum"); + + profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed; + require(controller_profile_confirmation_policy(profile) == + ControllerProfileConfirmationPolicy::kLed, + "confirmation policy was not exposed unchanged"); +} + +} // namespace + +int main() { + test_button_masks_and_direct_mapping(); + test_stick_center_boundaries_and_inversion(); + test_stick_curves_and_monotonicity(); + test_trigger_boundaries_curves_and_thresholds(); + test_default_whole_state_equivalence(); + test_rumble_scaling_and_confirmation_policy(); + 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/profile_service_test.cpp b/tests/profile_service_test.cpp new file mode 100644 index 0000000..dd132fc --- /dev/null +++ b/tests/profile_service_test.cpp @@ -0,0 +1,412 @@ +#include "controller_identity.h" +#include "controller_profile.h" +#include "pico_profile_storage.h" +#include "profile_service.h" +#include "profile_storage.h" + +#include +#include +#include + +namespace { + +struct FakeFlash { + uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; + int bank_replacements = 0; +}; + +FakeFlash flash{}; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +bool fake_read(void* context, uint8_t bank, size_t offset, + uint8_t* output, size_t size) { + auto* storage = static_cast(context); + if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr || + offset > PROFILE_STORAGE_BANK_SIZE || + size > PROFILE_STORAGE_BANK_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[bank][offset], size); + return true; +} + +bool fake_replace_bank(void* context, uint8_t bank, + const uint8_t* payload, size_t payload_size, + const uint8_t* header, size_t header_size) { + auto* storage = static_cast(context); + if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || + header == nullptr || + payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) { + return false; + } + ++storage->bank_replacements; + memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); + memcpy( + &storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE], + payload, payload_size); + memcpy(storage->bytes[bank], header, header_size); + return memcmp( + &storage->bytes[bank][ + PROFILE_STORAGE_RECORD_HEADER_SIZE], + payload, payload_size) == 0 && + memcmp(storage->bytes[bank], header, header_size) == 0; +} + +ProfileStorageIo fake_io() { + return { + &flash, + PROFILE_STORAGE_BANK_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_replace_bank, + }; +} + +ProfileServiceTransactionSnapshot transaction_snapshot() { + ProfileServiceTransactionSnapshot snapshot{}; + profile_service_transaction_snapshot(&snapshot); + return snapshot; +} + +ProfileServiceActiveProfileSnapshot active_profile_snapshot( + const ControllerIdentity& identity) { + ProfileServiceActiveProfileSnapshot snapshot{}; + profile_service_active_profile_snapshot(identity, &snapshot); + return snapshot; +} + +ControllerProfileDatabase reload_database( + const ProfileServiceTransactionSnapshot& transaction, + uint32_t expected_generation) { + ControllerProfileDatabase recovered{}; + ProfileStorage storage; + require(storage.initialize(fake_io(), &recovered) && + storage.snapshot().valid && + storage.snapshot().generation == expected_generation && + storage.snapshot().generation == + transaction.transaction.stored_generation && + storage.snapshot().payload_crc == + transaction.transaction.stored_crc, + "terminal transaction status did not identify persisted storage"); + return recovered; +} + +void test_pending_commands_are_not_decoded_as_profile_writes() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + profile_service_prepare(); + profile_service_initialize_on_storage_core(); + ProfileServiceActiveProfileSnapshot active = + active_profile_snapshot(controller_identity_global()); + require(active.valid && + active.metadata.state == ProfileServiceState::kReady && + active.metadata.generation == 0 && + profile_service_database_generation() == 0 && + active.profile_index == 0, + "initial active profile snapshot was not coherent"); + + const ControllerIdentity identity = controller_identity_global(); + constexpr uint8_t kProfileIndex = 2; + ControllerProfile customized = + controller_profile_default(identity, kProfileIndex); + customized.strong_rumble_scale = 17; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(customized, encoded, sizeof(encoded)), + "customized profile did not encode"); + + constexpr uint32_t kWriteTransactionId = 0x10203040; + require(profile_service_begin( + kWriteTransactionId, identity, kProfileIndex, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(kWriteTransactionId, 0, encoded, + sizeof(encoded)) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(kWriteTransactionId) == + ConfigurationTransactionStatus::kPending, + "profile write did not reach pending"); + profile_service_task_on_storage_core(0); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "profile write baseline did not commit"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 1 && + profile_service_database_generation() == 1 && + active.profile_index == 0, + "profile write did not publish one coherent generation"); + + constexpr uint32_t kResetTransactionId = 0xa5a55a5a; + require(profile_service_reset(kResetTransactionId, identity, + kProfileIndex) == + ConfigurationTransactionStatus::kPending, + "profile reset did not reach pending"); + ProfileServiceTransactionSnapshot reset = transaction_snapshot(); + require(reset.transaction.transaction_id == kResetTransactionId && + reset.transaction.status == + ConfigurationTransactionStatus::kPending, + "pending reset lost its transaction identity"); + + profile_service_task_on_storage_core(1000); + reset = transaction_snapshot(); + require(reset.transaction.transaction_id == kResetTransactionId && + reset.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "one reset tick decoded profile payload or published a malformed result"); + ControllerProfileDatabase recovered = reload_database(reset, 2); + require(recovered.fallback_profiles[kProfileIndex].strong_rumble_scale == + UINT8_MAX, + "terminal reset status was published before reset persisted"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 2 && + profile_service_database_generation() == 2 && + active.profile_index == 0, + "profile reset did not refresh the active snapshot generation"); + + constexpr uint32_t kActivateTransactionId = 0x50607080; + constexpr uint8_t kActivatedProfile = 3; + require(profile_service_activate(kActivateTransactionId, identity, + kActivatedProfile) == + ConfigurationTransactionStatus::kPending, + "profile activation did not reach pending"); + ProfileServiceTransactionSnapshot activate = transaction_snapshot(); + require(activate.transaction.transaction_id == kActivateTransactionId && + activate.transaction.status == + ConfigurationTransactionStatus::kPending, + "pending activation lost its transaction identity"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 2 && + active.profile_index == 0, + "pending activation leaked an uncommitted active profile"); + + profile_service_task_on_storage_core(2000); + activate = transaction_snapshot(); + require(activate.transaction.transaction_id == kActivateTransactionId && + activate.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "one activation tick decoded profile payload or published a malformed result"); + recovered = reload_database(activate, 3); + require(recovered.fallback_active_profile == kActivatedProfile, + "terminal activation status was published before activation persisted"); + active = active_profile_snapshot(identity); + require(active.valid && active.metadata.generation == 3 && + profile_service_database_generation() == 3 && + active.profile_index == kActivatedProfile && + active.profile.strong_rumble_scale == + recovered.fallback_profiles[kActivatedProfile] + .strong_rumble_scale, + "activation did not publish profile, index, and generation together"); +} + +void test_host_and_controller_mutations_are_serialized() { + const ControllerIdentity identity = controller_identity_global(); + ControllerProfile profile = + controller_profile_default(identity, 1); + profile.weak_rumble_scale = 23; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "serialization fixture profile did not encode"); + + constexpr uint32_t kHostTransactionId = 0x11223344; + constexpr uint32_t kInternalTransactionId = 0x80000019; + require(profile_service_begin( + kHostTransactionId, identity, 1, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving, + "host write did not acquire the profile mutation boundary"); + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kBusy, + "controller activation raced a receiving host write"); + ProfileServiceTransactionSnapshot snapshot = + transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kReceiving, + "busy controller activation replaced the host transaction"); + + require(profile_service_append( + kHostTransactionId, 0, encoded, sizeof(encoded)) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(kHostTransactionId) == + ConfigurationTransactionStatus::kPending, + "host write did not reach pending after controller contention"); + profile_service_task_on_storage_core(3000); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "serialized host write did not commit"); + + constexpr uint32_t kHostActivationTransactionId = 0x22334455; + require(profile_service_activate( + kHostActivationTransactionId, identity, 0) == + ConfigurationTransactionStatus::kPending, + "host activation did not acquire the released boundary"); + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kBusy, + "controller activation raced a pending host activation"); + snapshot = transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kPending && + active_profile_snapshot(identity).profile_index == 3, + "pending host activation was replaced or leaked before commit"); + profile_service_task_on_storage_core(4000); + require(active_profile_snapshot(identity).profile_index == 0, + "serialized host activation did not commit"); + require(profile_service_activate_internal( + 0x19, identity, 2) == + ConfigurationTransactionStatus::kMalformed, + "internal activation admitted a transaction without the high bit"); + + require(profile_service_activate_internal( + kInternalTransactionId, identity, 2) == + ConfigurationTransactionStatus::kPending, + "controller activation did not acquire the released boundary"); + require(profile_service_begin( + 0x55667788, identity, 0, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kBusy, + "host write raced a pending controller activation"); + snapshot = transaction_snapshot(); + require(snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted && + active_profile_snapshot(identity).profile_index == 0, + "pending controller activation replaced host-visible status or leaked before commit"); + + profile_service_task_on_storage_core(5000); + const ProfileServiceActiveProfileSnapshot active = + active_profile_snapshot(identity); + snapshot = transaction_snapshot(); + require(active.valid && active.profile_index == 2 && + snapshot.transaction.transaction_id == + kHostActivationTransactionId && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "controller activation did not publish while preserving host-visible status"); +} + +void test_completed_write_then_dirty_identity_activation() { + const int replacements_before = flash.bank_replacements; + const ControllerIdentity global = controller_identity_global(); + constexpr uint8_t kWrittenProfileIndex = 1; + ControllerProfile customized = + controller_profile_default(global, kWrittenProfileIndex); + customized.strong_rumble_scale = 31; + customized.weak_rumble_scale = 47; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(customized, encoded, sizeof(encoded)), + "sequential mutation fixture profile did not encode"); + + constexpr uint32_t kWriteTransactionId = 0x31415926; + require(profile_service_begin( + kWriteTransactionId, global, kWrittenProfileIndex, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(kWriteTransactionId, 0, encoded, + sizeof(encoded)) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(kWriteTransactionId) == + ConfigurationTransactionStatus::kPending, + "sequential profile write did not reach pending"); + profile_service_task_on_storage_core(6000); + const ProfileServiceTransactionSnapshot written = + transaction_snapshot(); + require(written.transaction.transaction_id == kWriteTransactionId && + written.transaction.status == + ConfigurationTransactionStatus::kCommitted && + flash.bank_replacements == replacements_before + 1, + "completed write lost correlation or used multiple bank replacements"); + + ControllerIdentity connected{}; + connected.stable = true; + connected.transport = ControllerTransport::kClassic; + connected.address[0] = 0x10; + connected.address[1] = 0x20; + connected.address[2] = 0x30; + connected.address[3] = 0x40; + connected.address[4] = 0x50; + connected.address[5] = 0x60; + connected.vendor_id = 0x1234; + connected.product_id = 0xabcd; + require(profile_service_observe_identity_on_storage_core(connected), + "connected identity did not enter the dirty database"); + + constexpr uint32_t kActivateTransactionId = 0x27182818; + constexpr uint8_t kActivatedProfileIndex = 2; + require(profile_service_activate( + kActivateTransactionId, connected, + kActivatedProfileIndex) == + ConfigurationTransactionStatus::kPending, + "activation after completed write did not reach pending"); + const ProfileServiceTransactionSnapshot pending = + transaction_snapshot(); + require(pending.transaction.transaction_id == + kActivateTransactionId && + pending.transaction.status == + ConfigurationTransactionStatus::kPending && + pending.transaction.stored_generation == 0 && + pending.transaction.stored_crc == 0, + "pending activation was not correlated to its own transaction"); + + profile_service_task_on_storage_core(7000); + const ProfileServiceTransactionSnapshot activated = + transaction_snapshot(); + require(activated.transaction.transaction_id == + kActivateTransactionId && + activated.transaction.status == + ConfigurationTransactionStatus::kCommitted && + activated.transaction.stored_generation == + written.transaction.stored_generation + 1 && + flash.bank_replacements == replacements_before + 2, + "activation did not complete as one next correlated bank replacement"); + + const ControllerProfileDatabase recovered = reload_database( + activated, activated.transaction.stored_generation); + const ControllerProfileDatabaseEntry* connected_entry = + controller_profile_database_find(recovered, connected); + require(connected_entry != nullptr && + connected_entry->active_profile == + kActivatedProfileIndex && + recovered.fallback_profiles[kWrittenProfileIndex] + .strong_rumble_scale == + customized.strong_rumble_scale && + recovered.fallback_profiles[kWrittenProfileIndex] + .weak_rumble_scale == + customized.weak_rumble_scale, + "activation did not atomically persist the dirty identity and prior write"); + const ProfileServiceActiveProfileSnapshot active = + active_profile_snapshot(connected); + require(active.valid && + active.metadata.generation == + activated.transaction.stored_generation && + active.profile_index == kActivatedProfileIndex, + "completed activation did not publish the dirty identity"); +} + +} // namespace + +ProfileStorageIo pico_profile_storage_io() { + return fake_io(); +} + +int main() { + test_pending_commands_are_not_decoded_as_profile_writes(); + test_host_and_controller_mutations_are_serialized(); + test_completed_write_then_dirty_identity_activation(); + return 0; +} diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp new file mode 100644 index 0000000..f0d2ef0 --- /dev/null +++ b/tests/profile_storage_test.cpp @@ -0,0 +1,658 @@ +#include "controller_identity.h" +#include "controller_profile.h" +#include "profile_storage.h" +#include "tests/controller_profile_legacy_fixtures.h" + +#include +#include +#include + +namespace { + +struct FakeFlash { + uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; + int successful_programs = 0; + int fail_after_programs = -1; + bool corrupt_next_program = false; + int corrupt_header_padding_offset = -1; + bool fail_reads_after_header_program = false; + bool header_programmed = false; + int erase_count = 0; + int bank_replacements = 0; +}; + +FakeFlash flash{}; +ControllerProfileDatabase database{}; +ControllerProfileDatabase recovered_database{}; +uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{}; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void erase_all() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + flash.successful_programs = 0; + flash.fail_after_programs = -1; + flash.corrupt_next_program = false; + flash.corrupt_header_padding_offset = -1; + flash.fail_reads_after_header_program = false; + flash.header_programmed = false; + flash.erase_count = 0; + flash.bank_replacements = 0; +} + +bool fake_read(void* context, uint8_t bank, size_t offset, + uint8_t* output, size_t size) { + auto* storage = static_cast(context); + if (storage->fail_reads_after_header_program && + storage->header_programmed) { + return false; + } + if (bank >= PROFILE_STORAGE_BANK_COUNT || + offset > PROFILE_STORAGE_BANK_SIZE || + size > PROFILE_STORAGE_BANK_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[bank][offset], size); + return true; +} + +bool fake_replace_bank(void* context, uint8_t bank, + const uint8_t* payload, size_t payload_size, + const uint8_t* header, size_t header_size) { + auto* storage = static_cast(context); + if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr || + header == nullptr || + payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE || + header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) { + return false; + } + + ++storage->bank_replacements; + memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE); + storage->erase_count += + static_cast(PROFILE_STORAGE_SECTORS_PER_BANK); + + uint8_t final_page[PROFILE_STORAGE_PAGE_SIZE]{}; + for (size_t offset = 0; offset < payload_size; + offset += PROFILE_STORAGE_PAGE_SIZE) { + if (storage->fail_after_programs >= 0 && + storage->successful_programs >= + storage->fail_after_programs) { + return false; + } + const size_t remaining = payload_size - offset; + const uint8_t* page = &payload[offset]; + if (remaining < PROFILE_STORAGE_PAGE_SIZE) { + memcpy(final_page, page, remaining); + page = final_page; + } + memcpy( + &storage->bytes[bank][ + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset], + page, PROFILE_STORAGE_PAGE_SIZE); + if (storage->corrupt_next_program) { + storage->bytes[bank][ + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1; + storage->corrupt_next_program = false; + } + ++storage->successful_programs; + } + if (memcmp( + &storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE], + payload, payload_size) != 0) { + return false; + } + if (storage->fail_after_programs >= 0 && + storage->successful_programs >= storage->fail_after_programs) { + return false; + } + + memcpy(storage->bytes[bank], header, header_size); + storage->header_programmed = true; + ++storage->successful_programs; + if (storage->corrupt_header_padding_offset >= 24 && + static_cast( + storage->corrupt_header_padding_offset) < header_size) { + storage->bytes[bank][ + static_cast( + storage->corrupt_header_padding_offset)] ^= 1; + } + return memcmp(storage->bytes[bank], header, header_size) == 0; +} + +ProfileStorageIo fake_io() { + return { + &flash, + PROFILE_STORAGE_BANK_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_replace_bank, + }; +} + +uint16_t fixture_read_u16(const uint8_t* input) { + return static_cast(input[0]) | + static_cast(input[1] << 8); +} + +void fixture_write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void fixture_write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +void install_legacy_database_bank_fixture() { + erase_all(); + constexpr uint8_t kBank = 1; + constexpr uint32_t kGeneration = 41; + constexpr size_t kFallbackOffset = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; + constexpr size_t kEntryOffset = + kFallbackOffset + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + constexpr uint8_t kEntryHeader[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE] = { + 1, 1, 7, 0, 1, 2, 3, 4, 5, 6, 0x7e, 0x05, 0x09, 0x20, 3, 1, + }; + + uint8_t* const record = flash.bytes[kBank]; + uint8_t* const payload = record + PROFILE_STORAGE_RECORD_HEADER_SIZE; + memset(record, 0, PROFILE_STORAGE_RECORD_HEADER_SIZE); + memset(payload, 0, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + + memcpy(payload, "SPDB", 4); + fixture_write_u16(&payload[4], + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); + fixture_write_u16( + &payload[6], + static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); + payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + payload[9] = CONTROLLER_PROFILE_COUNT; + payload[10] = 2; + payload[11] = 1; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint8_t* fixture = + profile_index == 0 + ? kLegacyNarrowRawRangeProfile + : profile_index == 1 ? kLegacyCustomThresholdProfile + : kLegacyDefaultProfile; + memcpy(&payload[kFallbackOffset + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], + fixture, CONTROLLER_PROFILE_ENCODED_SIZE); + } + + memcpy(&payload[kEntryOffset], kEntryHeader, sizeof(kEntryHeader)); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint8_t* fixture = + profile_index == 0 + ? kLegacyNarrowRawRangeProfile + : profile_index == 3 ? kLegacyCustomThresholdProfile + : kLegacyDefaultProfile; + memcpy(&payload[kEntryOffset + + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * CONTROLLER_PROFILE_ENCODED_SIZE], + fixture, CONTROLLER_PROFILE_ENCODED_SIZE); + } + + const uint32_t payload_crc = profile_storage_crc32( + payload, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE); + memcpy(record, "SPPF", 4); + fixture_write_u16(&record[4], 1); + fixture_write_u16(&record[6], + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION); + fixture_write_u32(&record[8], kGeneration); + fixture_write_u32( + &record[12], + static_cast(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)); + fixture_write_u32(&record[16], payload_crc); + fixture_write_u32(&record[20], profile_storage_crc32(record, 20)); +} + +void test_initialize_requires_batch_replacement() { + erase_all(); + ProfileStorageIo io = fake_io(); + io.replace_bank = nullptr; + ProfileStorage storage; + require(!storage.initialize(io, &database), + "profile storage initialized without bank replacement"); +} + +void test_two_bank_recovery() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + require(storage.initialize(fake_io(), &database) && + !storage.snapshot().valid, + "erased profile storage did not initialize empty"); + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk && + storage.snapshot().generation == 1, + "first profile database did not commit"); + const int programs_after_first = flash.successful_programs; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kUnchanged && + flash.successful_programs == programs_after_first, + "unchanged profile database consumed flash writes"); + + database.fallback_profiles[0].button_map[0] = 1; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk && + storage.snapshot().generation == 2, + "second profile database generation did not commit"); + ProfileStorage reloaded; + require(reloaded.initialize(fake_io(), &recovered_database) && + reloaded.snapshot().generation == 2 && + recovered_database.fallback_profiles[0].button_map[0] == 1, + "latest profile database did not survive reload"); + + const uint8_t newest_bank = reloaded.snapshot().active_bank; + flash.bytes[newest_bank][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4] ^= 1; + ProfileStorage after_corruption; + require(after_corruption.initialize(fake_io(), &recovered_database) && + after_corruption.snapshot().generation == 1 && + recovered_database.fallback_profiles[0].button_map[0] == 0, + "corrupt newest profile bank did not roll back"); +} + +void test_interrupted_commit_retains_previous_bank() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + require(storage.initialize(fake_io(), &database) && + storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk, + "interruption baseline did not commit"); + database.fallback_profiles[1].button_map[2] = 3; + flash.fail_after_programs = flash.successful_programs + 1; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kIoError, + "interrupted profile write reported success"); + + flash.fail_after_programs = -1; + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == 1 && + recovered_database.fallback_profiles[1].button_map[2] == 2, + "interrupted profile write replaced previous bank"); +} + +void test_successful_header_program_is_commit_point() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + require(storage.initialize(fake_io(), &database) && + storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk, + "commit-point baseline did not commit"); + + database.fallback_profiles[1].strong_rumble_scale = 17; + flash.header_programmed = false; + flash.fail_reads_after_header_program = true; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk && + storage.snapshot().generation == 2, + "successful header program was rolled back by a later read"); + + flash.fail_reads_after_header_program = false; + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == 2 && + recovered_database.fallback_profiles[1] + .strong_rumble_scale == 17, + "committed header did not recover after transient read failure"); +} + +void test_payload_corruption_prevents_header_publication() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + require(storage.initialize(fake_io(), &database) && + storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk, + "corruption baseline did not commit"); + const ProfileStorageSnapshot previous = storage.snapshot(); + const uint8_t target_bank = previous.active_bank ^ 1u; + const int programs_before_corruption = flash.successful_programs; + constexpr int kPayloadProgramCount = + (CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE + + PROFILE_STORAGE_PAGE_SIZE - 1) / + PROFILE_STORAGE_PAGE_SIZE; + + database.fallback_profiles[1].button_map[2] = 3; + flash.corrupt_next_program = true; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kIoError && + flash.successful_programs == + programs_before_corruption + kPayloadProgramCount, + "corrupt payload programming reached the header program"); + for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE; + ++index) { + require(flash.bytes[target_bank][index] == 0xff, + "rejected corrupt payload published a discoverable header"); + } + require(storage.snapshot().valid == previous.valid && + storage.snapshot().generation == previous.generation && + storage.snapshot().payload_crc == previous.payload_crc && + storage.snapshot().active_bank == previous.active_bank, + "rejected corrupt programming changed the storage snapshot"); + + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == previous.generation && + recovered.snapshot().active_bank == previous.active_bank && + recovered_database.fallback_profiles[1].button_map[2] == 2, + "headerless corrupt payload was recovered"); +} + +void test_batched_bank_replacement_is_one_atomic_operation() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + constexpr int kPayloadProgramCount = + (CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE + + PROFILE_STORAGE_PAGE_SIZE - 1) / + PROFILE_STORAGE_PAGE_SIZE; + require(storage.initialize(fake_io(), &database) && + storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk && + flash.bank_replacements == 1 && + flash.erase_count == static_cast( + PROFILE_STORAGE_SECTORS_PER_BANK) && + flash.successful_programs == + kPayloadProgramCount + 1, + "batched commit did not replace one bank in one operation"); + + const ProfileStorageSnapshot previous = storage.snapshot(); + const uint8_t target_bank = previous.active_bank ^ 1u; + const int programs_before_corruption = flash.successful_programs; + database.fallback_profiles[1].button_map[2] = 3; + flash.corrupt_next_program = true; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kIoError && + flash.bank_replacements == 2 && + flash.successful_programs == + programs_before_corruption + + kPayloadProgramCount, + "corrupt batched payload reached header publication"); + for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE; + ++index) { + require(flash.bytes[target_bank][index] == 0xff, + "failed batched replacement published a header"); + } + require(storage.snapshot().generation == previous.generation && + storage.snapshot().payload_crc == + previous.payload_crc && + storage.snapshot().active_bank == + previous.active_bank, + "failed batched replacement changed the committed snapshot"); + + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == + previous.generation && + recovered.snapshot().active_bank == + previous.active_bank && + recovered_database.fallback_profiles[1] + .button_map[2] == 2, + "headerless batched payload replaced the prior bank"); +} + +void test_header_padding_corruption_fails_commit_and_recovery() { + erase_all(); + controller_profile_database_default(&database); + ProfileStorage storage; + require(storage.initialize(fake_io(), &database) && + storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk, + "header padding baseline did not commit"); + + const ProfileStorageSnapshot previous = storage.snapshot(); + const uint8_t previous_scale = + database.fallback_profiles[1].strong_rumble_scale; + database.fallback_profiles[1].strong_rumble_scale = 17; + for (size_t offset = 24; + offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) { + flash.corrupt_header_padding_offset = + static_cast(offset); + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kIoError, + "corrupt header padding did not fail the commit"); + + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == + previous.generation && + recovered.snapshot().active_bank == + previous.active_bank && + recovered_database.fallback_profiles[1] + .strong_rumble_scale == previous_scale, + "corrupt header padding was accepted on recovery"); + } + + flash.corrupt_header_padding_offset = -1; + require(storage.commit(database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk, + "valid full header page did not commit"); + for (size_t offset = 24; + offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) { + require( + flash.bytes[storage.snapshot().active_bank][offset] == 0, + "valid committed header contained nonzero padding"); + } + ProfileStorage recovered; + require(recovered.initialize(fake_io(), &recovered_database) && + recovered.snapshot().generation == + previous.generation + 1u && + recovered_database.fallback_profiles[1] + .strong_rumble_scale == 17, + "valid full header page was rejected on recovery"); +} + +void test_legacy_database_bank_migration() { + install_legacy_database_bank_fixture(); + ProfileStorage storage; + require(storage.initialize(fake_io(), &recovered_database) && + storage.snapshot().valid && + storage.snapshot().active_bank == 1 && + storage.snapshot().generation == 41, + "legacy v1 database bank was not selected"); + require(flash.erase_count == 0, + "legacy bank admission erased flash"); + require(recovered_database.fallback_active_profile == 2, + "legacy fallback active profile was not preserved"); + + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint16_t expected_left = + profile_index == 1 + ? 0x1234 + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + const uint16_t expected_right = + profile_index == 1 + ? 0xabcd + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + require(recovered_database.fallback_profiles[profile_index] + .triggers[0] + .digital_threshold == expected_left && + recovered_database.fallback_profiles[profile_index] + .triggers[1] + .digital_threshold == expected_right, + "legacy fallback thresholds were not selectively migrated"); + } + require(recovered_database.fallback_profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + recovered_database.fallback_profiles[0] + .triggers[0] + .upper_saturation == 40000 && + recovered_database.fallback_profiles[0] + .triggers[1] + .lower_deadzone == 30000 && + recovered_database.fallback_profiles[0] + .triggers[1] + .upper_saturation == 40000, + "legacy fallback raw trigger ranges were not preserved"); + + const ControllerProfileDatabaseEntry& entry = + recovered_database.entries[0]; + require(entry.used && entry.active_profile == 3 && + entry.identity.stable && + entry.identity.transport == ControllerTransport::kClassic && + entry.identity.address_type == 7 && + entry.identity.address[0] == 1 && + entry.identity.address[5] == 6 && + entry.identity.vendor_id == 0x057e && + entry.identity.product_id == 0x2009, + "legacy entry identity or active profile was not preserved"); + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + const uint16_t expected_left = + profile_index == 3 + ? 0x1234 + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + const uint16_t expected_right = + profile_index == 3 + ? 0xabcd + : CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + require(entry.profiles[profile_index] + .triggers[0] + .digital_threshold == expected_left && + entry.profiles[profile_index] + .triggers[1] + .digital_threshold == expected_right, + "legacy entry thresholds were not selectively migrated"); + } + require(entry.profiles[0].triggers[0].lower_deadzone == 30000 && + entry.profiles[0].triggers[0].upper_saturation == 40000 && + entry.profiles[0].triggers[1].lower_deadzone == 30000 && + entry.profiles[0].triggers[1].upper_saturation == 40000, + "legacy entry raw trigger ranges were not preserved"); + + recovered_database.fallback_profiles[2].weak_rumble_scale = 17; + require(storage.commit(recovered_database, encoded_database, + sizeof(encoded_database)) == + ProfileStorageResult::kOk && + storage.snapshot().active_bank == 0 && + storage.snapshot().generation == 42, + "mutation after legacy admission did not commit"); + const uint8_t* const current_record = flash.bytes[0]; + const uint8_t* const current_payload = + current_record + PROFILE_STORAGE_RECORD_HEADER_SIZE; + require(fixture_read_u16(¤t_record[6]) == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && + fixture_read_u16(¤t_payload[4]) == + CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION, + "post-migration commit did not emit v2 storage schemas"); + constexpr size_t kFallbackOffset = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE; + constexpr size_t kEntryOffset = + kFallbackOffset + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; + for (uint8_t profile_index = 0; + profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + require(fixture_read_u16( + ¤t_payload[kFallbackOffset + + profile_index * + CONTROLLER_PROFILE_ENCODED_SIZE]) == + CONTROLLER_PROFILE_SCHEMA_VERSION && + fixture_read_u16( + ¤t_payload[ + kEntryOffset + + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_index * + CONTROLLER_PROFILE_ENCODED_SIZE]) == + CONTROLLER_PROFILE_SCHEMA_VERSION, + "post-migration commit retained a v1 profile"); + } + require(fixture_read_u16(&flash.bytes[1][6]) == + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + fixture_read_u16( + &flash.bytes[1][PROFILE_STORAGE_RECORD_HEADER_SIZE + 4]) == + CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION, + "post-migration commit erased or rewrote the admitted legacy bank"); + + ProfileStorage reloaded; + require(reloaded.initialize(fake_io(), &database) && + reloaded.snapshot().generation == 42 && + database.fallback_profiles[2].weak_rumble_scale == 17 && + database.fallback_profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + database.fallback_profiles[0] + .triggers[0] + .upper_saturation == 40000 && + database.fallback_profiles[0] + .triggers[0] + .digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + database.fallback_profiles[1] + .triggers[0] + .digital_threshold == 0x1234 && + database.fallback_profiles[1] + .triggers[1] + .digital_threshold == 0xabcd && + database.entries[0].used && + database.entries[0].active_profile == 3 && + database.entries[0] + .profiles[0] + .triggers[0] + .lower_deadzone == 30000 && + database.entries[0] + .profiles[0] + .triggers[0] + .upper_saturation == 40000 && + database.entries[0] + .profiles[0] + .triggers[0] + .digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD && + database.entries[0] + .profiles[3] + .triggers[0] + .digital_threshold == 0x1234 && + database.entries[0] + .profiles[3] + .triggers[1] + .digital_threshold == 0xabcd, + "v2 migration commit did not reload without data loss"); +} + +} // namespace +int main() { + test_two_bank_recovery(); + test_initialize_requires_batch_replacement(); + test_interrupted_commit_retains_previous_bank(); + test_successful_header_program_is_commit_point(); + test_payload_corruption_prevents_header_publication(); + test_batched_bank_replacement_is_one_atomic_operation(); + test_header_padding_corruption_fails_commit_and_recovery(); + test_legacy_database_bank_migration(); + return 0; +} diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index 3130f38..ce7dfac 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -289,7 +289,9 @@ void test_input_reports_and_timers_are_isolated() { states[3].button_west = true; for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { - switch_pro_set_input(instance, states[instance]); + switch_pro_set_input(instance, states[instance], + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); } now_ms = 15; @@ -328,7 +330,9 @@ void test_input_reports_and_timers_are_isolated() { ControllerState changed_zero = states[0]; changed_zero.button_east = false; changed_zero.button_system = true; - switch_pro_set_input(0, changed_zero); + switch_pro_set_input(0, changed_zero, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 30; expect(switch_pro_task(0), "instance 0 did not apply its changed input state"); @@ -341,7 +345,9 @@ void test_input_reports_and_timers_are_isolated() { ControllerState changed_three = states[3]; changed_three.button_west = false; changed_three.button_capture = true; - switch_pro_set_input(3, changed_three); + switch_pro_set_input(3, changed_three, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 45; expect(switch_pro_task(3), "instance 3 did not apply its changed input state"); @@ -370,8 +376,10 @@ void test_callback_send_and_imu_modes_are_isolated() { ControllerState one = zero; one.button_north = true; one.motion_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006}; - switch_pro_set_input(0, zero); - switch_pro_set_input(1, one); + switch_pro_set_input(0, zero, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + switch_pro_set_input(1, one, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 21; expect(switch_pro_task(0), "raw-IMU instance did not send input"); expect(switch_pro_task(1), "off-IMU instance timer did not send input"); @@ -400,8 +408,11 @@ void test_callback_send_and_imu_modes_are_isolated() { stationary.right_stick_x = stationary.right_stick_y = 0; stationary.motion_sample_count = 1; stationary.motion_samples[0] = {1000, 2000, 3000, 0, 0, 0}; - switch_pro_set_input(0, moving); - switch_pro_set_input(1, stationary); + switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + switch_pro_set_input(1, stationary, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 21; expect(switch_pro_task(0), "moving quaternion instance did not report"); expect(switch_pro_task(1), "stationary quaternion timer crossed instances"); @@ -578,7 +589,9 @@ void test_lifecycle_and_invalid_instances() { ControllerState ignored{}; ignored.button_system = true; switch_pro_init(kInvalidInstance); - switch_pro_set_input(kInvalidInstance, ignored); + switch_pro_set_input(kInvalidInstance, ignored, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback); expect(!switch_pro_task(kInvalidInstance), "invalid instance ran a driver task"); @@ -598,7 +611,8 @@ void test_protocol_neutral_trigger_threshold() { state.left_trigger = static_cast(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD - 1u); state.right_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; - switch_pro_set_input(0, state); + switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 15; expect(switch_pro_task(0), "trigger threshold report was not sent"); SwitchProReport report = copy_switch_report(latest_regular_report(0)); @@ -607,7 +621,8 @@ void test_protocol_neutral_trigger_threshold() { state.left_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD; state.right_trigger = CONTROLLER_TRIGGER_MIN; - switch_pro_set_input(0, state); + switch_pro_set_input(0, state, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 30; expect(switch_pro_task(0), "second trigger threshold report was not sent"); report = copy_switch_report(latest_regular_report(0)); @@ -615,13 +630,36 @@ void test_protocol_neutral_trigger_threshold() { "Switch trigger threshold changed at the upper boundary"); } +void test_custom_trigger_thresholds_are_isolated() { + initialize_contexts(); + ControllerState state{}; + state.left_trigger = 300; + state.right_trigger = 300; + switch_pro_set_input(0, state, 300, 301); + switch_pro_set_input(1, state, 301, 300); + + now_ms = 15; + expect(switch_pro_task(0) && switch_pro_task(1), + "custom trigger threshold reports were not sent"); + const SwitchProReport first = + copy_switch_report(latest_regular_report(0)); + const SwitchProReport second = + copy_switch_report(latest_regular_report(1)); + expect(first.inputs.buttonZL && !first.inputs.buttonZR, + "instance 0 did not use its exact left/right trigger thresholds"); + expect(!second.inputs.buttonZL && second.inputs.buttonZR, + "instance 1 trigger thresholds crossed HID contexts"); +} + void test_uart_parser_is_pure() { initialize_contexts(); ControllerState driver_state{}; driver_state.left_stick_x = driver_state.left_stick_y = driver_state.right_stick_x = driver_state.right_stick_y = 0; driver_state.button_north = true; - switch_pro_set_input(0, driver_state); + switch_pro_set_input(0, driver_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); now_ms = 15; switch_pro_task(0); @@ -724,6 +762,7 @@ int main() { test_grip_colors_are_isolated(); test_lifecycle_and_invalid_instances(); test_protocol_neutral_trigger_threshold(); + test_custom_trigger_thresholds_are_isolated(); test_uart_parser_is_pure(); if (failures != 0) { std::cerr << failures << " driver context test(s) failed\n"; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 7484f39..53157a8 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -42,11 +42,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "lifecycle", "pairing-policy", "slot-lighting", - "abxy-hotkey", + "profile-chord-raw", + "profile-feedback", + "stateful-rumble", "motion-hotkey", "analog-state", "rumble-mode", "clear-pairings", + "configuration-timer", "flash-core-start", "flash-core-failure", ): diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..48ef299 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,208 @@ +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +SPEC = importlib.util.spec_from_file_location("switch_pico_build", ROOT / "build.py") +build_script = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(build_script) + + +def make_sdk(path): + path.mkdir(parents=True) + (path / "pico_sdk_init.cmake").touch() + return path + + +def make_toolchain(path): + bin_dir = path / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / build_script.TOOLCHAIN_COMPILER).touch() + return path + + +def no_compiler(_name, *, path): + return None + + +def test_parse_cmake_cache_ignores_comments_and_malformed_lines(tmp_path): + cache = tmp_path / "CMakeCache.txt" + cache.write_text( + "// comment\n" + "# internal comment\n" + "PICO_SDK_PATH:PATH=/sdk\n" + "VALUE_WITH_EQUALS:STRING=left=right\n" + "malformed\n", + encoding="utf-8", + ) + + assert build_script.parse_cmake_cache(cache) == { + "PICO_SDK_PATH": "/sdk", + "VALUE_WITH_EQUALS": "left=right", + } + + +def test_explicit_environment_wins_over_cache_and_fallback(tmp_path): + explicit_sdk = make_sdk(tmp_path / "explicit-sdk") + explicit_toolchain = make_toolchain(tmp_path / "explicit-toolchain") + cached_sdk = make_sdk(tmp_path / "cached-sdk") + cached_toolchain = make_toolchain(tmp_path / "cached-toolchain") + fallback_sdk = make_sdk(tmp_path / "fallback-sdk") + fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain") + cache = tmp_path / "build" / "CMakeCache.txt" + cache.parent.mkdir() + cache.write_text( + f"PICO_SDK_PATH:PATH={cached_sdk}\n" + f"PICO_TOOLCHAIN_PATH:PATH={cached_toolchain}\n", + encoding="utf-8", + ) + environ = { + "PATH": "", + "PICO_SDK_PATH": str(explicit_sdk), + "PICO_TOOLCHAIN_PATH": str(explicit_toolchain), + } + + updates = build_script.configure_pico_environment( + environ=environ, + cache_paths=[cache], + sdk_candidates=[("fallback", fallback_sdk)], + toolchain_candidates=[("fallback", fallback_toolchain)], + which=no_compiler, + ) + + assert updates == {} + assert environ["PICO_SDK_PATH"] == str(explicit_sdk) + assert environ["PICO_TOOLCHAIN_PATH"] == str(explicit_toolchain) + + +@pytest.mark.parametrize( + ("variable", "invalid", "expected"), + [ + ("PICO_SDK_PATH", "stale-sdk", "pico_sdk_init.cmake"), + ( + "PICO_TOOLCHAIN_PATH", + "stale-toolchain", + f"bin/{build_script.TOOLCHAIN_COMPILER}", + ), + ], +) +def test_stale_explicit_environment_is_an_error( + tmp_path, variable, invalid, expected +): + sdk = make_sdk(tmp_path / "sdk") + toolchain = make_toolchain(tmp_path / "toolchain") + environ = { + "PATH": "", + "PICO_SDK_PATH": str(sdk), + "PICO_TOOLCHAIN_PATH": str(toolchain), + variable: str(tmp_path / invalid), + } + + with pytest.raises(build_script.BuildEnvironmentError) as error: + build_script.configure_pico_environment( + environ=environ, + cache_paths=[], + sdk_candidates=[], + toolchain_candidates=[], + which=no_compiler, + ) + + assert variable in str(error.value) + assert expected in str(error.value) + + +def test_cache_paths_win_over_fallbacks(tmp_path, capsys): + cached_sdk = make_sdk(tmp_path / "cached-sdk") + cached_toolchain = make_toolchain(tmp_path / "cached-toolchain") + fallback_sdk = make_sdk(tmp_path / "fallback-sdk") + fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain") + cache = tmp_path / "build" / "CMakeCache.txt" + cache.parent.mkdir() + cache.write_text( + f"PICO_SDK_PATH:PATH={cached_sdk}\n" + f"CMAKE_C_COMPILER:FILEPATH=" + f"{cached_toolchain / 'bin' / build_script.TOOLCHAIN_COMPILER}\n", + encoding="utf-8", + ) + environ = {"PATH": ""} + + updates = build_script.configure_pico_environment( + environ=environ, + cache_paths=[cache], + sdk_candidates=[("fallback", fallback_sdk)], + toolchain_candidates=[("fallback", fallback_toolchain)], + which=no_compiler, + ) + + assert updates == { + "PICO_SDK_PATH": str(cached_sdk), + "PICO_TOOLCHAIN_PATH": str(cached_toolchain), + } + output = capsys.readouterr().out + assert f"Auto-detected PICO_SDK_PATH={cached_sdk}" in output + assert f"Auto-detected PICO_TOOLCHAIN_PATH={cached_toolchain}" in output + assert str(cache) in output + + +def test_project_local_fallback_ignores_stale_cache(tmp_path, monkeypatch): + project_build = tmp_path / "build" + local_sdk = make_sdk(project_build / "_deps" / "pico_sdk-src") + local_toolchain = make_toolchain(project_build / "toolchain") + cache = tmp_path / "old-build" / "CMakeCache.txt" + cache.parent.mkdir() + cache.write_text( + f"PICO_SDK_PATH:PATH={tmp_path / 'missing-sdk'}\n" + f"PICO_TOOLCHAIN_PATH:PATH={tmp_path / 'missing-toolchain'}\n", + encoding="utf-8", + ) + monkeypatch.setattr(build_script, "BUILD_DIR", project_build) + environ = {"PATH": ""} + + updates = build_script.configure_pico_environment( + environ=environ, + cache_paths=[cache], + which=no_compiler, + ) + + assert updates == { + "PICO_SDK_PATH": str(local_sdk), + "PICO_TOOLCHAIN_PATH": str(local_toolchain), + } + + +def test_compiler_on_path_avoids_toolchain_override(tmp_path): + sdk = make_sdk(tmp_path / "sdk") + compiler = tmp_path / "path-bin" / build_script.TOOLCHAIN_COMPILER + environ = {"PATH": str(compiler.parent), "PICO_SDK_PATH": str(sdk)} + + updates = build_script.configure_pico_environment( + environ=environ, + cache_paths=[], + sdk_candidates=[], + toolchain_candidates=[], + which=lambda name, *, path: str(compiler), + ) + + assert updates == {} + assert "PICO_TOOLCHAIN_PATH" not in environ + + +def test_missing_dependencies_name_only_actionable_overrides(): + environ = {"PATH": ""} + + with pytest.raises(build_script.BuildEnvironmentError) as error: + build_script.configure_pico_environment( + environ=environ, + cache_paths=[], + sdk_candidates=[], + toolchain_candidates=[], + which=no_compiler, + ) + + assert str(error.value) == ( + "Missing build prerequisite(s): Pico SDK (set PICO_SDK_PATH); " + "Arm GNU toolchain (set PICO_TOOLCHAIN_PATH or add " + "arm-none-eabi-gcc to PATH)." + ) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index b002165..0086969 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json import struct import zlib +from pathlib import Path import pytest @@ -56,7 +58,45 @@ class FakeDevice: ), ] self.pairing_generation = 4 + self.global_identity = config_manager.ControllerIdentity.global_fallback() + self.stable_identity = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_CLASSIC, + 0, + bytes.fromhex("102030405060"), + 0x045E, + 0x02FD, + ) + self.profile_identities = [ + self.global_identity, + self.stable_identity, + ] + self.active_profiles = { + identity.to_bytes(): index + for identity, index in zip(self.profile_identities, (0, 1)) + } + default_profile = config_manager.ControllerProfile.default().to_bytes() + self.profiles = { + (identity.to_bytes(), index): default_profile + for identity in self.profile_identities + for index in range(config_manager.PROFILE_CAPACITY) + } + self.selected_profile = (self.global_identity.to_bytes(), 0) + self.profile_generation = 7 + self.profile_transaction_id = 0 + self.profile_transaction_identity = self.global_identity.to_bytes() + self.profile_transaction_index = 0 + self.profile_transaction_payload = bytearray() + self.profile_transaction_expected_size = 0 + self.profile_transaction_expected_crc = 0 + self.profile_transaction_status = config_manager.STATUS_OK + self.fail_profile_commit_status: int | None = None + self.bad_profile_response_crc = False self.requests: list[int] = [] + self.profile_chunk_sizes: list[int] = [] + self.pending_profile_mutation: tuple[int, bytes, int] | None = None + self.profile_transaction_pending_reads = 0 + self.profile_status_responses: list[tuple[int, int]] = [] def _pairing_payload(self) -> bytes: payload = bytearray([len(self.records), 0, 0, 0]) @@ -77,6 +117,71 @@ class FakeDevice: stored_crc, ) + def _profile_list_payload(self) -> bytes: + payload = bytearray([len(self.profile_identities)]) + for identity in self.profile_identities: + payload.extend(identity.to_bytes()) + payload.extend((self.active_profiles[identity.to_bytes()], 0)) + return bytes(payload) + + def _profile_transaction_payload(self) -> bytes: + stored = self.profiles.get( + ( + self.profile_transaction_identity, + self.profile_transaction_index, + ), + bytes(config_manager.PROFILE_SIZE), + ) + return struct.pack( + " None: + assert len(payload) == 19 + self.profile_transaction_id = struct.unpack_from(" None: + assert self.pending_profile_mutation is not None + operation, identity, profile_index = self.pending_profile_mutation + if self.fail_profile_commit_status is not None: + self.profile_transaction_status = self.fail_profile_commit_status + self.pending_profile_mutation = None + return + if operation == config_manager.OP_PROFILE_RESET: + indices = ( + range(config_manager.PROFILE_CAPACITY) + if profile_index == config_manager.PROFILE_NONE_BUTTON + else (profile_index,) + ) + default = config_manager.ControllerProfile.default().to_bytes() + for reset_index in indices: + self.profiles[(identity, reset_index)] = default + else: + assert operation == config_manager.OP_PROFILE_ACTIVATE + self.active_profiles[identity] = profile_index + self.profile_generation += 1 + self.profile_transaction_status = config_manager.STATUS_OK + self.pending_profile_mutation = None + def ctrl_transfer( self, bm_request_type: int, @@ -92,7 +197,9 @@ class FakeDevice: self.requests.append(request) if bm_request_type == 0xC0: if request == config_manager.OP_INFO: - return make_response(request, bytes([0, 2, 0, 2, 0, 0, 0, 2])) + return make_response( + request, bytes([0, 2, 0, 2, 0, 0, 0, 2]) + ) if request == config_manager.OP_CONFIGURATION_READ: return make_response( request, @@ -114,6 +221,44 @@ class FakeDevice: self._pairing_payload(), generation=self.pairing_generation, ) + if request == config_manager.OP_PROFILE_LIST: + return make_response( + request, + self._profile_list_payload(), + schema=config_manager.PROFILE_SCHEMA_VERSION, + generation=self.profile_generation, + ) + if request == config_manager.OP_PROFILE_READ: + response = bytearray( + make_response( + request, + self.profiles[self.selected_profile], + schema=config_manager.PROFILE_SCHEMA_VERSION, + generation=self.profile_generation, + ) + ) + if self.bad_profile_response_crc: + response[-1] ^= 1 + return bytes(response) + if request == config_manager.OP_PROFILE_TRANSACTION_STATUS: + if self.profile_transaction_status == config_manager.STATUS_PENDING: + if self.profile_transaction_pending_reads: + self.profile_transaction_pending_reads -= 1 + elif self.pending_profile_mutation is not None: + self._complete_profile_mutation() + self.profile_status_responses.append( + ( + self.profile_transaction_id, + self.profile_transaction_status, + ) + ) + return make_response( + request, + self._profile_transaction_payload(), + status=self.profile_transaction_status, + schema=config_manager.PROFILE_SCHEMA_VERSION, + generation=self.profile_generation, + ) raise AssertionError(f"unexpected IN request {request}") assert bm_request_type == 0x40 @@ -165,11 +310,138 @@ class FakeDevice: elif request == config_manager.OP_PAIRING_CLEAR: self.records = [] self.pairing_generation += 1 + elif request == config_manager.OP_PROFILE_SELECT: + assert len(payload) == 15 + self.selected_profile = (payload[:14], payload[14]) + assert self.selected_profile in self.profiles + elif request == config_manager.OP_PROFILE_BEGIN: + assert len(payload) == 28 + self.profile_transaction_id = struct.unpack_from(" config_manager.ControllerProfile: + return config_manager.ControllerProfile( + button_map=( + 1, + 0, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + config_manager.PROFILE_NONE_BUTTON, + ), + left_stick=config_manager.StickConfig( + -123, 456, 1000, 30000, 384, True, False + ), + right_stick=config_manager.StickConfig( + 789, -321, 500, 31000, 192, False, True + ), + left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000), + right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000), + weak_rumble_scale=77, + strong_rumble_scale=201, + confirmation_policy=2, + switching_chord=(1 << 6) | (1 << 7), + macro_trigger=0, + macro_cancel=1, + 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, + ), + config_manager.MacroStep.end(), + ), + turbo_modes=(0, 1, 2) + (0,) * 13, + ) + + def test_response_validation() -> None: payload = make_response(config_manager.OP_INFO, b"12345678") envelope = config_manager.parse_response(payload, config_manager.OP_INFO) @@ -205,6 +477,500 @@ def test_configuration_transaction_and_reset() -> None: assert config_manager.read_configuration(device).pairing_window_seconds == 60 +def test_identity_and_profile_binary_json_round_trip() -> None: + identity = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_BLE, + 3, + bytes.fromhex("A1B2C3D4E5F6"), + 0x1234, + 0xABCD, + ) + encoded_identity = identity.to_bytes() + assert encoded_identity == bytes.fromhex( + "01020300A1B2C3D4E5F63412CDAB" + ) + assert config_manager.ControllerIdentity.from_bytes(encoded_identity) == identity + assert ( + config_manager.ControllerIdentity.global_fallback().to_bytes() + == bytes(config_manager.CONTROLLER_IDENTITY_SIZE) + ) + malformed_identity = bytearray(encoded_identity) + malformed_identity[3] = 1 + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerIdentity.from_bytes(malformed_identity) + + default_profile = config_manager.ControllerProfile.default() + assert default_profile.left_trigger.digital_threshold == 22934 + assert default_profile.right_trigger.digital_threshold == 22934 + default_wire = default_profile.to_bytes() + assert struct.unpack_from(" None: + for threshold in (0, 0xFFFF): + trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold) + assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger + assert ( + config_manager.TriggerConfig.from_json_object( + trigger.to_json_object(), "trigger" + ) + == trigger + ) + + current_wire = bytearray( + config_manager.ControllerProfile.default().to_bytes() + ) + struct.pack_into( + " None: + args = config_manager.build_parser().parse_args(["profiles", "list"]) + assert ( + args.timeout + == config_manager.DEFAULT_OPERATION_TIMEOUT_SECONDS + == 15.0 + ) + + +def test_profile_list_select_read_and_chunked_commit() -> None: + device = FakeDevice() + entries = config_manager.list_profiles(device) + assert entries == ( + config_manager.ProfileListEntry(device.global_identity, 0), + config_manager.ProfileListEntry(device.stable_identity, 1), + ) + assert ( + config_manager.read_profile(device, device.stable_identity, 1) + == config_manager.ControllerProfile.default() + ) + assert device.requests[-2:] == [ + config_manager.OP_PROFILE_SELECT, + config_manager.OP_PROFILE_READ, + ] + + profile = custom_profile() + status = config_manager.write_profile( + device, device.stable_identity, 2, profile, 1.0 + ) + assert status.status == config_manager.STATUS_OK + assert status.stored_generation == 8 + assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16] + assert config_manager.OP_PROFILE_TRANSACTION_STATUS in device.requests + assert ( + config_manager.read_profile(device, device.stable_identity, 2) + == profile + ) + + +def test_profile_reset_and_activate_wait_for_correlated_transactions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + generated_ids = iter((0, 0xA5A55A5A)) + monkeypatch.setattr( + config_manager.secrets, "randbits", lambda _bits: next(generated_ids) + ) + identity = device.stable_identity + profile_key = (identity.to_bytes(), 2) + device.profiles[profile_key] = custom_profile().to_bytes() + + reset = config_manager.reset_profile(device, identity, 2, 1.0) + + assert reset.transaction_id == device.profile_transaction_id == 1 + assert reset.status == config_manager.STATUS_OK + assert reset.stored_generation == 8 + assert device.profile_status_responses == [ + (1, config_manager.STATUS_PENDING), + (1, config_manager.STATUS_OK), + ] + assert ( + device.profiles[profile_key] + == config_manager.ControllerProfile.default().to_bytes() + ) + + device.profile_status_responses.clear() + activated = config_manager.activate_profile(device, identity, 3, 1.0) + + assert ( + activated.transaction_id + == device.profile_transaction_id + == 0xA5A55A5A + ) + assert activated.status == config_manager.STATUS_OK + assert activated.stored_generation == 9 + assert device.profile_status_responses == [ + (0xA5A55A5A, config_manager.STATUS_PENDING), + (0xA5A55A5A, config_manager.STATUS_OK), + ] + assert device.active_profiles[identity.to_bytes()] == 3 + + +def test_profile_cli_surfaces_late_storage_failure( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + device.fail_profile_commit_status = 8 + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + previous_active = device.active_profiles[device.stable_identity.to_bytes()] + + assert ( + config_manager.main( + ["profiles", "activate", "4", "--identity", "1"] + ) + == 1 + ) + + output = capsys.readouterr() + assert output.out == "" + assert "storage failure" in output.err + assert ( + device.active_profiles[device.stable_identity.to_bytes()] + == previous_active + ) + assert device.profile_status_responses == [ + (device.profile_transaction_id, config_manager.STATUS_PENDING), + (device.profile_transaction_id, 8), + ] + + +def test_profile_cli_json_round_trip_activate_and_reset( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + device = FakeDevice() + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + export_path = tmp_path / "profile.json" + + assert config_manager.main(["profiles", "list"]) == 0 + output = capsys.readouterr().out + assert "0: global fallback (active profile 1)" in output + assert "1: Classic 10:20:30:40:50:60" in output + + device.profiles[(device.stable_identity.to_bytes(), 1)] = ( + custom_profile().to_bytes() + ) + assert ( + config_manager.main( + [ + "profiles", + "export", + "2", + str(export_path), + "--identity", + "1", + ] + ) + == 0 + ) + _ = capsys.readouterr() + exported = config_manager.ControllerProfile.from_json( + export_path.read_text(encoding="utf-8") + ) + assert exported == custom_profile() + + device.profiles[(device.stable_identity.to_bytes(), 1)] = ( + config_manager.ControllerProfile.default().to_bytes() + ) + assert ( + config_manager.main( + [ + "profiles", + "import", + "2", + str(export_path), + "--identity", + "1", + ] + ) + == 0 + ) + assert ( + device.profiles[(device.stable_identity.to_bytes(), 1)] + == custom_profile().to_bytes() + ) + _ = capsys.readouterr() + + assert ( + config_manager.main( + ["profiles", "activate", "4", "--identity", "1"] + ) + == 0 + ) + assert device.active_profiles[device.stable_identity.to_bytes()] == 3 + _ = capsys.readouterr() + + before_reset_requests = len(device.requests) + assert ( + config_manager.main( + ["profiles", "reset", "2", "--identity", "1"] + ) + == 2 + ) + assert "requires --yes" in capsys.readouterr().err + assert len(device.requests) == before_reset_requests + + assert ( + config_manager.main( + [ + "profiles", + "reset", + "2", + "--identity", + "1", + "--yes", + ] + ) + == 0 + ) + assert ( + device.profiles[(device.stable_identity.to_bytes(), 1)] + == config_manager.ControllerProfile.default().to_bytes() + ) + _ = capsys.readouterr() + assert ( + config_manager.main( + [ + "profiles", + "reset", + "all", + "--identity", + "1", + "--yes", + ] + ) + == 0 + ) + + +def test_malformed_profiles_are_rejected_before_usb( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes()) + malformed_binary[75] = 1 + with pytest.raises( + config_manager.ConfigManagerError, match="reserved fields" + ): + config_manager.ControllerProfile.from_bytes(malformed_binary) + + profile_object = config_manager.ControllerProfile.default().to_json_object() + del profile_object["turbo"] + missing_path = tmp_path / "missing.json" + missing_path.write_text(json.dumps(profile_object), encoding="utf-8") + + profile_object = config_manager.ControllerProfile.default().to_json_object() + profile_object["reserved"] = 0 + unknown_path = tmp_path / "unknown.json" + unknown_path.write_text(json.dumps(profile_object), encoding="utf-8") + + profile_object = config_manager.ControllerProfile.default().to_json_object() + profile_object["rumble"]["confirmation_policy"] = "invalid" + enum_path = tmp_path / "enum.json" + enum_path.write_text(json.dumps(profile_object), encoding="utf-8") + + profile_object = config_manager.ControllerProfile.default().to_json_object() + profile_object["sticks"]["left"]["outer_saturation"] = 0 + range_path = tmp_path / "range.json" + range_path.write_text(json.dumps(profile_object), encoding="utf-8") + + usb_lookups = 0 + + def candidates() -> list[FakeDevice]: + nonlocal usb_lookups + usb_lookups += 1 + return [FakeDevice()] + + monkeypatch.setattr(config_manager, "_candidate_devices", candidates) + for path in (missing_path, unknown_path, enum_path, range_path): + assert ( + config_manager.main(["profiles", "import", "1", str(path)]) == 1 + ) + assert "error:" in capsys.readouterr().err + assert usb_lookups == 0 + + +def test_profile_crc_status_failures_and_identity_bounds( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + device = FakeDevice() + device.bad_profile_response_crc = True + with pytest.raises( + config_manager.ConfigManagerError, match="response CRC mismatch" + ): + config_manager.read_profile(device, device.global_identity, 0) + + failing_device = FakeDevice() + failing_device.fail_profile_commit_status = 6 + with pytest.raises(config_manager.ConfigManagerError, match="CRC mismatch"): + config_manager.write_profile( + failing_device, + failing_device.global_identity, + 0, + custom_profile(), + 1.0, + ) + + bounded_device = FakeDevice() + monkeypatch.setattr( + config_manager, "_candidate_devices", lambda: [bounded_device] + ) + assert ( + config_manager.main( + [ + "profiles", + "export", + "1", + str(tmp_path / "unused.json"), + "--identity", + "2", + ] + ) + == 1 + ) + assert "identity index 2 is out of range" in capsys.readouterr().err + assert config_manager.OP_PROFILE_SELECT not in bounded_device.requests + + def test_status_and_pairing_commands( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_controller_profile_native.py b/tests/test_controller_profile_native.py new file mode 100644 index 0000000..3cf5575 --- /dev/null +++ b/tests/test_controller_profile_native.py @@ -0,0 +1,30 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_profile_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_profile_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_profile_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_controller_profile_runtime_native.py b/tests/test_controller_profile_runtime_native.py new file mode 100644 index 0000000..0fa353d --- /dev/null +++ b/tests/test_controller_profile_runtime_native.py @@ -0,0 +1,33 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_profile_runtime_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_profile_runtime_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_profile_runtime_test.cpp"), + 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), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_controller_profile_transform_native.py b/tests/test_controller_profile_transform_native.py new file mode 100644 index 0000000..474cdb6 --- /dev/null +++ b/tests/test_controller_profile_transform_native.py @@ -0,0 +1,31 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_profile_transform_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_profile_transform_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "controller_profile_transform_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "controller_profile_transform.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) 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) diff --git a/tests/test_profile_service_native.py b/tests/test_profile_service_native.py new file mode 100644 index 0000000..20fd48b --- /dev/null +++ b/tests/test_profile_service_native.py @@ -0,0 +1,33 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_profile_service_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 / "profile_service_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{root}", + str(root / "tests" / "profile_service_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "profile_storage.cpp"), + str(root / "profile_service.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_profile_storage_native.py b/tests/test_profile_storage_native.py new file mode 100644 index 0000000..80e204f --- /dev/null +++ b/tests/test_profile_storage_native.py @@ -0,0 +1,31 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_profile_storage_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 / "profile_storage_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "profile_storage_test.cpp"), + str(root / "controller_identity.cpp"), + str(root / "controller_profile.cpp"), + str(root / "profile_storage.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 1ddf306..27d9e2c 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -11,6 +11,9 @@ namespace { Bluepad32PairingSnapshot current_pairings{}; ConfigurationServiceSnapshot current_configuration{}; +ProfileServiceListSnapshot current_profile_list{}; +ProfileServiceSelectedSnapshot current_profile_selected{}; +ProfileServiceTransactionSnapshot current_profile_transaction{}; bool refresh_requested = false; bool clear_requested = false; std::vector control_payload; @@ -20,6 +23,16 @@ uint32_t append_transaction_id = 0; uint32_t commit_transaction_id = 0; size_t append_offset = 0; std::vector appended_bytes; +ControllerIdentity profile_identity{}; +uint8_t profile_index = 0; +uint16_t profile_schema = 0; +size_t profile_size = 0; +uint32_t profile_crc = 0; +bool profile_reset_requested = false; +uint32_t profile_reset_transaction_id = 0; +uint32_t profile_commit_transaction_id = 0; +bool profile_activate_requested = false; +uint32_t profile_activate_transaction_id = 0; void require(bool condition, const char* message) { if (!condition) { @@ -42,6 +55,13 @@ void write_u32(std::vector* output, size_t offset, (*output)[offset + 3] = static_cast(value >> 24); } +uint32_t read_u32(const std::vector& input, size_t offset) { + return static_cast(input[offset]) | + (static_cast(input[offset + 1]) << 8) | + (static_cast(input[offset + 2]) << 16) | + (static_cast(input[offset + 3]) << 24); +} + std::vector make_request( UsbConfigurationManagement::Operation operation, const std::vector& payload) { @@ -213,6 +233,178 @@ void test_vendor_requests() { "request with invalid magic was accepted"); } +void test_profile_vendor_requests() { + using namespace UsbConfigurationManagement; + ControllerIdentity expected_identity{}; + expected_identity.stable = true; + expected_identity.transport = ControllerTransport::kClassic; + expected_identity.address[5] = 7; + expected_identity.vendor_id = 0x057e; + expected_identity.product_id = 0x2009; + + current_profile_list = {}; + current_profile_list.metadata.state = ProfileServiceState::kReady; + current_profile_list.metadata.generation = 9; + current_profile_list.count = 2; + current_profile_list.rows[0].identity = controller_identity_global(); + current_profile_list.rows[1].identity = expected_identity; + current_profile_list.rows[1].active_profile = 2; + tusb_control_request_t request = setup_request( + Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == kResponseHeaderSize + 33 && + control_payload[5] == + static_cast(Operation::kProfileList) && + control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION && + control_payload[kResponseHeaderSize] == 2 && + control_payload[kResponseHeaderSize + 31] == 2, + "profile list response was not encoded"); + + current_profile_selected = {}; + current_profile_selected.metadata.state = + ProfileServiceState::kReady; + current_profile_selected.metadata.generation = 9; + current_profile_selected.valid = true; + current_profile_selected.status = + ConfigurationTransactionStatus::kCommitted; + current_profile_selected.identity = expected_identity; + current_profile_selected.profile_index = 2; + current_profile_selected.profile = + controller_profile_default(expected_identity, 2); + request = setup_request( + Operation::kProfileRead, TUSB_DIR_IN, kMaximumResponseSize); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == + kResponseHeaderSize + + CONTROLLER_PROFILE_ENCODED_SIZE && + control_payload[10] == + CONTROLLER_PROFILE_SCHEMA_VERSION && + control_payload[kResponseHeaderSize] == + static_cast( + CONTROLLER_PROFILE_SCHEMA_VERSION) && + control_payload[kResponseHeaderSize + 1] == 0 && + control_payload[kResponseHeaderSize + 2] == 0 && + control_payload[kResponseHeaderSize + 3] == 1, + "selected profile response was not encoded"); + + current_profile_transaction = {}; + current_profile_transaction.metadata.state = + ProfileServiceState::kReady; + current_profile_transaction.metadata.generation = 9; + current_profile_transaction.transaction.transaction_id = 0x01020304; + current_profile_transaction.transaction.status = + ConfigurationTransactionStatus::kPending; + request = setup_request( + Operation::kProfileTransactionStatus, TUSB_DIR_IN, + kMaximumResponseSize); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == kResponseHeaderSize + 20 && + control_payload[6] == + static_cast(Status::kPending) && + read_u32(control_payload, kResponseHeaderSize) == + 0x01020304, + "pending profile transaction status lost its transaction ID"); + + current_profile_transaction.transaction.status = + ConfigurationTransactionStatus::kCommitted; + current_profile_transaction.transaction.stored_generation = + 0x11223344; + current_profile_transaction.transaction.stored_crc = 0xaabbccdd; + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[6] == static_cast(Status::kOk) && + read_u32(control_payload, kResponseHeaderSize) == + 0x01020304 && + read_u32(control_payload, kResponseHeaderSize + 12) == + 0x11223344 && + read_u32(control_payload, kResponseHeaderSize + 16) == + 0xaabbccdd, + "final profile transaction status lost its commit result"); + + std::vector identity_payload(15); + require(controller_identity_encode( + expected_identity, identity_payload.data(), + CONTROLLER_IDENTITY_ENCODED_SIZE), + "profile test identity did not encode"); + identity_payload[14] = 2; + perform_out(Operation::kProfileSelect, identity_payload); + require(controller_identity_equal(expected_identity, + profile_identity) && + profile_index == 2, + "profile selection was not dispatched"); + + std::vector begin(28); + write_u32(&begin, 0, 0x55667788); + require(controller_identity_encode( + expected_identity, &begin[4], + CONTROLLER_IDENTITY_ENCODED_SIZE), + "profile begin identity did not encode"); + begin[18] = 1; + write_u16(&begin, 20, CONTROLLER_PROFILE_SCHEMA_VERSION); + write_u16(&begin, 22, CONTROLLER_PROFILE_ENCODED_SIZE); + write_u32(&begin, 24, 0xaabbccdd); + perform_out(Operation::kProfileBegin, begin); + require(begin_transaction_id == 0x55667788 && + profile_index == 1 && + profile_schema == CONTROLLER_PROFILE_SCHEMA_VERSION && + profile_size == CONTROLLER_PROFILE_ENCODED_SIZE && + profile_crc == 0xaabbccdd, + "profile begin was not dispatched"); + + std::vector chunk(48); + write_u32(&chunk, 0, 0x55667788); + write_u16(&chunk, 4, 0); + write_u16(&chunk, 6, 40); + perform_out(Operation::kProfileChunk, chunk); + require(append_transaction_id == 0x55667788 && + append_offset == 0 && appended_bytes.size() == 40, + "profile chunk was not dispatched"); + + std::vector commit(4); + write_u32(&commit, 0, 0x55667788); + perform_out(Operation::kProfileCommit, commit); + require(profile_commit_transaction_id == 0x55667788, + "profile commit was not dispatched"); + + std::vector mutation(19); + write_u32(&mutation, 0, 0x10203040); + require(controller_identity_encode( + expected_identity, &mutation[4], + CONTROLLER_IDENTITY_ENCODED_SIZE), + "profile mutation identity did not encode"); + mutation[18] = CONTROLLER_PROFILE_ALL; + perform_out(Operation::kProfileReset, mutation); + require(profile_reset_requested && + profile_reset_transaction_id == 0x10203040, + "profile reset transaction was not dispatched"); + write_u32(&mutation, 0, 0x50607080); + mutation[18] = 3; + perform_out(Operation::kProfileActivate, mutation); + require(profile_activate_requested && profile_index == 3 && + profile_activate_transaction_id == 0x50607080, + "profile activation transaction was not dispatched"); + + write_u32(&mutation, 0, 0); + perform_out(Operation::kProfileActivate, mutation, false); + request = setup_request( + Operation::kProfileReset, TUSB_DIR_OUT, kRequestHeaderSize + 15); + require(!tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request), + "legacy profile reset payload was accepted"); + + begin[19] = 1; + perform_out(Operation::kProfileBegin, begin, false); + request = setup_request( + Operation::kProfileSelect, TUSB_DIR_OUT, + kRequestHeaderSize + 14); + require(!tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request), + "short profile selection request was accepted"); +} + } // namespace uint32_t configuration_crc32(const uint8_t* data, size_t size) { @@ -256,6 +448,75 @@ ConfigurationTransactionStatus configuration_service_reset(uint32_t) { return ConfigurationTransactionStatus::kPending; } +ConfigurationTransactionStatus profile_service_select( + const ControllerIdentity& identity, uint8_t selected_profile) { + profile_identity = identity; + profile_index = selected_profile; + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus profile_service_begin( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t selected_profile, uint16_t schema_version, + size_t payload_size, uint32_t payload_crc) { + begin_transaction_id = transaction_id; + profile_identity = identity; + profile_index = selected_profile; + profile_schema = schema_version; + profile_size = payload_size; + profile_crc = payload_crc; + return ConfigurationTransactionStatus::kReceiving; +} + +ConfigurationTransactionStatus profile_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size) { + append_transaction_id = transaction_id; + append_offset = offset; + appended_bytes.assign(data, data + size); + return ConfigurationTransactionStatus::kReceiving; +} + +ConfigurationTransactionStatus profile_service_commit( + uint32_t transaction_id) { + profile_commit_transaction_id = transaction_id; + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus profile_service_reset( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t selected_profile) { + profile_reset_transaction_id = transaction_id; + profile_identity = identity; + profile_index = selected_profile; + profile_reset_requested = true; + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus profile_service_activate( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t selected_profile) { + profile_activate_transaction_id = transaction_id; + profile_identity = identity; + profile_index = selected_profile; + profile_activate_requested = true; + return ConfigurationTransactionStatus::kPending; +} + +void profile_service_list_snapshot(ProfileServiceListSnapshot* output) { + *output = current_profile_list; +} + +void profile_service_selected_snapshot( + ProfileServiceSelectedSnapshot* output) { + *output = current_profile_selected; +} + +void profile_service_transaction_snapshot( + ProfileServiceTransactionSnapshot* output) { + *output = current_profile_transaction; +} + void bluepad32_input_backend_request_pairing_snapshot() { refresh_requested = true; } @@ -288,6 +549,8 @@ bool tud_control_status(uint8_t, const tusb_control_request_t*) { } #include "../adapter_configuration.cpp" +#include "../controller_identity.cpp" +#include "../controller_profile.cpp" #include "../usb_configuration_management.cpp" int main() { @@ -297,5 +560,6 @@ int main() { test_envelope_encoding(); test_pairing_encoding(); test_vendor_requests(); + test_profile_vendor_requests(); return 0; } diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index 697cdb5..517651e 100644 --- a/usb_configuration_management.cpp +++ b/usb_configuration_management.cpp @@ -61,17 +61,39 @@ Status transaction_status(ConfigurationTransactionStatus status) { } return Status::kStorageError; } +Status profile_service_status(const ProfileServiceMetadata& metadata) { + if (metadata.state == ProfileServiceState::kLoading) { + return Status::kPending; + } + if (metadata.state == ProfileServiceState::kStorageError) { + return Status::kStorageError; + } + return Status::kOk; +} + bool valid_out_size(Operation operation, size_t size) { switch (operation) { case Operation::kConfigurationBegin: return size == kRequestHeaderSize + 12; + case Operation::kProfileBegin: + return size == kRequestHeaderSize + 28; case Operation::kConfigurationChunk: return size > kRequestHeaderSize + 8 && size <= kMaximumRequestSize; + case Operation::kProfileChunk: + return size > kRequestHeaderSize + 8 && + size <= kMaximumRequestSize; case Operation::kConfigurationCommit: case Operation::kConfigurationReset: return size == kRequestHeaderSize + 4; + case Operation::kProfileCommit: + return size == kRequestHeaderSize + 4; + case Operation::kProfileSelect: + return size == kRequestHeaderSize + 15; + case Operation::kProfileReset: + case Operation::kProfileActivate: + return size == kRequestHeaderSize + 19; case Operation::kPairingRefresh: case Operation::kPairingClear: return size == kRequestHeaderSize; @@ -218,6 +240,74 @@ size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, payload, offset, output, output_size); } +size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot, + uint8_t* output, size_t output_size) { + if (snapshot.count > PROFILE_SERVICE_LIST_CAPACITY) { + return 0; + } + uint8_t payload[kProfileListPayloadSize]{}; + payload[0] = snapshot.count; + size_t offset = 1; + for (uint8_t index = 0; index < snapshot.count; ++index) { + if (!controller_identity_encode(snapshot.rows[index].identity, + &payload[offset], + CONTROLLER_IDENTITY_ENCODED_SIZE) || + snapshot.rows[index].active_profile >= + CONTROLLER_PROFILE_COUNT) { + return 0; + } + payload[offset + 14] = snapshot.rows[index].active_profile; + offset += 16; + } + return encode_response( + Operation::kProfileList, profile_service_status(snapshot.metadata), + 0, CONTROLLER_PROFILE_SCHEMA_VERSION, snapshot.metadata.generation, + payload, offset, output, output_size); +} + +size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot, + uint8_t* output, size_t output_size) { + Status status = profile_service_status(snapshot.metadata); + if (status == Status::kOk) { + status = transaction_status(snapshot.status); + } + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + size_t payload_size = 0; + if (snapshot.valid) { + if (!controller_profile_encode(snapshot.profile, payload, + sizeof(payload))) { + return 0; + } + payload_size = sizeof(payload); + } + return encode_response( + Operation::kProfileRead, status, 0, + CONTROLLER_PROFILE_SCHEMA_VERSION, snapshot.metadata.generation, + payload, payload_size, output, output_size); +} + +size_t encode_profile_transaction( + const ProfileServiceTransactionSnapshot& snapshot, + uint8_t* output, size_t output_size) { + const ConfigurationTransactionSnapshot& transaction = + snapshot.transaction; + uint8_t payload[20]{}; + write_u32(&payload[0], transaction.transaction_id); + write_u16(&payload[4], transaction.received_size); + write_u16(&payload[6], transaction.expected_size); + write_u32(&payload[8], transaction.expected_crc); + write_u32(&payload[12], transaction.stored_generation); + write_u32(&payload[16], transaction.stored_crc); + Status status = profile_service_status(snapshot.metadata); + if (status == Status::kOk) { + status = transaction_status(transaction.status); + } + return encode_response( + Operation::kProfileTransactionStatus, status, 0, + CONTROLLER_PROFILE_SCHEMA_VERSION, snapshot.metadata.generation, + payload, sizeof(payload), output, output_size); +} + } // namespace UsbConfigurationManagement namespace { @@ -286,6 +376,96 @@ bool process_out_request() { (static_cast(payload[2]) << 16) | (static_cast(payload[3]) << 24)); return true; + case Operation::kProfileSelect: { + ControllerIdentity identity{}; + if (payload[14] >= CONTROLLER_PROFILE_COUNT || + !controller_identity_decode( + payload, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity)) { + return false; + } + const ConfigurationTransactionStatus status = + profile_service_select(identity, payload[14]); + return status == ConfigurationTransactionStatus::kCommitted || + status == ConfigurationTransactionStatus::kPending; + } + case Operation::kProfileBegin: { + ControllerIdentity identity{}; + if (payload[19] != 0 || + !controller_identity_decode( + &payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity)) { + return false; + } + profile_service_begin( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24), + identity, payload[18], + static_cast(payload[20] | + (payload[21] << 8)), + static_cast(payload[22] | + (payload[23] << 8)), + static_cast(payload[24]) | + (static_cast(payload[25]) << 8) | + (static_cast(payload[26]) << 16) | + (static_cast(payload[27]) << 24)); + return true; + } + case Operation::kProfileChunk: { + const uint16_t chunk_size = + static_cast(payload[6] | + (payload[7] << 8)); + if (request.payload_size != 8 + chunk_size || + chunk_size > kMaximumChunkSize) { + return false; + } + profile_service_append( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24), + static_cast(payload[4] | + (payload[5] << 8)), + &payload[8], chunk_size); + return true; + } + case Operation::kProfileCommit: + profile_service_commit( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24)); + return true; + case Operation::kProfileReset: + case Operation::kProfileActivate: { + const uint32_t transaction_id = + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24); + ControllerIdentity identity{}; + if (transaction_id == 0 || + (request.operation == Operation::kProfileActivate && + payload[18] >= CONTROLLER_PROFILE_COUNT) || + (request.operation == Operation::kProfileReset && + payload[18] != CONTROLLER_PROFILE_ALL && + payload[18] >= CONTROLLER_PROFILE_COUNT) || + !controller_identity_decode( + &payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity)) { + return false; + } + const ConfigurationTransactionStatus status = + request.operation == Operation::kProfileReset + ? profile_service_reset( + transaction_id, identity, payload[18]) + : profile_service_activate( + transaction_id, identity, payload[18]); + return status == ConfigurationTransactionStatus::kPending || + status == ConfigurationTransactionStatus::kUnchanged || + status == ConfigurationTransactionStatus::kCommitted; + } case Operation::kPairingRefresh: bluepad32_input_backend_request_pairing_snapshot(); return true; @@ -367,6 +547,27 @@ extern "C" bool tud_vendor_control_xfer_cb( snapshot, response, sizeof(response)); break; } + case Operation::kProfileList: { + ProfileServiceListSnapshot snapshot{}; + profile_service_list_snapshot(&snapshot); + response_size = encode_profile_list( + snapshot, response, sizeof(response)); + break; + } + case Operation::kProfileRead: { + ProfileServiceSelectedSnapshot snapshot{}; + profile_service_selected_snapshot(&snapshot); + response_size = encode_profile_read( + snapshot, response, sizeof(response)); + break; + } + case Operation::kProfileTransactionStatus: { + ProfileServiceTransactionSnapshot snapshot{}; + profile_service_transaction_snapshot(&snapshot); + response_size = encode_profile_transaction( + snapshot, response, sizeof(response)); + break; + } default: return false; } diff --git a/usb_configuration_management.h b/usb_configuration_management.h index 738a132..21e07f5 100644 --- a/usb_configuration_management.h +++ b/usb_configuration_management.h @@ -5,6 +5,7 @@ #include "bluepad32_input_backend.h" #include "configuration_service.h" +#include "profile_service.h" namespace UsbConfigurationManagement { @@ -16,11 +17,14 @@ constexpr size_t kResponseHeaderSize = 20; constexpr size_t kPairingRecordSize = 8; constexpr size_t kPairingPayloadHeaderSize = 4; constexpr size_t kMaximumRequestSize = 64; +constexpr size_t kProfileListPayloadSize = + 1 + PROFILE_SERVICE_LIST_CAPACITY * 16; constexpr size_t kMaximumResponseSize = - kResponseHeaderSize + kPairingPayloadHeaderSize + - BLUEPAD32_PAIRING_RECORD_CAPACITY * kPairingRecordSize; + kResponseHeaderSize + kProfileListPayloadSize; constexpr size_t kMaximumChunkSize = kMaximumRequestSize - kRequestHeaderSize - 8; +static_assert(kMaximumResponseSize == 293, + "profile list no longer fits the EP0 response buffer"); enum class Operation : uint8_t { kInfo = 0x01, @@ -33,6 +37,15 @@ enum class Operation : uint8_t { kPairingRead = 0x20, kPairingRefresh = 0x21, kPairingClear = 0x22, + kProfileList = 0x30, + kProfileSelect = 0x31, + kProfileRead = 0x32, + kProfileBegin = 0x33, + kProfileChunk = 0x34, + kProfileCommit = 0x35, + kProfileReset = 0x36, + kProfileActivate = 0x37, + kProfileTransactionStatus = 0x38, }; enum class Status : uint8_t { @@ -61,5 +74,12 @@ size_t encode_response(Operation operation, Status status, uint8_t flags, uint8_t* output, size_t output_size); size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, uint8_t* output, size_t output_size); +size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot, + uint8_t* output, size_t output_size); +size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot, + uint8_t* output, size_t output_size); +size_t encode_profile_transaction( + const ProfileServiceTransactionSnapshot& snapshot, + uint8_t* output, size_t output_size); } // namespace UsbConfigurationManagement