diff --git a/.gitignore b/.gitignore index 70bc105..5bc85b8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ __pycache__ pytest-*/* build-*/* .ignore +src/firmware/platform/pico/switch2_wake_config.h diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 5bcc361..4521243 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -85,10 +85,10 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: | 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. | +| Macros | Complete | Four sequences share sixteen steps and 136 sparse bytes; Once, While held, Toggle and bounded Repeat are supported. | +| Turbo, Auto Burst and finite Burst | Complete | Configurable 1–30 Hz and 1–99% duty, shared defaults/sparse overrides, and 1–255 finite pulses use bounded phase arithmetic. | +| Persistent profiles | Complete | Eight schema-6/384-byte profiles per global/stable identity use a two-arena indexed catalog with migration and atomic publication. | +| Profile switching | Complete | PC activation, retained cycle chord and eight direct modifier shortcuts publish feedback only after commit. | | 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. | @@ -323,7 +323,7 @@ Core completion evidence: ### 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. +Use eight 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. Button mapping: @@ -380,7 +380,7 @@ Turbo behavior: Profile switching: - configurable controller chord -- one to four rumble pulses +- one to eight rumble pulses - matching onboard LED count - controller RGB/player LED feedback when supported @@ -394,12 +394,13 @@ Acceptance: Completion evidence: -- strict 256-byte profile schema and 17,696-byte fixed database support four +- strict 256-byte profile records and a compact indexed catalog support eight 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 + profile commits append one flash-safe record and compact atomically between + two 128 KiB arenas +- legacy fixed profile databases migrate 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 @@ -562,7 +563,250 @@ Flow: Do not add a second in-application flash writer unless ROM UF2 cannot meet a concrete requirement. -### Phase 7 — Performance and release qualification +### Phase 7 — Indexed profile catalog — Complete + +Replace the fully decoded fixed database before increasing profile count. +Initial capacity is eight profiles for the global fallback and each of sixteen +stable controller identities; the format must support a later increase without +another storage rewrite. + +Storage design: + +- reserve two 128 KiB flash arenas for append-only profile records and atomic + compaction +- store identity, profile index, generation, schema, payload length, and CRC + in every record header +- keep two independently checksummed superblocks; publish a compacted arena + only after every live record verifies +- retain the current four-profile bank reader for one-time migration +- do not erase an admitted legacy bank until the new catalog and superblock + have been read back successfully +- maintain a compact RAM index, not a decoded copy of every profile +- decode only the fallback and active profile for each observed identity +- keep report-path profile access allocation-free with bounded snapshots + +The completed AIO image uses 695,592 bytes of 4 MiB flash and reserves 256 +KiB for the two profile arenas. Total flash use plus configuration, bonds, +and the RP2350 terminal sector is 978,216 bytes (23.32%). Linked SRAM is +99,040 of 532,480 bytes; the profile catalog index is 1,828 bytes. + +Acceptance: + +- all existing profiles 1–4 survive migration byte-for-byte at the semantic + level +- profiles 5–8 default independently and persist across reboot +- interrupted append and compaction recover the last published generation +- corrupt newest records fall back to the previous valid record +- identity capacity remains aligned with the sixteen-entry bond store +- only active/fallback profiles are decoded in SRAM +- profile switching, management USB, and the graphical editor expose all + eight slots + +Completion evidence: + +- the native catalog suite covers interrupted header publication, corrupt + payload fallback, compaction, sixteen-identity capacity, and semantic + migration of global and stable profiles 1–4 +- service tests cover on-demand selection, cached active profiles, reset-all, + controller-originated activation, and persistence of profiles 7 and 8 +- all 109 tests pass; UART, AIO, and feasibility firmware build +- the browser editor renders and selects all eight slots +- Pico 2 W hardware read and activated profile 8, then restored profile 1 + +### Profile and Profile Studio enhancements — Sets A and B complete + +The live playtest, eight-slot catalog, controller-native labels, automatic +active-profile synchronization, and Sets A/B are implemented. Set C remains +candidate work; the native Switch-family output plan below is separate. + +| Priority | Candidate | Intended scope | Dependency or principal risk | +|---|---|---|---| +| A1 — Complete | Named and copyable profiles | Profile names and controller aliases use catalog metadata rather than input-profile fields. Copy/export/import preserve the current draft and per-section resets leave other settings unchanged. | Atomic metadata records; no report-path cost. | +| A2 — Complete | Visual response-curve editor | Replace raw `curve_q8_8` as the primary control with a graph, named presets, fine adjustment, live raw/output markers, and “apply to other side” for sticks or triggers. Retain the exact fixed-point value as the wire representation. | Implemented entirely in Profile Studio; existing profiles round-trip unchanged. | +| A3 — Complete | Controller aliases and Identify action | Allow names such as “Living-room DualSense”; show battery, transport, and capabilities as secondary details; provide an Identify button that briefly rumbles or lights only the selected live controller. | Implemented with catalog alias records, capability-gated live telemetry, and a bounded non-persistent Identify command. | +| B1 — Complete | Direct profile shortcuts | Modifier plus unique face/D-pad selectors address profiles 1–8, with the cycle chord retained and feedback after commit. | Deterministic arbitration, selector rollover and held/generation transitions are covered. | +| B2 — Complete | One Shift layer per profile | Hold/Toggle selects one alternate button map; base analog tuning, physical Turbo settings and macro definitions stay shared. | Modifier consumption and reset precedence are explicit. | +| B3 — Complete | Macro authoring tools | Timestamped firmware recording, insert/duplicate/remove/drag/keyboard reorder, visual-only preview and live duration/byte budgets; Once/While held/Toggle/Repeat playback. | Eight steps per macro, sixteen shared steps and 136 sparse bytes remain fixed. | +| B4 — Complete | Configurable Turbo and finite Burst | Shared rate/duty/count defaults with per-button overrides retain hold Turbo and Auto Burst. | Fixed-point timing skips elapsed cycles; UI warns about pulses narrower than host sampling. | +| C1 | Motion calibration and tuning | Expose live gyro/accelerometer values, bias calibration, axis orientation/inversion, sensitivity, drift threshold, smoothing, and hold/toggle activation. Preserve native Switch motion units rather than introducing gyro-to-stick emulation first. | Controller-specific validation and physical motion testing; filters must not add report latency. | +| C2 | Feedback preview and profile lighting | Add non-persistent weak/strong rumble tests, profile-switch preview, and player LED/lightbar preview. Optionally persist an RGB profile color where the controller supports it. | New bounded management command; unsupported output capabilities must be visibly disabled. | + +Set A completion evidence: + +- profile names and controller aliases survive catalog reload, interrupted + writes, and arena compaction without changing the 256-byte profile schema +- profiles copy across identities and slots through existing validated atomic + transactions; browser JSON import/export and per-section resets operate on + unsaved drafts +- four response-curve graphs expose named presets, exact Q8.8 fine adjustment, + live curve markers, and one-click linked-side application +- live controller details expose transport, normalized battery percentage, and + rumble/lightbar/player-LED/motion capabilities +- Identify queues one bounded feedback pulse only for the matching live stable + identity + +Recommended delivery order: + +1. A1 profile metadata, duplication, and browser backup controls. +2. A2 curve visualization and linked-side editing. +3. A3 controller aliases, battery/capability details, and Identify. +4. B1 direct profile shortcuts. +5. B3 macro recording and editing before adding new playback semantics. +6. B2 one bounded Shift layer. +7. B4 configurable Turbo and finite Burst. +8. C1 motion calibration. +9. C2 feedback preview and custom lighting. + +Design constraints: + +- Do not add automatic per-game switching on console paths; the Pico cannot + reliably observe the active game. +- Do not add arbitrary scripts or an unbounded stack of action layers. +- Keep every report-path operation allocation-free and bounded. +- Keep live telemetry read-only, non-overlapping, and paused while the editor + is hidden. +- Preserve unsaved drafts across metadata refreshes and transient USB + disconnects. +- Gate every controller-specific input or output by reported capabilities. +- Add a new profile schema only when persistent runtime behavior changes; + UI-only presets and catalog metadata must not churn the profile wire format. + +Research basis: + +- 8BitDo Ultimate Software: mapping, stick/trigger tuning, vibration, macros, + profiles, and Turbo/Burst modes — https://support.8bitdo.com/ultimate/pro2.html +- DualSense Edge profiles: names, copies, direct shortcuts, curve presets, + linked trigger settings, and live playtest — + https://www.playstation.com/en-us/support/hardware/set-up-edge-controller/ +- Steam Input action-set layers: temporary mapping overlays and precedence + risks — + https://partner.steamgames.com/doc/features/steam_controller/action_set_layers +- Xbox Elite Shift: modifier-driven alternate mappings — + https://support.xbox.com/en-US/help/hardware-network/controller/shift-elite-series-2 + +Set B delivery evidence: + +- Schema 6 retains the complete 136-byte macro stream and moves the profile + payload to 384 bytes. Catalog 2 retains 512-byte records and existing arena + capacity; names/aliases do not grow with the profile. +- Native regressions exercise interrupted page/arena publication, full + identity capacity, old formats and high generation values, competing + shortcuts, source-coordinate Shift/Turbo, finite timing and playback resets. +- Hardware migration preserved all sixteen stored profiles, their names and + active indices. A temporary inactive profile-8 save/readback covered the new + fields and was restored afterward. +- Real Profile Studio checks covered recording start/timeout/Use, a saved + recorded step, repeat and continuous visual previews, and desktop/mobile + layouts. The live recording run contained idle input; button-edge and + quantization cases are covered by native regressions. +- Recording uses management operation `0x42`, schema 1: bounded pages of + firmware-timestamped input, explicit start/stop/run identity and visible + capacity/time/disconnect termination. It does not write profiles until Save. + +### Native Switch-family HD rumble — Planned + +Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md). +It includes implementation locations, acceptance checks and current mixed-controller timing caveats. + +Goal: preserve Nintendo's left/right, low/high-band commands on controllers +that can execute them natively. This is a separate output backend from the +DualSense PCM synthesizer, not a promise that every controller in “Switch +mode” supports the same rumble protocol. No Switch-family native forwarding +implementation is included in the current DualSense work. + +Current constraints: + +- `ControllerRumbleOutput.hd` retains decoded substeps but not the original + eight wire bytes. Unity-gain forwarding therefore needs an explicit raw + representation alongside the decoded, profile-scaled timeline. +- The patched Bluepad32 Switch parser enables vibration with subcommand + `0x48`, then implements conventional magnitudes through fixed frequencies + and a 40 ms refresh. Preserve that hardware-tested third-party fallback. +- `send_subcmd()` currently has a process-global four-bit packet counter; + native ownership needs a counter per physical controller shared by every + `0x01`/`0x10` sender. Player-LED requests currently construct zeroed rumble + fields, so they must participate in rumble arbitration rather than silently + overwrite the current command. +- Joy-Cons are currently separate, horizontally mapped controllers in + Bluepad32. A paired two-Joy-Con logical controller is not implemented. + +Delivery order: + +1. **Qualify protocol and models.** Start with an original genuine Switch Pro + Controller (`057E:2009`), then original Joy-Con L/R (`057E:2006/2007`). + Capture console USB commands and actual Bluetooth output for neutral, + repeated, relative and multi-substep words. Verify the accepted formats, + amplitude normalization and repeated-word semantics against real hardware; + the older public four-byte tables alone do not establish every compressed + command's behavior. Do not infer native support from a Pro-like parser + type, name or VID/PID alone: ambiguous clones stay on compatibility output + until their model is qualified. Switch 2 controllers and NSO retro models + require separate capability/protocol qualification. +2. **Add a generation-tagged native command path.** Extend the existing rumble + envelope with original bytes and an explicit validity/unmodified flag; + retain the decoded timeline for scaling, recovery and fallback. Add a + fixed-capacity Core-0-to-Core-1 command queue and one output owner per + physical Switch device. Use native Bluetooth report `0x10`, not PCM. + The existing format is 11 bytes including the Bluetooth HID transaction + byte: about 1,375 payload bytes/s at 125 reports/s, before L2CAP/HCI/radio + overhead. Direct forwarding need not inherit the DualSense 10.667 ms PCM + lookback, but its actual latency must be measured. +3. **Implement fidelity, scaling and resynchronization together.** Raw + forwarding is a fast path only when profile gain is unity and physical + controller state is synchronized. Other gains require a bounded encoder + from the scaled per-band timeline, preserving all representable substeps. + Validate the inverse amplitude mapping against independent golden data; + decoder-normalized Q15 values are not raw wire amplitude codes. Preserve + silence and Nintendo's safe amplitude bounds. Do not apply the DualSense + 2x/0.8-power response curve to Nintendo actuators. If a scaled multi-step + command cannot be represented in one word, qualify a bounded legal packet + schedule/quantization policy explicitly; do not quietly collapse it to a + peak or latest magnitude. After queue loss, feedback or reconnect, send a + valid absolute current-state/neutral resynchronization before dependent + relative commands; never replay an obsolete vibration backlog. +4. **Unify LEDs, feedback and lifetime handling.** Keep the current effective + rumble bytes in all applicable subcommand reports. Local confirmation + temporarily overrides host output while host state continues advancing, + then resumes the current state, not the old effect. Preserve the existing + 50 ms Switch-command expiry policy and prioritize explicit stop. Cancel + duration, delayed-start and refresh timers when ownership changes or a + device disconnects; old-generation callbacks must never touch a replacement. + XInput input to this backend remains a two-magnitude, stateful effect with + fixed carriers, not invented Nintendo frequency detail. +5. **Add single-actuator and third-party policies.** A standalone Joy-Con + needs an explicit mono downmix so effects addressed to either host side + are not simply lost. Preserve each band's dominant contribution with + deterministic frequency/tie handling and safe amplitude limits; document + the unavoidable spatial loss. Stereo routing to a Joy-Con pair belongs + with a separate logical-pairing feature. Keep the current 8BitDo Ultimate + enable/fixed-frequency/refresh behavior unless that exact model passes + native qualification; never replace it with a blanket Switch-family rule. +6. **Qualify and promote per model.** Add independent codec/golden-packet + regressions, scale-zero/unity/intermediate checks, channel separation, + packet-counter wrap, backpressure/resync, LED/feedback coexistence, + timeout/stop and disconnect/reuse cases. Run safe physical frequency and + amplitude sweeps and captured game effects; then measure one and four + controllers, mixed DualSense/Switch output, simultaneous input/motion and + persistent-profile writes. Report p50/p95/p99/worst host-receipt-to-HCI + submission, skipped/resynchronized commands and physical actuator onset + where instrumentation is available. Enable the path only for qualified + models; keep the UART protocol, bonds, calibration and wake configuration + unchanged. + +Implementation locations: `usb/switch/switch_haptics.*` and the profile +rumble transform for representation/scaling; `input/bluepad32_input_backend.*` +for routing/lifetimes; a bounded Switch output scheduler under `input/`; +and `patches/bluepad32-sdl3-imu.patch` for parser integration. Patch the +build-local Bluepad32 copy, not the upstream checkout. + +Protocol references: + +- Bluetooth reports, neutral values and actuator safety: + https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md +- Frequency/amplitude encoding tables: + https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md + +### Phase 8 — Performance and release qualification Measure: diff --git a/CMakeLists.txt b/CMakeLists.txt index a7e7c57..ae35731 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,54 @@ endif() option(SWITCH_PICO_LOG "Enable UART debug logging" OFF) option(SWITCH_PICO_ADAPTER_FEASIBILITY "Build the automatic Switch/XInput feasibility prototype" OFF) +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + set(SWITCH_PICO_NATIVE_DEFAULT ON) + set(SWITCH_PICO_CLOCK_DEFAULT 300) +else() + set(SWITCH_PICO_NATIVE_DEFAULT OFF) + set(SWITCH_PICO_CLOCK_DEFAULT 150) +endif() +option(SWITCH_PICO_HAPTICS_EXPERIMENT + "Enable native DualSense haptics and transport diagnostics" ${SWITCH_PICO_NATIVE_DEFAULT}) +option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM + "Execute the native haptics hot path from SRAM" ON) +option(SWITCH_PICO_HD_RUMBLE + "Auto-arm the first eligible DualSense native haptics stream" ${SWITCH_PICO_NATIVE_DEFAULT}) +set(SWITCH_PICO_SYS_CLOCK_MHZ "${SWITCH_PICO_CLOCK_DEFAULT}" CACHE STRING + "Pico 2 W CPU clock: 300 MHz default, 150 stock or 400 opt-in") +set_property(CACHE SWITCH_PICO_SYS_CLOCK_MHZ PROPERTY STRINGS 150 300 400) +if(NOT SWITCH_PICO_SYS_CLOCK_MHZ MATCHES "^(150|300|400)$") + message(FATAL_ERROR "SWITCH_PICO_SYS_CLOCK_MHZ must be 150, 300, or 400") +endif() +set(SWITCH_PICO_OVERCLOCK_MV "1300" CACHE STRING + "Experimental core voltage: 1300 mV, or explicit 1400 mV at 400 MHz") +set_property(CACHE SWITCH_PICO_OVERCLOCK_MV PROPERTY STRINGS 1300 1400) +if(NOT SWITCH_PICO_OVERCLOCK_MV MATCHES "^(1300|1400)$") + message(FATAL_ERROR "SWITCH_PICO_OVERCLOCK_MV must be 1300 or 1400") +endif() +if(SWITCH_PICO_OVERCLOCK_MV STREQUAL "1400" + AND NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "400") + message(FATAL_ERROR "The 1400 mV experiment is restricted to 400 MHz") +endif() +option(SWITCH_PICO_CYW43_PACKET_READ + "Use packet-level CYW43 receive transactions" ${SWITCH_PICO_NATIVE_DEFAULT}) +option(SWITCH_PICO_HCI_CREDIT_BATCH + "Batch incoming HCI credit returns" ${SWITCH_PICO_NATIVE_DEFAULT}) +if(SWITCH_PICO_HD_RUMBLE) + set(SWITCH_PICO_HAPTICS_EXPERIMENT ON) +endif() +set(SWITCH_PICO_HD_PACKET_FRAMES "64" CACHE STRING + "Native gameplay frames per packet: 64 standard, 32 single-controller experiment") +set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 64 32) +if(NOT SWITCH_PICO_HD_PACKET_FRAMES MATCHES "^(32|64)$") + message(FATAL_ERROR "SWITCH_PICO_HD_PACKET_FRAMES must be 32 or 64") +endif() +if(SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32") + if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT OR NOT SWITCH_PICO_CYW43_PACKET_READ + OR NOT SWITCH_PICO_HCI_CREDIT_BATCH OR SWITCH_PICO_SYS_CLOCK_MHZ LESS 300) + message(FATAL_ERROR "32-frame qualification requires native transport, packet reads, credit batching and at least 300 MHz") + endif() +endif() set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend") set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32) if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART" @@ -45,6 +93,34 @@ if(SWITCH_PICO_ADAPTER_FEASIBILITY message(FATAL_ERROR "SWITCH_PICO_ADAPTER_FEASIBILITY requires the BLUEPAD32 backend") endif() +if(SWITCH_PICO_HAPTICS_EXPERIMENT + AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + message(FATAL_ERROR + "SWITCH_PICO_HAPTICS_EXPERIMENT requires the BLUEPAD32 backend") +endif() +if(NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "150" + AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + message(FATAL_ERROR "Experimental clocks require the Pico 2 W backend") +endif() +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + # Boot stage 2 must see the divider too: establish safe flash timing + # before main raises clk_sys. Keep the CYW43 SPI bus at or below 37.5 MHz. + math(EXPR SWITCH_PICO_FLASH_CLKDIV "(${SWITCH_PICO_SYS_CLOCK_MHZ} + 74) / 75") + math(EXPR SWITCH_PICO_PIO_DIV256 "(${SWITCH_PICO_SYS_CLOCK_MHZ} * 256 + 74) / 75") + math(EXPR SWITCH_PICO_PIO_DIV_INT "${SWITCH_PICO_PIO_DIV256} / 256") + math(EXPR SWITCH_PICO_PIO_DIV_FRAC "${SWITCH_PICO_PIO_DIV256} % 256") + add_compile_definitions( + SWITCH_PICO_SYS_CLOCK_MHZ=${SWITCH_PICO_SYS_CLOCK_MHZ} + SWITCH_PICO_OVERCLOCK_MV=${SWITCH_PICO_OVERCLOCK_MV} + PICO_FLASH_SPI_CLKDIV=${SWITCH_PICO_FLASH_CLKDIV} + CYW43_PIO_CLOCK_DIV_INT=${SWITCH_PICO_PIO_DIV_INT} + CYW43_PIO_CLOCK_DIV_FRAC8=${SWITCH_PICO_PIO_DIV_FRAC}) + if(NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "150") + # RP2350 otherwise discards boot2 and uses the ROM's XIP setup, + # ignoring PICO_FLASH_SPI_CLKDIV despite reporting a boot2 name. + add_compile_definitions(PICO_EMBED_XIP_SETUP=1) + endif() +endif() # Pull in Raspberry Pi Pico SDK (must be before project) include(pico_sdk_import.cmake) @@ -53,6 +129,65 @@ project(switch-pico C CXX ASM) # Initialise the Raspberry Pi Pico SDK pico_sdk_init() +if(SWITCH_PICO_CYW43_PACKET_READ) + if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT) + message(FATAL_ERROR "Packet-level CYW43 reads require the transport experiment") + endif() + find_package(Git REQUIRED) + set(CYW43_BUS_DIR ${PICO_SDK_PATH}/src/rp2_common/pico_cyw43_driver/cybt_shared_bus) + set(CYW43_LOCAL_DIR ${CMAKE_CURRENT_BINARY_DIR}/cyw43-packet-read) + set(CYW43_STATUS_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/cyw43-packet-read-status.patch) + file(MAKE_DIRECTORY ${CYW43_LOCAL_DIR}) + configure_file(${CYW43_BUS_DIR}/cybt_shared_bus_driver.c + ${CYW43_LOCAL_DIR}/cybt_shared_bus_driver.c COPYONLY) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CYW43_STATUS_PATCH}) + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + GIT_CEILING_DIRECTORIES=${CMAKE_CURRENT_BINARY_DIR} + ${GIT_EXECUTABLE} apply --no-index ${CYW43_STATUS_PATCH} + WORKING_DIRECTORY ${CYW43_LOCAL_DIR} + RESULT_VARIABLE CYW43_PATCH_RESULT ERROR_VARIABLE CYW43_PATCH_ERROR) + if(NOT CYW43_PATCH_RESULT EQUAL 0) + message(FATAL_ERROR "Cannot prepare packet-read driver: ${CYW43_PATCH_ERROR}") + endif() + get_target_property(CYW43_BUS_SOURCES cybt_shared_bus INTERFACE_SOURCES) + list(FIND CYW43_BUS_SOURCES ${CYW43_BUS_DIR}/cybt_shared_bus_driver.c CYW43_DRIVER_INDEX) + if(CYW43_DRIVER_INDEX EQUAL -1) + message(FATAL_ERROR "SDK shared-bus source layout changed") + endif() + list(REMOVE_ITEM CYW43_BUS_SOURCES ${CYW43_BUS_DIR}/cybt_shared_bus_driver.c) + list(APPEND CYW43_BUS_SOURCES ${CYW43_LOCAL_DIR}/cybt_shared_bus_driver.c) + set_property(TARGET cybt_shared_bus PROPERTY INTERFACE_SOURCES ${CYW43_BUS_SOURCES}) +endif() +if(SWITCH_PICO_HCI_CREDIT_BATCH) + if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT) + message(FATAL_ERROR "HCI credit batching requires the transport experiment") + endif() + find_package(Git REQUIRED) + set(HCI_LOCAL_ROOT ${CMAKE_CURRENT_BINARY_DIR}/btstack-credit-batch) + set(HCI_LOCAL_SOURCE ${HCI_LOCAL_ROOT}/lib/btstack/src/hci.c) + set(HCI_BATCH_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/btstack-credit-batch.patch) + file(MAKE_DIRECTORY ${HCI_LOCAL_ROOT}/lib/btstack/src) + configure_file(${PICO_BTSTACK_PATH}/src/hci.c ${HCI_LOCAL_SOURCE} COPYONLY) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${HCI_BATCH_PATCH}) + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + GIT_CEILING_DIRECTORIES=${CMAKE_CURRENT_BINARY_DIR} + ${GIT_EXECUTABLE} apply --no-index ${HCI_BATCH_PATCH} + WORKING_DIRECTORY ${HCI_LOCAL_ROOT} + RESULT_VARIABLE HCI_PATCH_RESULT ERROR_VARIABLE HCI_PATCH_ERROR) + if(NOT HCI_PATCH_RESULT EQUAL 0) + message(FATAL_ERROR "Cannot prepare credit-batch HCI: ${HCI_PATCH_ERROR}") + endif() + get_target_property(HCI_SOURCES pico_btstack_base INTERFACE_SOURCES) + list(FIND HCI_SOURCES ${PICO_BTSTACK_PATH}/src/hci.c HCI_SOURCE_INDEX) + if(HCI_SOURCE_INDEX EQUAL -1) + message(FATAL_ERROR "SDK HCI source layout changed") + endif() + list(REMOVE_ITEM HCI_SOURCES ${PICO_BTSTACK_PATH}/src/hci.c) + list(APPEND HCI_SOURCES ${HCI_LOCAL_SOURCE}) + set_property(TARGET pico_btstack_base PROPERTY INTERFACE_SOURCES ${HCI_SOURCES}) +endif() # Configure BLUEPAD32 input backend if selected if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") @@ -111,7 +246,10 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${SWITCH_PICO_SOURCE_DIR}/configuration/configuration_transaction.cpp ${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp ${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/controller_macro_capture.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/switch2_wake.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/bootsel_pairing_button.cpp + ${SWITCH_PICO_SOURCE_DIR}/platform/pico/system_clock.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_configuration_storage.cpp ${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/controller_profile.cpp @@ -124,13 +262,59 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${SWITCH_PICO_SOURCE_DIR}/usb/usb_configuration_management.cpp ${SWITCH_PICO_SOURCE_DIR}/usb/xinput/xinput_driver.cpp ) + set(SWITCH2_WAKE_CONFIG + ${SWITCH_PICO_SOURCE_DIR}/platform/pico/switch2_wake_config.h) + set_property( + DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${SWITCH2_WAKE_CONFIG}) + if(EXISTS ${SWITCH2_WAKE_CONFIG}) + set(SWITCH2_WAKE_CONFIGURED_VALUE 1) + else() + set(SWITCH2_WAKE_CONFIGURED_VALUE 0) + endif() + # The experiment yields after one received packet; its transport wrapper + # explicitly reschedules remaining input so timers run between packets. target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1 SWITCH_PICO_HID_INSTANCE_COUNT=4 SWITCH_PICO_USB_OUTPUT_MODES=1 PICO_FLASH_ASSUME_CORE1_SAFE=0 - PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=16 + PICO_STACK_SIZE=4096 + PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$,1,16> + SWITCH2_WAKE_CONFIGURED=${SWITCH2_WAKE_CONFIGURED_VALUE} ) + if(SWITCH_PICO_CYW43_PACKET_READ) + target_sources(switch-pico PRIVATE + ${SWITCH_PICO_SOURCE_DIR}/platform/pico/cyw43_packet_transport.c) + target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_CYW43_PACKET_READ=1) + target_link_options(switch-pico PRIVATE + "LINKER:--wrap=cyw43_btbus_read" "LINKER:--wrap=cyw43_btbus_init") + endif() + if(SWITCH_PICO_HCI_CREDIT_BATCH) + target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HCI_CREDIT_BATCH=1) + endif() + if(SWITCH_PICO_HAPTICS_EXPERIMENT) + target_sources(switch-pico PRIVATE + ${SWITCH_PICO_SOURCE_DIR}/input/haptics_experiment.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/switch_hd_rumble_synth.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/haptics_transport_probe.cpp) + target_compile_definitions(switch-pico PRIVATE + SWITCH_PICO_HAPTICS_EXPERIMENT=1 + SWITCH_PICO_HD_PACKET_FRAMES=${SWITCH_PICO_HD_PACKET_FRAMES} + SWITCH_PICO_HAPTICS_EXPERIMENT_RAM=$) + target_compile_definitions(bluepad32 PRIVATE + SWITCH_PICO_HAPTICS_EXPERIMENT=1) + # These boundaries are defined in different SDK translation units from + # their callers. GNU ld --wrap intercepts unresolved symbol references; + # an LTO build must verify actual caller edges, not just wrapper symbols. + target_link_options(switch-pico PRIVATE + "LINKER:--wrap=cyw43_bluetooth_hci_write" + "LINKER:--wrap=cyw43_bluetooth_hci_read" + "LINKER:--wrap=btstack_run_loop_base_poll_data_sources") + endif() + if(SWITCH_PICO_HD_RUMBLE) + target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1) + endif() else() target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HID_INSTANCE_COUNT=1 @@ -163,7 +347,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") pico_multicore pico_flash ) - target_link_libraries(switch-pico hardware_watchdog) + target_link_libraries(switch-pico hardware_watchdog hardware_adc hardware_vreg hardware_powman) endif() if (SWITCH_PICO_LOG) diff --git a/HAPTICS_EXPERIMENT.md b/HAPTICS_EXPERIMENT.md new file mode 100644 index 0000000..267c91d --- /dev/null +++ b/HAPTICS_EXPERIMENT.md @@ -0,0 +1,313 @@ +# DualSense native HD rumble and transport experiment + +## Goal and evidence + +Native Nintendo HD-rumble decoding and stateful XInput motor strengths feed the DualSense Bluetooth PCM backend. Standard AIO/XInput builds enable the qualified 300 MHz/1.3 V transport by default and auto-arm the first eligible DualSense that becomes ready, in any slot. One native stream is selected at a time; other controllers retain compatibility rumble. The deterministic transport fixture remains available. Console gameplay was user-tested; precise actuator-onset latency and perceptual equivalence to Nintendo hardware are not claimed. + +The user observed 1–2 seconds of gameplay-to-haptics delay in OMP session `01a06fa9-cdc7-72de-ac0e-7de08c355f06`. Both a DS5Dongle-style 0x39 stream and a short 0x32 stream failed after continuous silence, latest-state replacement and can-send callbacks were tried. Do not repeat those changes as newly discovered fixes or attribute the observed delay to profile feedback. + +Recovered source and retained object identify three concrete issues: + +- `request_can_send()` armed `send_requested` after `l2cap_request_can_send_now_event()`. This BTstack can deliver the event synchronously; the callback discarded it and left a pending flag with no notification. An isolated reproduction produced zero sends / pending=true, versus one send / pending=false when armed before requesting. +- The final short report used haptic descriptor 0xd2. SAxense uses 0x92 for its single 64-byte block. The extra bit is undocumented; the mismatch is established, its contribution to the physical delay is not. +- Relative Pico BTstack timers add one millisecond tick. Rescheduling `{11,11,10}` relative to each callback does not implement an exact 3 kHz clock and accumulates callback lateness. + +The original diagnostic captures were not from a live native-haptic stream. CPU saturation, ACL-credit starvation, and controller-internal latency were not measured. + +Sources: + +- https://github.com/egormanga/SAxense/blob/master/SAxense.c +- https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp +- https://github.com/awalol/DS5Dongle/blob/master/src/bt.cpp +- https://github.com/awalol/DS5Dongle/blob/master/CMakeLists.txt +- Local SDK `lib/btstack/src/l2cap.c`, `src/rp2_common/pico_btstack/btstack_run_loop_async_context.c`. +- Frequency reference: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md +- Reconstructed substep reference: https://github.com/HandHeldLegend/NS-LIB-HID/blob/becc24f0841bbb875da24ea622cc1ada00cb8492/docs/hd-rumble-implementation-guide.md +- Eight-millisecond playback-window reference: https://github.com/HandHeldLegend/HOJA-LIB-RP2040/blob/238f66d1c4aae87fc320d94d8abd38229e7da2d0/src/utilities/pcm.c + +## Implementation contract + +1. AIO/XInput defaults enable `SWITCH_PICO_HAPTICS_EXPERIMENT`, `SWITCH_PICO_HD_RUMBLE`, packet-level CYW43 reads and bounded HCI credit batching at 300 MHz/1.3 V. UART is unchanged. Preserve wake identity, pairing storage and USB modes. Incoming flow control and FIFO capacities remain unchanged; the controller's advertised outgoing capacity is eight ACL packets on this hardware. +2. One selected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. Auto-arm chooses the first eligible ready controller, not necessarily slot 0, and later controllers do not steal an active stream. The fixture requires explicit start. Idle native output remains silent; other models and unselected slots use their controller-specific compatibility paths. +3. Report 0x32 plus A2 remains a 143-byte L2CAP SDU. The first report selects native mode with sized state block 0x90/63 and one silent 0x92/64 haptic block. Subsequent reports use compact controls `{0x91,3,0x62,16,counter}`. Standard gameplay and the fixture carry two blocks (64 stereo frames, descriptor 0xd2). Explicit `SWITCH_PICO_HD_PACKET_FRAMES=32` carries one 64-byte block (descriptor 0x92) for single-controller qualification only. The counter advances by the number of blocks. Padding and Bluetooth CRC remain deterministic. No speaker, microphone, USB audio endpoint, Opus or resampler. +4. At 3 kHz, 32/64 stereo frames require 93.75/46.875 reports/s. Absolute rational deadlines preserve fractional time and skip obsolete packets after stalls rather than burst-replaying them. Timer wakeups account for SDK +1 tick. Can-send permission and audio deadlines remain separate; flags are armed before requests and synchronous callbacks cannot recursively generate a stream. +5. The deterministic fixture remains a finite 288-report / 6.144-second sequence: 48 priming intervals, four cycles of left 100 Hz / silence / right 200 Hz / silence (12 reports = 256 ms per phase), then 48 trailing-silence reports. Its peak remains 32/127. Gameplay is continuous, has no one-second priming pattern, and uses timestamped Switch commands instead. Stop restores compatibility output; disconnect cancels without stale-pointer use. +6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset. +7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` retain USB management framing. AIO builds enable these operations; explicitly disabled/UART builds do not. Operation 0x40 uses schema 5 and transport profiling uses schema 3. Update firmware and host tools together. +8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency. + +## Gameplay mode + +Build using the provisioned Pico SDK/toolchain environment: + +```sh +cmake -S . -B build-hd-rumble -DPICO_BOARD=pico2_w \ + -DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \ + -DSWITCH_PICO_HD_RUMBLE=ON \ + -DSWITCH_PICO_SYS_CLOCK_MHZ=300 -DSWITCH_PICO_OVERCLOCK_MV=1300 \ + -DSWITCH_PICO_CYW43_PACKET_READ=ON -DSWITCH_PICO_HCI_CREDIT_BATCH=ON \ + -DSWITCH_PICO_HD_PACKET_FRAMES=64 \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON -DSWITCH_PICO_LOG=OFF +cmake --build build-hd-rumble +``` + +Load `build-hd-rumble/switch-pico.elf` or `.uf2`, or use the standard `build.py --aio` entry point and `firmware/switch-pico-aio.uf2`. Both use the optimized native path. Reconnect a DualSense with PS if needed. Manual selection uses `haptics-experiment gameplay --slot N` after stopping any active run; CLI slots are zero-based, so the second controller is `--slot 1`. Manual arming does not persist across power cycles. `stop` disarms the native stream and restores compatibility output. A new eligible connection may auto-arm; there is no periodic re-arm that defeats an explicit stop. + +The decoder preserves each actuator's one-to-three ordered substeps and frequency indices. Amplitudes become linear Q0.15 values via precomputed lookups; compatibility magnitudes retain their previous mapping. Profile strong/weak scales apply to the low/high bands of both actuators without discarding substeps. + +The synthesizer has independent left/right low/high phase accumulators. Frequencies are `40 * 2^(index/32)` and `80 * 2^(index/32)` Hz. Each Switch command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each Switch-updated side expires after 50 ms, matching the existing conservative timeout policy. + +Standard gameplay uses 21.333 ms causal lookback; the explicit 32-frame experiment uses 10.667 ms. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output. + +Native gameplay uses balanced **2x low/high gain after profile scaling**, followed by a gentle **0.8-power curve** on the combined amplitude. This lifts quiet and medium effects while retaining their low/high ratio. The curve is a 257-entry lookup with integer interpolation, not per-sample floating-point math. Combined weights are capped at 65535 to avoid overflow and clipping. Zero remains zero. The amplitude curve does not alter carrier frequencies or local-confirmation gain; packet timing follows the transport configuration above. This response replaced the initial 1.5x and low-band-only experiments after user comparison. + +Local confirmation remains a transient overlay and resumes the current host state. USB reset/unmount/suspend clears held XInput output. In Auto mode, an unmount intentionally watchdog-reboots to Switch probe; this includes a reset that clears TinyUSB's configured/mounted state. A libusb reset can consequently report “Entity not found” while the device re-enumerates. Persistent manual XInput mode is exempt from that Auto-mode reboot policy. + +The gameplay stream continues with silence while idle. It stops on disconnect, explicit stop, or a stalled send-permission watchdog. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup. + +## Building and running + +Use the repository's provisioned Pico SDK/toolchain environment. These CMake commands only build; they do not flash the adapter: + +```sh +cmake -S . -B build-haptics -DPICO_BOARD=pico2_w \ + -DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT=ON \ + -DSWITCH_PICO_HD_RUMBLE=OFF \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON \ + -DSWITCH_PICO_LOG=OFF +cmake --build build-haptics +``` + +The fixture artifacts are `build-haptics/switch-pico.elf` and `.uf2`; disabling auto-arm lets `start` run without first stopping gameplay. For a same-clock flash comparison, use a separate directory and `SWITCH_PICO_HAPTICS_EXPERIMENT_RAM=OFF`. Standard AIO/XInput `build.py` entry points explicitly enable the qualified defaults even with an old CMake cache; UART entry points explicitly disable them. + +After loading the chosen image and connecting a DualSense: + +```sh +uv run switch-pico-config haptics-experiment status +uv run switch-pico-config haptics-experiment start --slot 0 --watch --json +uv run switch-pico-config haptics-experiment profile --json +uv run switch-pico-config haptics-experiment stop --slot 0 +``` + +For the deterministic fixture only, the first tone is intentionally scheduled 1.024 seconds after start. Gameplay instead renders the timestamped host timeline with one report interval of lookback. First-tone fields identify the logical first nonsilent sample and the containing report's submission, not actual actuator onset. + +## Protocol + +USB vendor operation 0x40: OUT `{action, slot}` (0=stop, 1=finite fixture, 2=gameplay; slots 0..3), using the existing envelope. IN is schema 5, 84 bytes. + +- Seventeen little-endian u32 fields: run_id, connection_generation, start_us, generated_packets, sent_packets, skipped_packets, send_failures, can_send_requests, synchronous_callbacks, max_generate_us, max_send_gap_us, max_lateness_us, max_request_wait_us, first_tone_due_us, first_tone_sent_us, last_sent_us, elapsed_us. +- Four u8 fields: state, slot, last_error, reserved (zero). +- Byte 72: mode (0=fixture, 1=gameplay); byte 73: actual stereo frames per packet (32 or 64); byte 74: whether the last successfully submitted PCM packet was nonzero (0/1); byte 75: zero reserved. Nonzero PCM is firmware output evidence, not measured actuator motion. +- Little-endian u32 at 76: `host_updates`; at 80: `dropped_updates`. +- State: idle=0, pending=1, running=2, completed=3, stopped=4, disconnected=5, unsupported=6, error=7. Disabled build reports unsupported. +- Microsecond timestamps are low 32 bits of Pico uptime; use unsigned modular differences for this bounded experiment. Host receipt time is not a hardware onset measurement. +- Error: none=0, unsupported controller=1, insufficient MTU=2, disconnected=3, timeout=4, transport failure=5, queued conventional output=6. The fixture rejects a queued start. Gameplay allows a bounded startup interval for prior output to drain; it does not discard LED/control reports. + +### Transport timing probe + +Operation `0x41` is IN-only. Schema 3 contains 44 little-endian 32-bit fields in `HapticsTransportProbe` declaration order (176 bytes). The first 32 retain the prior counters and raw advertised ACL capacity. Appended fields report requested/measured system clock, measured USB clock, regulator setting, flash divider, CYW43 PIO divider in 1/256 units, estimated temperature (signed milli-Celsius), credit/ACL/other HCI write counts, write failures, and packet-read optimization. Regulator voltage is a setting, not a multimeter reading; temperature assumes a 3.3 V ADC reference and is uncalibrated. Counters correlate by run ID/generation; snapshots are not an atomic cross-operation instant. + +```sh +uv run switch-pico-config haptics-experiment profile --json +``` + +The probe measures scheduled timer lateness before choosing a current packet, permission wait before generating/sending, full synchronous `l2cap_send` duration, CYW43 write/read duration, data-source polling duration/gaps, selected-handle Number Of Completed Packets events, and observed ACL credit extrema. The first successful tone's send-return timestamp complements the original pre-send timestamp. Neither is radio transmission or actuator onset. + +Timing totals are inclusive and overlap: a data-source poll can dispatch a completion callback that sends a packet, and a synchronous send can call the CYW43 writer. Do not add those totals as independent CPU costs. Read/write/poll measurements cover all controller traffic during the selected run; completion counts and outstanding packets are filtered to the selected connection. Free ACL slots come from the shared controller pool. Extrema are observations, not a complete occupancy trace. + +The instrumentation stores bounded counters in memory, not per-packet UART logs. GNU linker wrappers preserve each transport call's arguments, return values and call count. After a bounded poll returns nonempty input, the wrapper marks future receive work pending rather than draining recursively; this fairness behavior runs even while measurement is inactive. Verify wrapper call sites in the actual ELF and nonzero live counters; native tests alone cannot establish that a differently optimized SDK build retained the boundaries. + +## SRAM relocation (not RAM replacement) + +DS5Dongle executes selected hot code from SRAM instead of external XIP flash. Its full audio build also relocates roughly 220 KB of Opus code/data; that does not belong in this haptics-only experiment. + +Start by placing this experiment's packet synthesis/send callback and small waveform constants in SRAM using the Pico SDK's time-critical sections; verify symbol placement and SRAM cost in the linked ELF. Do not claim that this alone fixes transport latency. Keep a build switch for flash-versus-SRAM comparison without changing the protocol or clock. + +Only after timing evidence, consider selected L2CAP/HCI/CYW43 and USB hot call chains. Relocating only a wrapper leaves callees in flash. Broad object-section rewriting is SDK/compiler sensitive, consumes SRAM needed by stacks and buffers, and must be checked in the map. Do not wholesale replace memcpy/memset or move the entire stack without measurements. Shared flash-safe operations and BOOTSEL sampling remain legitimate jitter sources to measure, not disable unsafely. + +## Optional overclocking + +The initial investigation used stock 150 MHz. The qualified AIO default is now **300 MHz at 1.3 V**. CPU clocks do not increase Bluetooth air rate or repair protocol/scheduling errors. + +The first 300 MHz image stopped at its flash-divider guard: RP2350 had discarded optional boot2/XIP setup, so `PICO_FLASH_SPI_CLKDIV` was not applied. `PICO_EMBED_XIP_SETUP=1` fixed that boot path; its linked copy/execute path was checked. The corrected 300 MHz/1.3 V image booted and passed input-plus-rumble tests. On this board 400 MHz/1.3 V did not boot; the user-requested 400 MHz/1.4 V variant booted and passed a short run but was not faster in the measured workload. + +An explicit `SWITCH_PICO_SYS_CLOCK_MHZ=400` plus `SWITCH_PICO_OVERCLOCK_MV=1400` permits that experiment; it alone lifts the regulator's 1.3 V limit. Lower-voltage builds restore the limit after lowering voltage, including warm reboot. Stock 150 MHz is an explicit option, not the default AIO image. USB remains 48 MHz. Flash/PIO dividers are 4/4 at 300 MHz and 6/(5+86/256) at 400 MHz, keeping flash at 75/66.67 MHz and CYW43 SPI at about 37.5 MHz. + +[Pimoroni's measurements](https://learn.pimoroni.com/article/overclocking-the-pico-2) are useful experimental evidence, not a stability or lifetime guarantee for every Pico 2 W. Do not generalize their extreme-voltage/cooling experiments. Keep a recovery image that understands the current profile schema; old pre-migration firmware does not understand catalog 2/schema 6. + +## Verification and acceptance + +Build normal and opt-in firmware, run focused regressions, verify time-critical symbols and retained wake configuration. Exercise the actual USB CLI and connected controller. Capture live counters during the finite run. The current two-block format requires 46.875 reports/s and nominal HID+A2 traffic of 6,703.125 bytes/s, before L2CAP/HCI/radio overhead. Acceptance is 288/288 submissions with zero skipped slots and failures, repeated runs, bounded stop and continuing controller input. + +Physical acceptance requires correlating first-tone scheduling/sending with actuator onset using an accelerometer/contact microphone or a synchronized observation. Record controller model/firmware, packet gaps and CPU generation time. A successful `l2cap_send` is not an acknowledgement of playback. Only a measured low-latency result permits integration with Nintendo's per-side, per-band timeline. + +## Results + +### Initial single-block baseline (superseded) + +- Normal all-in-one, experimental SRAM, and experimental flash variants built successfully for Pico 2 W / RP2350 Arm, using the provisioned Pico SDK and GNU Arm 15.2.1 toolchain. +- 99 focused host-control, USB-management, build-helper and Bluepad32-preparation tests passed. Both native sender variants passed, including independent zlib validation of all 576 generated reports. +- The first hardware start exposed an additional integration defect: the Classic path leaves Bluepad32's cached `conn.protocol` unset. Eligibility now uses `gap_get_connection_type(handle)`, matching the existing backend identity code. Modeling the real unset field made the native test fail before this correction and pass afterward; the corrected hardware accepted the run. +- The initial SRAM image put the callback at `0x20000174` and waveform at `0x20000910`, with 2,024 bytes extra SRAM versus its flash counterpart. Final-image placement is recorded below. +- At this initial stage no wake capture, pairing reset, clock, voltage, FIFO-size or HCI-credit-limit change was performed. Wake transmission itself was not exercised. + +### Initial connected-controller run + +The SRAM variant was flashed and exercised over the actual USB management endpoint with a reconnected Sony DualSense (`054c:0ce6`) in slot 0, using the configured stock 150 MHz clock and unchanged regulator settings. Controller firmware revision and physical actuator onset were not measured. + +```sh +uv run switch-pico-config --timeout 15 haptics-experiment start --slot 0 --watch --json +``` + +The host captured 63 snapshots during run 1: + +| Measurement | Result | +| --- | ---: | +| Scheduled packet slots | 576 | +| Successful PCM submissions | 113 | +| Obsolete packet slots skipped | 463 | +| Send failures | 0 | +| CAN_SEND_NOW requests | 113 | +| Synchronous callbacks | 58 | +| Maximum packet generation time | 55 us | +| Maximum permission wait | 72,231 us | +| Maximum submission gap | 108,791 us | +| First tone submission after scheduled first tone | 15,618 us | +| Terminal elapsed time, including restoration | 6,264,335 us | + +The finite lifecycle reached `completed`, but **the PCM cadence acceptance criterion failed**: 113 submissions over a 6.144-second pattern window is about 18.4 packets/s, versus the required 93.75. `completed` means the finite run and compatibility restoration finished, not that every packet was delivered or low-latency playback was proved. There was no historical PCM queue replay; old sample slots were discarded. + +Run 2 exercised early stop: firmware-confirmed `stopped`, 37 submissions, 142 skipped slots, zero send failures. Controller input remained live afterward. Configuration generation 9 / CRC `b740995b` matched the pre-flash baseline. + +This established that synthesis was inexpensive but did not separate synchronous I/O, receive-loop work and credit wait. The follow-on measurements below did so. The flash-resident comparison was built, not flashed or timed; no SRAM latency improvement was established by this baseline. + +### Transport diagnosis and corrections + +1. **Instrumented sixteen-packet baseline:** 106/576 submissions, 470 skipped slots. Every poll consumed all 16 reads: 2,624 reads across 164 polls. Maximum poll duration 37,834 us; timer lateness 38,544 us. In contrast, synchronous send peaked at 1,429 us and CYW43 write at 1,090 us. Nested timing totals must not be summed. +2. **Receive fairness:** cap each experimental poll at one packet and explicitly reschedule further work after nonempty reads. Maximum timer lateness fell to 4,126 us. However, the three-slot outgoing cap still filled and permission waits reached 127,578 us; this alone did not sustain PCM. +3. **Negotiated credits:** capture the controller's raw initialization response: **8 ACL packets of 1,021 bytes**. Remove only the experimental software clamp, allowing BTstack to use the advertised capacity. Keep controller-to-host flow control enabled. The single-block sender improved to 356/576 submissions but still skipped 220 slots. +4. **Native mode handoff:** the user felt no vibration. Compatibility restoration had explicitly selected rumble; audio reports did not clear that selection. Sending the sized 0x10 native-state block first produced user-confirmed vibration, albeit weak and still interrupted. +5. **Compact two-block format:** [DS5Dongle's audio implementation](https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp) documents that audio controls can be reduced to a buffer-length value and packet counter. With microphone streaming disabled, mask 0x62 and length 3 leave room for two 64-byte blocks in the same 143-byte SDU. Buffer length 16 is the reference configuration's minimum. The counter advances by two. This halves required report cadence without reducing the 3 kHz sample rate or changing the audible pattern. + +### Final live acceptance + +Four consecutive six-second runs on the connected DualSense completed with **288/288 reports, zero skipped slots and zero send failures in every run**. Across those runs: + +| Measurement | Worst observed value | +| --- | ---: | +| Packet generation | 57 us | +| Send-permission wait | 191 us | +| Report gap (nominal 21,333.33 us) | 24,081 us | +| Submission lateness | 2,988 us | +| First tone send-return lateness | 3,095 us | + +The user confirmed **alternating pulses** and described them as still a bit weak. Peak 32/127 is intentional; it remained unchanged across packing comparisons. This establishes native haptic acceptance and recognizable channel alternation, not a precise physical latency measurement. + +Early-stop run 5 reached `stopped` with 72 submissions, zero skipped slots and zero failures. The host observed stop confirmation in approximately 111 ms, including USB querying/restoration. During that check 605 controller reports arrived, and the live input state advanced by 565 generations while remaining connected. Button-to-Switch latency and actuator-stop latency were not measured. + +Final verification: **122 focused tests passed**; normal, experimental SRAM and experimental flash images built. The SRAM callback is at `0x20000174`, waveform at `0x20000920`; the flash variant places them at `0x10014624` and `0x10068534`. SRAM relocation adds **2,056 bytes** versus the final flash variant. Real SDK call sites were verified to enter the transport wrappers. + +Configuration remains generation 9 / CRC `b740995b`; wake configuration remains included. Clock stays at the configured stock 150 MHz, voltage unchanged. No FIFO enlargement, incoming flow-control removal, speaker/microphone stream or broad stack relocation was needed. + +### Gameplay integration verification + +The initial gameplay image passed 513 real Switch-format USB OUT reports through the decoder, profile scaling, cross-core history and native sender: 513 observed host updates, zero dropped updates, zero Bluetooth skips and zero send failures. A stronger-command repeat also passed; the user confirmed correct alternating left/right effects. A simultaneous local-profile identification test retained all 513 updates, dispatched local confirmation, and stayed in native gameplay with no skips or failures. + +The user then tested an actual Switch game and reported that it **worked well but needed more strength**. Gameplay gain was increased to 1.5x with joint headroom limiting, then built, flashed and exercised again: all 513 commands arrived, 200 Bluetooth reports were submitted during the test, zero command drops/skips/send failures, and 1,443 controller input reports continued. Maximum observed packet-generation time was 680 us, permission wait 292 us, and report gap 24,343 us. These are firmware/transport measurements, not a physical latency bound. + +Stop and re-arm were also exercised: stop confirmation was observed in about 19.5 ms; the new run resumed continuous silence and controller input. That re-arm recorded one skipped silent startup slot, with no host commands or send failures; the active USB-driven tests above had no skipped slots. + +Final focused verification: **148 tests passed**, covering decoder fidelity, frequency/phase behavior, substeps, gain/headroom, watchdogs, overflow, startup/stop, profile gain/feedback, host controls, existing backend lifecycle, UART and build helpers. HD gameplay, normal all-in-one, deterministic experiment, and Pico/UART firmware builds succeeded. Configuration remained generation 9 / CRC `b740995b`; wake identity, clock and voltage were not changed. + +The HD gameplay image auto-arms on connection. This is a translation to DualSense actuators, not a promise of identical Nintendo force response. Physical onset still needs synchronized measurement. Follow-on IMU and rumble-response refinements are recorded below. + +### IMU scheduling correction + +After HD integration was committed as `6188fcb`, the user reported a small aiming lag. The USB scheduler had two avoidable delays: unsuccessful motion sends advanced the shared timer, and control replies reset the same timer used for motion. It also mutated quaternion/timestamp state before successful USB submission. + +The scheduler now uses an independent logical 15 ms motion clock, checks endpoint readiness before integrating, retries overdue data at the next ready opportunity, and rolls back motion state if queuing fails. Successful sends advance the logical clock rather than drifting with 8 ms USB polling. Long stalls skip missed periods instead of replaying a catch-up burst. Control replies and overdue motion get bounded service without one starving the other. No gyro scaling or smoothing was changed. + +New regressions failed on the old implementation and passed with the correction. Live USB checks measured about 15 ms average motion-report spacing, including during control-reply traffic; four deliberate backpressure trials recovered with successive-read gaps of about 7.96–7.98 ms. The user retested aiming on the Switch and reported it was “pretty good now.” + +### Accepted rumble response + +The user described the 1.5x mix as thin/hollow and lacking body. A 2x low / 1.5x high comparison was still insufficient; the user requested more high-band response and selected a fuller amplitude curve. The final balanced 2x / 0.8-power response was tested on the device and in the same game, and the user selected **“Fuller and good.”** + +The final USB-driven test delivered all **513 host commands**, with **zero dropped updates**, **zero Bluetooth skips**, and **zero send failures**. Controller input continued with 1,408 reports during the test. Maximum observed packet generation was 687 us and report gap 23,925 us. Local-feedback gain, frequencies, the 50 ms watchdog, the 21.333 ms lookback, and controller buffering remain unchanged. + +Final checks: **151 focused tests passed**. HD gameplay, deterministic experiment, normal all-in-one, and Pico/UART builds succeeded. The accepted response and IMU scheduler correction are flashed; image files are `build-hd-rumble/switch-pico.elf` and `.uf2`. The stream remains armed. These follow-on refinements are separate from the committed HD integration. + +### Qualified lower-latency transport and standard-image cutover + +The following comparisons used real 8 ms USB rumble output while continuously +reading USB motion reports, not only an idle PCM fixture. Read/poll means +include empty calls and nested work and must not be interpreted as additive +CPU utilization. + +| Configuration | Mean read call | Mean poll call | Result | +|---|---:|---:|---| +| Stock 150 MHz | 971 us | 2,494 us | 2,050 commands received; six skipped audio slots | +| 300 MHz / 1.3 V | 665 us | 1,370 us | 2,050 commands; zero drops/skips/send failures | +| 400 MHz / 1.4 V | 730 us | 1,485 us | 2,050 commands; zero drops/skips/send failures | +| 300 MHz + packet-level reads | 421 us | 1,139 us | 2,050 commands; zero drops/skips/send failures | +| 300 MHz + packet reads + credit batching | 274 us | 572 us | 2,050 commands; zero drops/skips/send failures | + +Packet-level reads use one published ring-index snapshot and one consumer +publication/notification per complete packet. Partial packets stay +unconsumed; a build-local SDK patch propagates SPI read errors. Credit +batching retains controller-to-host flow control and three host ACL credits, +returning at two completed packets or a bounded timer deadline. SCO remains +immediate. Lifecycle cancellation and synchronous callbacks have regressions. + +The packet-read stress run passed 8,194 commands with zero drops/skips/failures. +The subsequent 32-frame run also passed all 8,194 commands over about 65 s, +submitted 6,165 audio reports, and had a worst observed report gap of +12,594 us (nominal 10,666.67 us). No physical actuator-onset bound is claimed. + +The user described the controlled test effects as notably good. Preserve this +reference separately from actual game effects: alternate 256 ms of left word +`0x68402100` / neutral right, then neutral left / right word `0x4840a100`, +refreshing every 8 ms. Neutral is `0x40400100`; words are little-endian in the +eight-byte rumble payload of USB report `0x10`. These are fixed 160/320 Hz +bands with primary/secondary amplitude codes 80/16, using the normal profile +scaling and 2x/0.8 response—not an extra test-only boost. Sustained reference +effects do not establish that short or swept game effects will feel identical. + +Native XInput was exercised with the DualSense on API slot 1 while a Switch +Pro occupied slot 0. The Pro received two compatibility dispatches without +entering the native stream; four DualSense commands held left/right output +for 700 ms and explicit zeros stopped it. The stream stayed selected on the +DualSense and configuration generation remained 9. The former slot-0-only +auto-arm condition was removed and a mixed-controller regression verifies +first-eligible selection without a later controller stealing ownership. + +Final integrated verification includes 228 passing repository tests, standard +AIO/XInput builds with the native defaults, the manual fixture and unchanged +Pico/UART builds. The profile catalog was migrated and all sixteen stored +profiles were compared with the pre-migration backup; the temporary editor +profile and name were restored. No configuration, bond, or wake-identity reset +was part of the transport work. + +### Mixed-controller cadence limit + +Final testing with a Switch Pro plus a DualSense and continuous USB motion +reads changed the cadence decision. Rumble commands targeted only the +DualSense; the Pro supplied concurrent input traffic, not a Pro rumble +qualification. The 32-frame stream received all 2,050 +host commands but skipped **80 audio slots in 16.6 s**. Maximum permission +wait reached **17,180 us**, exceeding its 10,667 us interval, with all eight +outgoing credits observed in use. CPU clock remained 300 MHz/1.3 V. + +The standard build therefore uses **64 frames / 46.875 reports per second**, +without reverting its CPU or transport improvements. The same 2,050-command +mixed-controller comparison passed with zero drops/skips/send failures and +778 audio reports. Its worst observed report gap was 26,588 us. A subsequent +roughly 65-second mixed-controller stress run received all **8,194 commands** +and submitted **3,082 audio reports**, with **zero drops, skipped audio slots, +or send failures**. It processed 41,738 input reports and its worst observed +audio report gap was 26,655 us. + +The 32-frame path remains an explicit single-controller experiment and is +covered by the same native protocol/lifecycle tests; it is not advertised as +sustainable for mixed/four-controller operation. diff --git a/README.md b/README.md index 9cf6348..530d225 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,107 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration. +### Switch 2 wake from L + R + Home, PS, or Xbox + +The AIO firmware can wake a sleeping Switch 2 when a connected controller's +physical **L + R + System** chord becomes held: L + R + Home on +Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or +LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal +button and does not start wake advertising. Setup needs one wake advertisement +captured from a Joy-Con 2 already paired with that Switch 2. The generated +configuration is console-specific and is intentionally ignored by Git. + +The implementation replays the captured, unencrypted Switch 2 BLE wake +advertisement for two seconds at a 20 ms base interval. The configured AIO +firmware adopts the captured Joy-Con's public Bluetooth address once during +startup, before Bluepad32 admits controller connections. Wake bursts then +require no identity change and do not disconnect the input controller. This +follows the packet format documented by +[`ndeadly/switch2_controller_research`](https://github.com/ndeadly/switch2_controller_research/blob/master/bluetooth_interface.md) +and the capture/replay approach demonstrated by +[`alexvnesta/switch2controller`](https://github.com/alexvnesta/switch2controller) +and the MIT-licensed +[`Switch2-Wake-Beacon-ESPHome`](https://github.com/sickyj/Switch2-Wake-Beacon-ESPHome). + +Back up the complete Pico flash before replacing the AIO firmware with the +temporary capture image: + +```sh +picotool save -a -v switch-pico-before-wake-capture.uf2 +``` + +Then: + +1. Connect the Pico 2 W to the computer and build/flash the one-shot capture + firmware: + + ```sh + python3 build.py --wake-capture + ``` + + This also publishes `firmware/switch-pico-wake-capture.elf` and + `firmware/switch-pico-wake-capture.uf2`. + +2. Start the configuration tool. It auto-detects a single Pico USB serial + port; use `--port /dev/ttyACM0` when more than one Pico is attached: + + ```sh + python3 tools/configure_switch2_wake.py + ``` + +3. Detach a Joy-Con 2 that is already paired with the target Switch 2, put the + console to sleep, and press that Joy-Con's Home button. Do not press its + sync button. The capture firmware accepts the first public `ADV_IND` packet + with Nintendo's Switch 2 wake flag and nonzero console address, stops + scanning automatically, lights the onboard LED solid, and repeats the + captured record until the tool receives it. + +4. The tool validates the packet and atomically writes + `src/firmware/platform/pico/switch2_wake_config.h`. + +5. Restore the AIO firmware with the generated wake configuration: + + ```sh + python3 build.py --aio + ``` + +6. The Pico now has a new stable Bluetooth host address. Clear its old + Bluepad32 bonds by holding BOOTSEL for ten seconds, then open a pairing + window and pair each input controller again. This is a one-time re-pair. + + +The capture tool also accepts a saved serial log: + +```sh +python3 tools/configure_switch2_wake.py --input switch2-joycon-capture.log +``` + +With the configured AIO firmware powered while the console sleeps, first turn +on the paired input controller with Home, PS, or Xbox and let it reconnect to +the Pico. Then hold L + R and press its system button to send one wake burst. +Holding the chord does not retrigger it; release at least one chord button +before another attempt. Plain Home, PS, or Xbox is forwarded normally and does +not disturb the radio. Because the wake identity is stable from startup, +the wake code itself does not disconnect the input controller. + +Hardware testing found that the Switch 2 can briefly remove USB power while +entering or leaving sleep. A Pico powered only by that port necessarily +reboots, resetting the CYW43439 and dropping every controller link regardless +of the wake implementation. Continuous controller connectivity therefore +requires a properly isolated powered USB hub or another power arrangement +that keeps the Pico powered without backfeeding the console. Do not use an +unisolated USB Y-cable. + +The configured Pico continuously owns the captured Joy-Con's public Bluetooth +address, so keep that Joy-Con inactive while the AIO firmware is running to +avoid two radios using one address. + +To target another Switch 2, repeat the capture and configuration steps. To +disable wake, delete the generated `switch2_wake_config.h`, rebuild the AIO +firmware, clear the Pico's bonds, and pair the input controllers to its restored +factory address. Restore the full-flash backup only if you need to recover the +exact pre-capture firmware and persistent state. + ### Pairing up to four controllers 1. Flash and connect the Pico 2 W to the Switch. @@ -122,6 +223,7 @@ uv run switch-pico-config mode xinput uv run switch-pico-config mode dinput uv run switch-pico-config mode mac uv run switch-pico-config profiles list +uv run switch-pico-config profiles edit 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 @@ -130,40 +232,62 @@ uv run switch-pico-config pairings list uv run switch-pico-config pairings clear --yes ``` -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. +Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use separate append-only arenas before the adapter and Bluepad32 bond regions. Profile writes are individually checksummed, recover the previous record after interruption or corruption, compact atomically between arenas, skip unchanged data, and are rate-limited. Output mode is selected before TinyUSB starts and never changes while mounted. A mode command atomically stores the selection, resets synthetic input, reboots, follows the same physical USB port through re-enumeration, and verifies requested versus active mode. `auto` uses the verified Switch probe → Windows XInput transition; manual modes bypass probing. The controller chord **L + R + Select + Start + System** held for three seconds cycles `auto → switch → xinput → dinput → mac → auto`. The destructive ten-second BOOTSEL pairing reset also restores `auto` before reboot, providing physical recovery. Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble. -`profiles 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`. +`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port. + +The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. + +Profile names and controller aliases are stored as independently checksummed +catalog metadata. Runtime profiles use schema 6 and 384-byte records; names remain separate. The +editor can rename and copy profiles across controllers and slots, import or +export JSON backups, and reset one section without discarding the rest of the +draft. Its response-curve cards provide named presets, exact Q8.8 fine +adjustment, live curve markers, and one-click application to the opposite +stick or trigger. Connected-controller details include transport, battery, +and supported feedback/motion capabilities; **Identify** sends one bounded +rumble/light pulse only to the selected live controller. + +`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--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. `diagnostics` reports Bluetooth initialization stage, real BTstack timer callbacks, controller report traffic, host/local rumble requests and dispatches, active/rumble-capable slot counts, and pending feedback. The AIO -build bounds each CYW43 HCI drain to 16 packets so continuous multi-controller -traffic returns to BTstack timers instead of starving rumble stop/refresh, -configuration, pairing, and profile work. +build services one CYW43 packet per poll and explicitly reschedules remaining +input. Packet-level ring reads and bounded incoming-credit batching reduce +bus work without disabling flow control. `haptics-experiment profile --json` +adds transport timings, clock/voltage settings and packet-size diagnostics. ### Per-controller profiles -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. +The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. -- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`. +- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 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. +- Confirmation uses one to eight 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. -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. +Profile hotkeys use physical controls, with trigger thresholds from the base profile. Reserved output-mode handling runs first, followed by direct profile shortcuts, profile cycling, and motion toggle. Shift consumes its modifier, macro bindings use unshifted controls, Turbo gates physical button sources, the selected button map and base analog transforms produce output, and explicit macro overrides apply last. + +- **Direct shortcuts:** assign one modifier and unique face/D-pad selectors to profiles 1–8. Chord controls are consumed; holding or changing a profile cannot retrigger the same press. Activation and feedback wait for the atomic commit. The existing one-second minimum commit interval still applies to rapid successive changes. +- **Shift:** one alternate button-only map, enabled while held or toggled on a fresh modifier press. It does not layer analog tuning, Turbo settings, or macro definitions. Toggle state resets with connection, profile, mode, and configuration changes. +- **Turbo/Burst:** shared settings with optional per-button overrides; 1–30 Hz, 1–99% duty, and 1–255 finite Burst pulses. Hold Turbo follows the button, Auto Burst toggles continuous repetition, and Burst runs its configured count after a press. Defaults remain 15 Hz/50%. Counts describe scheduled ON windows; narrow phases can be missed by host report sampling, so the editor warns instead of silently changing settings. +- **Macros:** four independently triggered sequences, up to eight steps each, sharing sixteen decoded steps and a **136-byte sparse stream**. Wait/button/all-field steps cost 3/5/17 bytes. Playback supports Once, While held, Toggle, and bounded Repeat (1–255 cycles). Repeating zero-duration sequences are rejected; clock gaps skip elapsed cycles rather than replay a backlog. +- **Authoring:** insert, duplicate, remove, drag-reorder, or move steps with keyboard controls. Duration and byte budgets update on every edit. The visual preview shows overridden versus passthrough fields and never injects controller output. +- **Recording:** Record input captures timestamped firmware-side changes before profile mapping, not the editor's 75 ms snapshots. Choose channels and explicit analog quantization; the initial state consumes one entry and long holds split at ten seconds. The UI limits capture to the remaining 8/16/136 budget, visibly reports capacity/time/disconnect endings, and retains data for review. **Use recorded steps** changes only the unsaved macro; **Save to Pico** is still separate. Input-report and millisecond playback precision remain real limits. ### Per-controller motion toggle -Press **D-pad Up + R + Start** together to disable or re-enable motion for one controller. On DualSense, use **D-pad Up + R1 + Options**. +The default motion action is **D-pad Up + R + Start**; on DualSense, use **D-pad Up + R1 + Options**. Each profile can replace it with any button/trigger chord from the graphical editor; a stored empty chord selects the default. - A longer rumble confirms motion disabled. - A shorter rumble confirms motion enabled. @@ -171,7 +295,7 @@ Press **D-pad Up + R + Start** together to disable or re-enable motion for one c - Other controller slots are unaffected. - Motion returns to enabled after disconnect or reboot. -Edit `controller_hotkey_config.h` to change the chord, default state, or feedback patterns. +Edit `src/firmware/input/controller_hotkey_config.h` only to change the default motion-enabled state or its feedback patterns. ### Per-slot controller colors @@ -199,7 +323,15 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and ### Rumble per controller -Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller. +Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps. + +The standard AIO and XInput builds now use **300 MHz at 1.3 V**, packet-level CYW43 reads, bounded HCI credit returns, and native DualSense haptics by default. The first eligible DualSense/DualSense Edge that becomes ready can occupy the one native stream, in any slot; later controllers do not steal it. Other rumble-capable controllers retain their tested parser-specific output. To change the selected native controller manually, stop the current run and use `haptics-experiment gameplay --slot N` (API slots are zero-based). + +In Switch mode, that stream preserves decoded left/right, low/high-band HD commands. In XInput mode, strong/low magnitude drives the left 160 Hz carrier and weak/high drives the right 320 Hz carrier; these commands stay active until changed or stopped. XInput does not supply Nintendo frequency/substep detail. USB reset, unmount, and suspend stop held host rumble. Auto-mode XInput additionally reboots to Switch probe after unmount, by the existing one-attachment policy; manual XInput is exempt. + +Standard native gameplay uses **64 stereo frames at 3 kHz** per Bluetooth report (46.875 reports/s), with 21.333 ms causal lookback. The 32-frame mode passed single-controller tests but skipped audio slots under mixed Pro/DualSense load, so it is an explicit experiment: `SWITCH_PICO_HD_PACKET_FRAMES=32` requires the optimized transport and at least 300 MHz. It uses 93.75 reports/s and 10.667 ms lookback but is not the mixed-controller default. Native streaming continues silence while idle; no physical actuator-onset bound is claimed. See [HAPTICS_EXPERIMENT.md](HAPTICS_EXPERIMENT.md). + +400 MHz is an explicit experiment: use `SWITCH_PICO_SYS_CLOCK_MHZ=400` and `SWITCH_PICO_OVERCLOCK_MV=1400`. This board did not boot at 400 MHz/1.3 V; 1.4 V booted and passed a short run but did not outperform 300 MHz in the comparison. USB stays at 48 MHz and flash/radio bus dividers remain bounded. UART builds are unchanged; a stock-clock AIO build is an explicit recovery/compatibility option, not the normal default. ### Hardware validation @@ -223,7 +355,7 @@ swift tools/Test-SwitchPicoMac.swift The diagnostic matches only `CAFE:4021`, identifies each of the four interfaces by interface and location, and prints changed axes, hats, and buttons with their HID usage and logical range. The four signed stick axes remain `X`/`Y`/`Z`/`Rx`. Move each analog trigger slowly and confirm output such as `LeftBrake page=0x02 usage=0xC5 logical=0...65535 value=32768` and `RightAccelerator page=0x02 usage=0xC4 logical=0...65535 value=32768`; each trigger should traverse intermediate values across `0...65535`, not only the endpoints. The diagnostic continues through hot-plug events until Ctrl-C. If opening a device fails, allow the terminal (or the app launching Swift) under **System Settings → Privacy & Security → Input Monitoring**, then rerun it. -On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes. +On the tested Linux host, all four HID interfaces enumerate immediately at the USB layer, but `auto` initially presents them as a composite Nintendo Pro Controller while probing the host. Linux binds `hid-nintendo` to each interface and performs synchronous handshake and calibration requests with retries; incomplete composite interoperability causes `-110` timeouts and can accumulate into a 15–30 second user-visible delay before the transient hidraw nodes are removed. The timeout is not observed on the Switch. For a Linux laptop, persist `dinput` for immediate generic-HID enumeration (`uv run switch-pico-config mode dinput`) or `xinput` when rumble is required, then restore `auto` or `switch` before console use. Profile management uses endpoint-zero vendor transfers and does not depend on `hid-nintendo`. Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license. @@ -231,7 +363,7 @@ Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry ## Limitations - No NFC/amiibo/IR support. -- Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation. +- Rumble is controller-specific: UART uses SDL3 haptics; AIO uses the selected DualSense native PCM backend or the controller's existing Bluepad32 rumble implementation. Native Switch-family forwarding remains planned. - The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB. - In XInput output mode, Home/System is carried in the raw XUSB Guide bit `0x0400`, and Capture is carried in the de-facto Share/reserved bit `0x0800` used by modern open XUSB stacks. The standard Microsoft XInput headers define neither Guide nor Share for `XINPUT_GAMEPAD.wButtons`, so `XInputGetState` does not expose either button portably. Guide may be reserved or intercepted by the OS, while Share/Capture support depends on the installed driver or consumers such as GameInput and Steam; qualify the intended controller, driver, and application on real Windows hardware. @@ -263,7 +395,7 @@ RUMBLE (force feedback) ### HD rumble translation -Nintendo sends two stateful four-byte HD-rumble actuator words. Each word can carry full or relative high/low frequency and amplitude commands with up to three subsamples; amplitude uses a logarithmic curve. The Pico decodes both words once in `SwitchHapticsDecoder`, retains actuator state across packets, and reduces the result to conventional low/strong and high/weak motor magnitudes. SDL3 and Bluepad32 cannot reproduce the original linear-actuator frequencies or left/right spatial effects, but they receive the correct nonlinear band amplitudes. +Nintendo sends two stateful four-byte HD-rumble actuator words with full/relative low/high-band commands and up to three substeps. `SwitchHapticsDecoder` retains this timeline as well as conventional strong/weak magnitudes. The selected DualSense's native PCM backend uses the timeline; ordinary controller-parser and UART/SDL paths use the magnitudes. Preserving frequency intent is not a claim of identical force response across actuators. Native forwarding for genuine Switch-family controllers is [planned separately](ADAPTER_PARITY_PLAN.md#native-switch-family-hd-rumble--planned), not enabled by the DualSense implementation. The UART return frame carries the decoded result rather than raw HD-rumble bytes: @@ -589,6 +721,45 @@ After changing any IMU conversion, calibration, timing, or report packing: 5. Inject a known single-axis gyro rate and decode the packed quaternion. The corresponding component must change smoothly with the expected sign. 6. Perform the decisive end-to-end check: genuine Pro Controller → SDL3 bridge → UART → emulated Pico → Zelda. This path was confirmed correct after the mode-2 fix. +## Firmware resource usage + +The Pico 2 W AIO build is measured from `build-aio/switch-pico.elf` and its +linked binary, not from the larger debug-bearing ELF or UF2 transport file: + +| Resource | Used or reserved | Device capacity | +|---|---:|---:| +| Executable flash image | 746,568 bytes | 4 MiB | +| Indexed profile arenas | 256 KiB | 4 MiB flash | +| Adapter configuration | 8 KiB | 4 MiB flash | +| BTstack bonds | 8 KiB | 4 MiB flash | +| RP2350 terminal sector | 4 KiB | 4 MiB flash | +| Allocated/reserved SRAM, including heap and stacks | 130,784 bytes | 520 KiB | + +The executable plus persistent reservations consume 1,029,192 bytes of flash, +leaving 3,165,112 bytes. Allocated SRAM sections leave 401,696 bytes of link-time +headroom; this is not a runtime heap high-water measurement. Core 0 has a +4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested +catalog migration/compaction rather than overflowing its 4 KiB scratch bank. + +Profiles use two 128 KiB append-only arenas and retain 248 physical record +slots. Catalog 2 uses a 128-byte header plus a 384-byte profile in the same +512-byte stride. The second page is programmed before the header-containing +first page, and records are read back before publication. Profile names +remain 256-byte metadata payloads and aliases remain 32 bytes. The compact +index stores locations and generations; active/fallback profiles for observed +identities and the selected profile are decoded, not the entire database. + +The catalog supports eight profiles for the global fallback and each of 16 +stable identities. Missing records resolve to defaults, so profiles 5–8 do +not consume flash until changed. When an arena fills, the latest indexed +records are compacted into its peer and the new superblock is published last. +Interrupted or corrupt appends leave the previous valid record available. +Catalog 1 and retired four-profile banks migrate through the alternate arena; +the old published data is retained until all copies and the new superblock +verify. Schema 1–5 profiles retain their meaning when decoded as schema 6. +Keep a profile export before downgrading: older firmware cannot read the new +catalog/profile format. + ## References - GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE - nxbt (Switch controller research/tools): https://github.com/Brikwerk/nxbt diff --git a/SWITCH_FAMILY_HD_RUMBLE_PLAN.md b/SWITCH_FAMILY_HD_RUMBLE_PLAN.md new file mode 100644 index 0000000..d0d2091 --- /dev/null +++ b/SWITCH_FAMILY_HD_RUMBLE_PLAN.md @@ -0,0 +1,153 @@ +# Switch-family native HD-rumble implementation handoff + +Status: **planned, not implemented**. This document is self-contained for another coding agent. The shorter project-roadmap version is in `ADAPTER_PARITY_PLAN.md` under “Native Switch-family HD rumble — Planned”. + +## Goal + +Deliver native Nintendo rumble commands to qualified Switch-family Bluetooth controllers without collapsing left/right and low/high-band information to conventional motor magnitudes. This is a separate backend from DualSense PCM synthesis. + +Start with a genuine original Switch Pro Controller, then original standalone Joy-Con L/R. Keep third-party Switch-mode controllers on their existing tested fallback until individually qualified. Do not implement Joy-Con logical pairing or assume Switch 2/NSO controllers share the same capabilities. + +## Current project state and coordination + +- Branch at handoff: `feature/controller-profiles`. +- `44a474e`: roadmap plan for native Switch-family HD rumble. +- `3c2d9fa`: Set B profiles, catalog migration, recording, optimized transport and native DualSense/XInput work. +- Other transport qualification/artifact work may still be in progress. Coordinate with the active agent before editing shared files; do not reset, stash, or overwrite its changes. Re-read current code rather than relying on line numbers here. +- Standard AIO/XInput builds now use 300 MHz/1.3 V and the optimized CYW43 transport. The first eligible DualSense may own one native PCM stream, regardless of slot. Other models retain their controller-specific rumble path. +- Profiles are schema 6 / 384 bytes; catalog 2 keeps a 512-byte record stride and two 128 KiB arenas. Preserve migration, identity keys, names, active indices and atomic publication. +- Preserve Bluetooth bonds, calibration, the UART wire protocol and the private `src/firmware/platform/pico/switch2_wake_config.h`. Do not expose that file's contents or change the configured wake identity. + +### Important timing qualification caveat + +Do not generalize single-controller DualSense results to mixed-controller loads. A 32-frame/93.75-packet-per-second run passed roughly 65 seconds with one controller, but a later Switch Pro + DualSense test with continuous USB motion reads recorded **80 skipped audio slots over 16.6 seconds**, despite receiving all 2,050 USB commands with no command drops or send failures. Maximum permission wait was 17,180 us and the eight outgoing ACL credits were observed exhausted. The standard native cadence is consequently **64 frames / 46.875 packets per second** at the same 300 MHz/1.3 V, with 32 frames an explicit experiment. Preserve the current cadence choice and coordinate before changing it as part of this Nintendo backend task. + +The Nintendo path should not need PCM packets at all. Its small native commands have a different bandwidth budget, which still needs real multi-controller measurement. + +## Read these code paths first + +| Area | Files / symbols | Relevant facts | +|---|---|---| +| Switch host decoder | `src/firmware/usb/switch/switch_haptics.h/.cpp`, `SwitchHapticsDecoder`, `ControllerRumbleOutput`, `SwitchHapticsFrame` | Two sides, each with up to three decoded low/high frequency/amplitude substeps. Original eight wire bytes are not retained in the output envelope. | +| Intensity scaling | `src/firmware/profile/controller_profile_transform.cpp`, `controller_profile_scale_host_rumble` | Strong scales low band and weak scales high band on both sides. Q15 amplitudes are decoder-normalized values, not raw Nintendo amplitude codes. | +| Routing and lifetime | `src/firmware/input/bluepad32_input_backend.cpp` | Generation-tagged slots, compatibility mailbox, native submission, local/profile feedback, controller ready/disconnect events. Preserve slot isolation. | +| Existing Nintendo output | `patches/bluepad32-sdl3-imu.patch`; generated `build-aio/_deps/bluepad32-src/src/components/bluepad32/parser/uni_hid_parser_switch.c` | Modify the patch/build-local copy, not the upstream checkout or arbitrary SDK files. | +| Parser functions | `send_subcmd`, `switch_encode_rumble`, `switch_send_dual_rumble_now`, `switch_stop_rumble_now`, `set_led`, `fsm_enable_rumble` | Existing conventional path enables vibration with 0x48, uses fixed frequencies and a 40 ms refresh. | +| Transport | `src/firmware/input/haptics_transport_probe.*`, `src/firmware/platform/pico/cyw43_packet_transport.c`, `patches/btstack-credit-batch.patch` | Bounded receive fairness, packet-level reads, real per-handle credit accounting. Do not remove incoming flow control or invent extra controller credits. | +| Prior evidence | `HAPTICS_EXPERIMENT.md` | Distinguishes measured submission timing from physical actuator onset and preserves the accepted controlled-effect reference. | + +### Existing parser issues the new owner must resolve + +1. `send_subcmd()` uses a process-global four-bit packet counter. Move sequence state to each physical parser/device instance and share it across that device's rumble and subcommand reports. +2. Player-LED requests are built with zeroed rumble fields. All applicable subcommands must carry the current effective rumble state while native rumble is active. +3. The parser has duration, delayed-start and refresh timers. A second independent native writer cannot safely coexist with those timers or their stale callbacks. +4. `switch_encode_rumble()` currently takes one amplitude for both bands of an actuator. It is not a complete encoder for independent low/high amplitudes or all compressed multi-substep forms. +5. Bluepad32 explicitly treats Joy-Cons as separate, horizontally mapped controllers. There is no existing two-Joy-Con logical pair to route stereo output into. + +## Model policy + +- Initial target: original genuine Pro Controller `057E:2009`. +- Next: original Joy-Con L `057E:2006`, Joy-Con R `057E:2007`. +- A Pro-like name, VID/PID or parser type is not sufficient proof of full native compatibility; clones can present the same identity. Use qualified model/firmware evidence and conservative handling of ambiguous devices. +- Preserve the 8BitDo Ultimate's tested enable/fixed-frequency/refresh behavior until that exact model passes native qualification. +- Do not send DualSense 0x32 PCM packets to Nintendo devices. +- Do not apply the DualSense 2x/0.8-power response curve to Nintendo actuators. +- Switch 2 controllers and NSO/retro models require separate protocol and actuator qualification. Some Nintendo-family devices have no HD-rumble actuators. + +## Implementation sequence + +### 1. Establish protocol truth with a genuine Pro Controller + +Capture real console USB rumble words and Bluetooth reports for: + +- neutral and explicit stop; +- each actuator independently; +- low and high bands independently and together; +- repeated words, absolute and relative commands; +- all observed one/two/three-substep forms; +- frequency sweeps and safe amplitude changes. + +Validate accepted Bluetooth encodings, byte order, amplitude normalization and repeated-word behavior. The older public fixed-state tables do not prove every compressed command's behavior. Use independent golden data and real controller acceptance, not only `decode(encode(x))` against the same implementation. + +### 2. Preserve command information and add bounded delivery + +Extend the existing rumble envelope with original eight-byte data and explicit validity/unmodified state while retaining the decoded, profile-scaled timeline. Conventional XInput/UART/local feedback must not accidentally acquire valid raw Nintendo bytes. + +Use a fixed-capacity, generation-tagged Core-0-to-Core-1 command queue. Add one native output owner per physical Switch device, integrated with the existing parser's sender. No heap allocation, PCM FIFO or unbounded catch-up loop. + +Nintendo rumble-only Bluetooth output is report `0x10`: transaction byte, report ID, four-bit counter and eight rumble bytes, **11 bytes before L2CAP/HCI/radio overhead**. At 125 reports/s that is about 1,375 payload bytes/s. If the original command can be sent directly, it need not inherit DualSense's PCM lookback. Measure actual queue/submission delay before making latency claims. + +### 3. Implement scaling and state recovery, not just unity passthrough + +Raw forwarding is a fast path only when profile gain is unity and the physical controller's command state is synchronized. For changed gain, encode the scaled low/high-band timeline using verified Nintendo encodings and safe amplitude limits. + +- Preserve exact silence and valid unity behavior. +- Quantize against the supported Nintendo amplitude/frequency representation; do not treat normalized Q15 amplitudes as wire codes. +- Retain all representable substeps. +- If scaling makes a multi-step sequence impossible to encode in one word, explicitly qualify a bounded legal packet schedule or documented quantization policy. Do not silently replace it with peak/latest magnitude output. +- After overflow, dropped history, feedback or reconnect, establish a legal absolute current-state/neutral baseline before forwarding commands that depend on prior state. +- Never replay an obsolete vibration backlog to catch up. + +### 4. Centralize LEDs, local feedback, stop and disconnect + +The same per-device owner must serialize rumble-only reports and subcommands. Piggyback current effective rumble into applicable `0x01` reports rather than interrupting an effect with default/zero fields. + +Local confirmation temporarily overrides host output, while the host timeline keeps advancing. After confirmation, resume the current host state, not an expired effect. + +Preserve the existing 50 ms Switch-command expiry policy. Prioritize explicit stop. Cancel all parser rumble timers when transferring ownership or disconnecting; callbacks and queued commands from an old generation must never touch a replacement device. Preserve ordinary setup/calibration and subcommand replies. + +For XInput input, use two-magnitude stateful effects with fixed native carriers and explicit zero/host-lifetime stop. XInput does not contain Nintendo frequency or substep detail. + +### 5. Add explicit single-actuator and third-party behavior + +A standalone Joy-Con has one actuator but is currently exposed as a standalone controller. Do not simply discard whichever host side is absent. Define a deterministic mono downmix preserving each band's dominant contribution, with a documented frequency/tie rule and safe amplitude limits. Spatial fidelity is necessarily lost. + +Routing a stereo left/right pair belongs with a separate logical Joy-Con pairing feature; do not bundle that input-topology change into this task. + +Third-party models remain on their proven compatibility policy unless their native behavior is independently qualified. Retain useful existing rumble rather than broadly enabling an unverified protocol. + +### 6. Qualify and enable per model + +Permanent regression cases should defend observable behavior: + +- independent golden packet/codec vectors and neutral values; +- zero, unity and intermediate profile intensity; +- band/actuator isolation and substep order; +- per-device counter wrap and multiple devices; +- LED/subcommand coexistence during effects; +- local feedback resume, timeout and explicit stop; +- queue pressure, loss/resynchronization, disconnect/reuse; +- compatibility fallback and unsupported models. + +Run existing relevant suites, then the complete repository suite: + +```sh +uv run pytest -q tests/test_switch_haptics_native.py \ + tests/test_controller_profile_transform_native.py \ + tests/test_bluepad32_backend_lifecycle_native.py \ + tests/test_prepare_bluepad32.py tests/test_usb_output_driver_native.py +uv run pytest -q +``` + +Build affected AIO, XInput and UART configurations. `build.py` flashes by default; use CMake for build-only checks and coordinate hardware access before flashing. + +Hardware acceptance: safe frequency/amplitude sweeps; actual captured game effects; one versus four controllers; mixed Nintendo/DualSense traffic; simultaneous motion/input, LEDs and profile writes; reconnect and stop. Report p50/p95/p99/worst host-receipt-to-HCI submission, lost/resynchronized commands and physical actuator onset where instrumentation exists. HCI acceptance is not proof of playback. + +Enable native output only for models that pass qualification. Keep the fallback available for ambiguous/unsupported devices. Do not claim full Switch-family support from testing a single Pro Controller. + +## Non-goals + +- Nintendo controller firmware updating or modifying calibration flash. +- Bond resets or wake-identity changes. +- USB host support, audio/headphone output, NFC/IR or adaptive triggers. +- New per-game detection, scripts or unbounded action layers. +- Re-tuning DualSense gain or general overclock experiments as a side task. +- Pretending unmeasured physical latency or cross-actuator force equivalence. + +## Protocol references + +- Reports, neutral values and safe amplitude notes: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md +- Frequency/amplitude encoding tables: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md +- Current implementation patches and investigation: `patches/bluepad32-sdl3-imu.patch`, `HAPTICS_EXPERIMENT.md`, `ADAPTER_PARITY_PLAN.md`. + +Treat community reverse-engineering references as evidence to validate, not an official guarantee. Preserve applicable source licenses and do not copy noncommercial reference implementations into this project without resolving their licensing. diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h index 424d437..e006856 100644 --- a/bluepad32_config/btstack_config.h +++ b/bluepad32_config/btstack_config.h @@ -56,9 +56,13 @@ #define MAX_NR_WHITELIST_ENTRIES 16 #define MAX_NR_LE_DEVICE_DB_ENTRIES 16 -// Keep controller buffers and controller-to-host flow control enabled to avoid -// overrunning the shared CYW43 bus. +// Retain the normal firmware's conservative outgoing credit cap. The PCM +// experiment uses the controller's advertised ACL capacity (8 on CYW43439); +// BTstack still clamps to the real HCI Read Buffer Size response. +#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT #define MAX_NR_CONTROLLER_ACL_BUFFERS 3 +#endif +// Incoming flow control remains enabled; do not overrun the shared CYW43 bus. #define MAX_NR_CONTROLLER_SCO_PACKETS 3 #define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL #define HCI_HOST_ACL_PACKET_LEN 1024 diff --git a/build.py b/build.py old mode 100644 new mode 100755 index eda4f08..a9f0bd0 --- a/build.py +++ b/build.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Build and flash the project with optional grip color overrides.""" + import argparse import os import random @@ -15,17 +16,17 @@ CONFIG_FILE = FIRMWARE_SOURCE_DIR / "platform" / "pico" / "controller_color_conf BUILD_DIR = SCRIPT_DIR / "build" AIO_BUILD_DIR = SCRIPT_DIR / "build-aio" FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility" +WAKE_CAPTURE_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_capture" +WAKE_CAPTURE_BUILD_DIR = SCRIPT_DIR / "build-wake-capture" FIRMWARE_DIR = SCRIPT_DIR / "firmware" FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf" FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2" AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf" AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2" -FEASIBILITY_FIRMWARE_ELF_PATH = ( - FIRMWARE_DIR / "switch-pico-adapter-feasibility.elf" -) -FEASIBILITY_FIRMWARE_UF2_PATH = ( - FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2" -) +FEASIBILITY_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-adapter-feasibility.elf" +FEASIBILITY_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2" +WAKE_CAPTURE_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-wake-capture.elf" +WAKE_CAPTURE_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-wake-capture.uf2" ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser() UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser() @@ -38,11 +39,14 @@ MACROS = tuple( 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" + for build_dir in ( + BUILD_DIR, + AIO_BUILD_DIR, + FEASIBILITY_BUILD_DIR, + WAKE_CAPTURE_BUILD_DIR, + ) ) +TOOLCHAIN_COMPILER = "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc" class BuildEnvironmentError(RuntimeError): @@ -253,15 +257,14 @@ def configure_pico_environment( 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) - ) + 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.", @@ -279,6 +282,11 @@ def parse_args(): action="store_true", help="Build and flash the Pico 2 W automatic Switch/XInput prototype.", ) + mode_group.add_argument( + "--wake-capture", + action="store_true", + help="Build and flash the automatic Switch 2 wake capture firmware.", + ) group = parser.add_mutually_exclusive_group() group.add_argument( "--random-grip-color", @@ -290,16 +298,22 @@ def parse_args(): metavar="RRGGBB", help="Set every emulated controller slot to the provided hex color.", ) - return parser.parse_args() + args = parser.parse_args() + if args.wake_capture and (args.random_grip_color or args.grip_color): + parser.error("wake capture firmware does not use grip-color options") + return args + def random_hex_color(): return "".join(f"{random.randrange(256):02X}" for _ in range(3)) + def validate_custom_color(value): if not re.fullmatch(r"[0-9A-Fa-f]{6}", value): raise ValueError("Color must be a 6-digit hex value like FF8800.") return value + def update_grip_colors(rgb_hex): if not CONFIG_FILE.exists(): sys.stderr.write(f"Error: Cannot find {CONFIG_FILE}\n") @@ -331,6 +345,7 @@ def update_grip_colors(rgb_hex): sys.stderr.write(f"Error writing {CONFIG_FILE}: {exc}\n") sys.exit(1) + def run_cmd(command): try: subprocess.run(command, cwd=SCRIPT_DIR, check=True) @@ -340,12 +355,15 @@ def run_cmd(command): except subprocess.CalledProcessError as exc: sys.exit(exc.returncode) + def resolve_picotool(): env_val = os.environ.get("PICOTOOL_PATH") if env_val: env_path = Path(env_val).expanduser() if not env_path.exists(): - sys.stderr.write(f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n") + sys.stderr.write( + f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n" + ) sys.exit(1) return env_path @@ -353,9 +371,12 @@ def resolve_picotool(): if found: return Path(found) - sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n") + sys.stderr.write( + "Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n" + ) sys.exit(1) + def build( aio, adapter_feasibility, @@ -379,6 +400,20 @@ def build( "-DPICO_BOARD=pico", "-DSWITCH_PICO_INPUT_BACKEND=UART", ] + # AIO and XInput images use the qualified native transport by default. + # UART remains unchanged; explicit flags also prevent stale cache choices. + native = "ON" if aio or adapter_feasibility else "OFF" + definitions.extend( + [ + f"-DSWITCH_PICO_HAPTICS_EXPERIMENT={native}", + f"-DSWITCH_PICO_HD_RUMBLE={native}", + f"-DSWITCH_PICO_SYS_CLOCK_MHZ={300 if native == 'ON' else 150}", + "-DSWITCH_PICO_OVERCLOCK_MV=1300", + "-DSWITCH_PICO_HD_PACKET_FRAMES=64", + f"-DSWITCH_PICO_CYW43_PACKET_READ={native}", + f"-DSWITCH_PICO_HCI_CREDIT_BATCH={native}", + ] + ) run_cmd( [ @@ -392,12 +427,12 @@ def build( ) run_cmd(["cmake", "--build", str(build_dir)]) - missing_artifacts = [ - path for path in (elf_path, uf2_path) if not path.is_file() - ] + missing_artifacts = [path for path in (elf_path, uf2_path) if not path.is_file()] if missing_artifacts: missing = ", ".join(str(path) for path in missing_artifacts) - sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n") + sys.stderr.write( + f"Error: Build did not produce required artifact(s): {missing}\n" + ) sys.exit(1) FIRMWARE_DIR.mkdir(parents=True, exist_ok=True) shutil.copy2(elf_path, firmware_elf_path) @@ -409,6 +444,35 @@ def build( print(f"Copied UF2: {firmware_uf2_path}") +def build_wake_capture(): + elf_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.elf" + uf2_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.uf2" + run_cmd( + [ + "cmake", + "-S", + str(WAKE_CAPTURE_SOURCE_DIR), + "-B", + str(WAKE_CAPTURE_BUILD_DIR), + "-DPICO_BOARD=pico2_w", + ] + ) + run_cmd(["cmake", "--build", str(WAKE_CAPTURE_BUILD_DIR)]) + missing_artifacts = [path for path in (elf_path, uf2_path) if not path.is_file()] + if missing_artifacts: + missing = ", ".join(str(path) for path in missing_artifacts) + sys.stderr.write(f"Error: Wake capture build did not produce: {missing}\n") + sys.exit(1) + FIRMWARE_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(elf_path, WAKE_CAPTURE_FIRMWARE_ELF_PATH) + shutil.copy2(uf2_path, WAKE_CAPTURE_FIRMWARE_UF2_PATH) + print(f"Built wake capture ELF: {elf_path}") + print(f"Built wake capture UF2: {uf2_path}") + print(f"Copied ELF: {WAKE_CAPTURE_FIRMWARE_ELF_PATH}") + print(f"Copied UF2: {WAKE_CAPTURE_FIRMWARE_UF2_PATH}") + return elf_path + + def flash(elf_path, allow_elf_override): picotool = resolve_picotool() if not elf_path.exists(): @@ -421,6 +485,7 @@ def flash(elf_path, allow_elf_override): sys.exit(1) run_cmd([str(picotool), "load", str(elf_path), "-fx"]) + def main(): args = parse_args() try: @@ -428,6 +493,10 @@ def main(): except BuildEnvironmentError as exc: sys.stderr.write(f"Error: {exc}\n") sys.exit(1) + if args.wake_capture: + wake_capture_elf = build_wake_capture() + flash(wake_capture_elf, allow_elf_override=False) + return color = None @@ -477,5 +546,6 @@ def main(): allow_elf_override=not args.aio and not args.adapter_feasibility, ) + if __name__ == "__main__": main() diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index f8b3088..84ddd36 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 fed55c0..5919357 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 01de644..b2f4218 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 55071c4..5919357 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico-wake-capture.elf b/firmware/switch-pico-wake-capture.elf new file mode 100755 index 0000000..d74d007 Binary files /dev/null and b/firmware/switch-pico-wake-capture.elf differ diff --git a/firmware/switch-pico-wake-capture.uf2 b/firmware/switch-pico-wake-capture.uf2 new file mode 100644 index 0000000..9aaf841 Binary files /dev/null and b/firmware/switch-pico-wake-capture.uf2 differ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index 3b03c1f..fb9aa07 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -102,7 +102,13 @@ index a22ef26..3d5ecef 100644 ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r +@@ -618,11 +618,11 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + if (r->buttons[2] & 0x01) + ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS +- if (r->buttons[2] & 0x04) +- ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // "mute" button ++ if (r->buttons[2] & 0x02) ++ ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // Touchpad click // Gyro for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) { @@ -578,3 +584,30 @@ index 67841e8..9fe7134 100644 d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color; d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble; logi("Device detected as PS Move: 0x%02x\n", type); +diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c +--- a/src/components/bluepad32/bt/uni_bt.c ++++ b/src/components/bluepad32/bt/uni_bt.c +@@ -73,6 +73,12 @@ + #include "uni_log.h" + #include "uni_property.h" + ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. ++extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, ++ uint16_t local_cid); ++#endif ++ + // globals + bd_addr_t uni_local_bd_addr; + +@@ -473,6 +479,10 @@ void uni_bt_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t* pack + if (device == NULL) { + loge("--->>> CANNOT FIND DEVICE"); + } else { ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) ++ break; ++#endif + uni_hid_device_send_queued_reports(device); + } + break; diff --git a/patches/btstack-credit-batch.patch b/patches/btstack-credit-batch.patch new file mode 100644 index 0000000..8a53b9f --- /dev/null +++ b/patches/btstack-credit-batch.patch @@ -0,0 +1,296 @@ +--- a/lib/btstack/src/hci.c ++++ b/lib/btstack/src/hci.c +@@ -93,6 +93,12 @@ + #endif + #endif + ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++#if !defined(ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL) || (HCI_HOST_ACL_PACKET_NUM != 3) ++#error "SWITCH_PICO_HCI_CREDIT_BATCH requires controller-to-host flow control with three ACL credits" ++#endif ++#endif ++ + #ifndef MAX_NR_CONTROLLER_ACL_BUFFERS + #define MAX_NR_CONTROLLER_ACL_BUFFERS 255 + #endif +@@ -273,6 +279,74 @@ + #endif + static hci_stack_t * hci_stack = NULL; + ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++static btstack_timer_source_t hci_credit_batch_timer; ++static bool hci_credit_batch_armed; ++static bool hci_credit_batch_due; ++static bool hci_credit_batch_enabled; ++static uint32_t hci_credit_batch_generation; ++ ++static void hci_credit_batch_reset(void){ ++ if (hci_credit_batch_armed){ ++ btstack_run_loop_remove_timer(&hci_credit_batch_timer); ++ hci_credit_batch_armed = false; ++ } ++ hci_credit_batch_due = false; ++} ++ ++static void hci_credit_batch_stop(void){ ++ hci_credit_batch_enabled = false; ++ hci_credit_batch_generation++; ++ hci_credit_batch_reset(); ++} ++ ++static void hci_credit_batch_timeout(btstack_timer_source_t * timer); ++ ++static void hci_credit_batch_arm(void){ ++ if (!hci_credit_batch_enabled || hci_credit_batch_armed || (hci_stack == NULL)) return; ++ if (hci_stack->state != HCI_STATE_WORKING) return; ++ btstack_run_loop_set_timer_handler(&hci_credit_batch_timer, hci_credit_batch_timeout); ++ // Pico adds one millisecond to its truncated clock: 1 ms requests expire within 2 ms. ++ btstack_run_loop_set_timer(&hci_credit_batch_timer, 1); ++ hci_credit_batch_armed = true; ++ btstack_run_loop_add_timer(&hci_credit_batch_timer); ++} ++ ++static bool hci_credit_batch_ready(void){ ++ uint32_t pending = 0; ++ bool sco_pending = false; ++ btstack_linked_item_t * it; ++ for (it = (btstack_linked_item_t *) hci_stack->connections; it; it = it->next){ ++ hci_connection_t * connection = (hci_connection_t *) it; ++ pending += connection->num_packets_completed; ++ if ((connection->address_type == BD_ADDR_TYPE_SCO) && (connection->num_packets_completed != 0)){ ++ sco_pending = true; ++ } ++ } ++ if (pending == 0){ ++ hci_stack->host_completed_packets = 0; ++ hci_credit_batch_reset(); ++ return false; ++ } ++ // Three advertised ACL credits leave one in reserve at the two-completion low water mark. ++ return hci_credit_batch_due || sco_pending || (pending >= 2); ++} ++ ++static void hci_credit_batch_timeout(btstack_timer_source_t * timer){ ++ UNUSED(timer); ++ if (!hci_credit_batch_armed) return; ++ hci_credit_batch_armed = false; ++ if (!hci_credit_batch_enabled || (hci_stack == NULL)) return; ++ if (hci_stack->state != HCI_STATE_WORKING) return; ++ hci_credit_batch_due = true; ++ if (!hci_credit_batch_ready()) return; ++ // Retry at a future tick if the transport/buffer is busy, never spin. Arm before hci_run: ++ // a synchronous send may close/reinitialize HCI, so do not touch its state afterwards. ++ hci_credit_batch_arm(); ++ hci_run(); ++} ++#endif ++ + #ifdef ENABLE_CLASSIC + // default name + static const char * default_classic_name = "BTstack 00:00:00:00:00:00"; +@@ -1195,6 +1269,9 @@ + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + hci_stack->host_completed_packets = 1; + conn->num_packets_completed++; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_arm(); ++#endif + #endif + + // handle different packet types +@@ -1269,8 +1346,10 @@ + return; + } + ++#ifndef SWITCH_PICO_HCI_CREDIT_BATCH + // execute main loop + hci_run(); ++#endif + } + + static void hci_connection_stop_timer(hci_connection_t * conn){ +@@ -1296,6 +1375,9 @@ + + btstack_linked_list_remove(&hci_stack->connections, (btstack_linked_item_t *) conn); + btstack_memory_hci_connection_free( conn ); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ (void) hci_credit_batch_ready(); ++#endif + + // now it's gone + hci_emit_nr_connections_changed(); +@@ -4314,6 +4396,12 @@ + + conn = hci_connection_for_handle(handle); + if (!conn) break; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // The controller reclaims disconnected credits. Clear them before pairing or ++ // disconnection callbacks can re-enter hci_run, including handle reuse. ++ conn->num_packets_completed = 0; ++ (void) hci_credit_batch_ready(); ++#endif + #ifdef ENABLE_CLASSIC + // pairing failed if it was ongoing + hci_pairing_complete(conn, ERROR_CODE_REMOTE_USER_TERMINATED_CONNECTION); +@@ -4780,6 +4868,10 @@ + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + conn->num_packets_completed++; + hci_stack->host_completed_packets = 1; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // SCO is never deferred, but needs the same transport-busy retry guarantee. ++ hci_credit_batch_arm(); ++#endif + hci_run(); + #endif + } +@@ -4814,6 +4906,10 @@ + break; + case HCI_ACL_DATA_PACKET: + acl_handler(packet, size); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Also service credits for malformed fragments that return early after accounting. ++ if (hci_stack != NULL) hci_run(); ++#endif + break; + #ifdef ENABLE_CLASSIC + case HCI_SCO_DATA_PACKET: +@@ -4869,6 +4965,11 @@ + #endif + + static void hci_state_reset(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_reset(); ++ hci_credit_batch_generation++; ++ hci_stack->host_completed_packets = 0; ++#endif + // no connections yet + hci_stack->connections = NULL; + +@@ -4953,6 +5054,10 @@ + #endif + + void hci_init(const hci_transport_t *transport, const void *config){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + + #ifdef HAVE_MALLOC + if (!hci_stack) { +@@ -5082,6 +5187,9 @@ + } + + void hci_deinit(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + btstack_run_loop_remove_timer(&hci_stack->timeout); + #ifdef HAVE_MALLOC + if (hci_stack) { +@@ -5138,6 +5246,9 @@ + } + + void hci_close(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + #ifdef ENABLE_CLASSIC + // close remote device db +@@ -5264,6 +5375,10 @@ + // HCI_STATE_FALLING_ASLEEP on open + + static int hci_power_control_on(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + + // power on + int err = 0; +@@ -5300,6 +5415,9 @@ + } + + static void hci_power_control_off(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + log_info("hci_power_control_off"); + +@@ -5319,6 +5437,9 @@ + } + + static void hci_power_control_sleep(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + log_info("hci_power_control_sleep"); + +@@ -5363,6 +5484,10 @@ + } + + static void hci_power_enter_initializing_state(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + // set up state machine + hci_stack->num_cmd_packets = 1; // assume that one cmd can be sent + hci_stack->hci_packet_buffer_reserved = false; +@@ -5543,6 +5668,10 @@ + } + + int hci_power_control(HCI_POWER_MODE power_mode){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Repeated POWER_ON while working must not restart a pending credit's deadline. ++ if (power_mode != HCI_POWER_ON) hci_credit_batch_stop(); ++#endif + log_info("hci_power_control: %d, current mode %u", power_mode, hci_stack->state); + btstack_run_loop_remove_timer(&hci_stack->timeout); + int err = 0; +@@ -5834,6 +5963,11 @@ + + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + static void hci_host_num_completed_packets(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Clear batching state before any transport/application callback can add new credits. ++ hci_credit_batch_reset(); ++ uint32_t generation = hci_credit_batch_generation; ++#endif + + // create packet manually as arrays are not supported and num_commands should not get reduced + hci_reserve_packet_buffer(); +@@ -5868,6 +6002,9 @@ + + hci_dump_packet(HCI_COMMAND_DATA_PACKET, 0, packet, size); + hci_stack->hci_transport->send_packet(HCI_COMMAND_DATA_PACKET, packet, size); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ if ((hci_stack == NULL) || (generation != hci_credit_batch_generation)) return; ++#endif + + // release packet buffer for synchronous transport implementations + if (hci_transport_synchronous()){ +@@ -7664,12 +7801,21 @@ + #endif + + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // A single deferred ACL credit must not block ordinary HCI commands. ++ if (hci_stack->host_completed_packets && hci_credit_batch_ready()){ ++ if (!hci_can_send_command_packet_transport()) return; ++ hci_host_num_completed_packets(); ++ return; ++ } ++#else + // send host num completed packets next as they don't require num_cmd_packets > 0 + if (!hci_can_send_command_packet_transport()) return; + if (hci_stack->host_completed_packets){ + hci_host_num_completed_packets(); + return; + } ++#endif + #endif + + if (!hci_can_send_command_packet_now()) return; diff --git a/patches/cyw43-packet-read-status.patch b/patches/cyw43-packet-read-status.patch new file mode 100644 index 0000000..2bde2fb --- /dev/null +++ b/patches/cyw43-packet-read-status.patch @@ -0,0 +1,27 @@ +--- a/cybt_shared_bus_driver.c ++++ b/cybt_shared_bus_driver.c +@@ -501,8 +501,11 @@ + cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *p_buf_index) { + uint32_t buf[4]; + +- cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf)); ++ cybt_result_t result = cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf)); ++ if (result != CYBT_SUCCESS) { ++ return result; ++ } + + p_buf_index->host2bt_in_val = buf[0]; + p_buf_index->host2bt_out_val = buf[1]; + p_buf_index->bt2host_in_val = buf[2]; +@@ -596,7 +599,10 @@ + if ((mem_addr & 0xFFF) + transfer_size > 0x1000) { + transfer_size = 0x1000 - (mem_addr & 0xFFF); + } +- cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data); ++ int result = cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data); ++ if (result != 0) { ++ return CYBT_ERR_HCI_READ_FAILED; ++ } + cybt_debug(" read_mem addr 0x%08lx len %ld\n", mem_addr, transfer_size); + DUMP_BYTES(p_data, transfer_size); + data_len -= transfer_size; diff --git a/pyproject.toml b/pyproject.toml index a504030..17deced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,4 +31,4 @@ where = ["src"] include = ["switch_pico_bridge*"] [tool.setuptools.package-data] -switch_pico_bridge = ["controller_db/gamecontrollerdb.txt"] +switch_pico_bridge = ["controller_db/gamecontrollerdb.txt", "web/*", "web/assets/*"] diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 44a42d2..5811bc8 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1,5 +1,9 @@ #include "input/bluepad32_input_backend.h" #include "input/controller_hotkey_config.h" +#include "input/switch2_wake.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_experiment.h" +#endif #include "configuration/configuration_service.h" #include "profile/profile_service.h" #include @@ -52,13 +56,11 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = { {0x00, 0xcc, 0x66}, {0xff, 0xaa, 0x00}, {0xcc, 0x33, 0xff}, + {0xff, 0x44, 0x44}, + {0x00, 0xdd, 0xdd}, + {0xff, 0x66, 0xbb}, + {0xcc, 0xff, 0x33}, }; -constexpr uint32_t kMotionHotkeyDpadMask = - SWITCH_MOTION_HOTKEY_DPAD_MASK; -constexpr uint32_t kMotionHotkeyButtonMask = - SWITCH_MOTION_HOTKEY_BUTTON_MASK; -constexpr uint32_t kMotionHotkeyMiscMask = - SWITCH_MOTION_HOTKEY_MISC_MASK; constexpr bool kDefaultMotionEnabled = SWITCH_MOTION_DEFAULT_ENABLED != 0; constexpr uint16_t kMotionDisabledFeedbackDurationMs = @@ -75,10 +77,7 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; static_assert(kProfileFeedbackPhaseDurationMs == 75); -static_assert(CONTROLLER_PROFILE_COUNT == 4); -static_assert(kMotionHotkeyDpadMask != 0); -static_assert(kMotionHotkeyButtonMask != 0); -static_assert(kMotionHotkeyMiscMask != 0); +static_assert(CONTROLLER_PROFILE_COUNT == 8); static_assert(kMotionDisabledFeedbackDurationMs > 0); static_assert(kMotionEnabledFeedbackDurationMs > 0); @@ -105,6 +104,9 @@ struct RumbleEnvelope { uint32_t connection_generation; ControllerRumbleOutput rumble; uint16_t duration_ms; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + uint64_t received_us = 0; +#endif }; struct FeedbackEnvelope { uint32_t connection_generation; @@ -153,7 +155,6 @@ struct BackendSlot { bool active; bool rumble_pending; bool motion_enabled; - bool motion_hotkey_latched; bool feedback_pending; uint32_t feedback_until_ms; uint8_t pending_profile_feedback_count; @@ -168,6 +169,10 @@ struct BackendSlot { critical_section_t g_state_lock; BackendSlot g_slots[kSlotCount]; +ControllerMacroCapture g_macro_capture; +// Catalog migration/compaction needs more than the 4 KiB scratch bank. +// Supply a dedicated static stack in main SRAM rather than overflowing it. +alignas(8) uint32_t g_core1_stack[4096]; BleIdentityMapping g_ble_identity_mappings[kSlotCount]{}; // These acknowledgement generations and request producers are only used by @@ -205,6 +210,9 @@ uint32_t g_controller_reports = 0; uint32_t g_host_rumble_requests = 0; uint32_t g_local_feedback_requests = 0; uint32_t g_rumble_dispatches = 0; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +uint32_t g_seeded_native_run_id = 0; +#endif uint16_t host_rumble_duration_ms() { #ifdef SWITCH_PICO_USB_OUTPUT_MODES @@ -506,6 +514,8 @@ ConnectionStatus compute_connection_status() { : ConnectionStatus::Scanning; } + + void publish_device_state(uint8_t slot, uni_hid_device_t* device, uint16_t pre_hotkey_button_mask, const ControllerState& state) { @@ -515,6 +525,8 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device, target.state = state; target.pre_hotkey_button_mask = pre_hotkey_button_mask; ++target.state_generation; + g_macro_capture.observe(slot, target.connection_generation, + time_us_32(), state); } critical_section_exit(&g_state_lock); } @@ -638,6 +650,21 @@ constexpr uint16_t logical_button_bit( return static_cast( 1u << static_cast(button)); } +bool wake_chord_rising_edge(uint8_t slot, uni_hid_device_t* device, + uint16_t button_mask) { + const uint16_t chord = + logical_button_bit(ControllerProfileLogicalButton::kLeftShoulder) | + logical_button_bit(ControllerProfileLogicalButton::kRightShoulder) | + logical_button_bit(ControllerProfileLogicalButton::kSystem); + critical_section_enter_blocking(&g_state_lock); + const BackendSlot& previous = g_slots[slot]; + const bool rising = + previous.active && previous.device == device && + (button_mask & chord) == chord && + (previous.pre_hotkey_button_mask & chord) != chord; + critical_section_exit(&g_state_lock); + return rising; +} constexpr uint16_t logical_button_mask( uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) { @@ -708,10 +735,6 @@ constexpr uint16_t logical_button_mask( : 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( @@ -810,10 +833,6 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, } struct HotkeyDecision { 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, @@ -826,8 +845,9 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, __atomic_add_fetch(&g_local_feedback_requests, 1, __ATOMIC_RELAXED); } void reset_slot_hotkeys(BackendSlot& slot) { + g_macro_capture.disconnect(static_cast(&slot - g_slots), + slot.connection_generation, time_us_32()); slot.motion_enabled = kDefaultMotionEnabled; - slot.motion_hotkey_latched = false; slot.pre_hotkey_button_mask = 0; slot.feedback_pending = false; slot.feedback_until_ms = 0; @@ -844,44 +864,12 @@ void reset_slot_hotkeys(BackendSlot& slot) { HotkeyDecision update_controller_hotkeys( - uint8_t slot_index, uni_hid_device_t* device, - const uni_gamepad_t& gamepad) { - const bool motion_pressed = - (gamepad.dpad & kMotionHotkeyDpadMask) == - kMotionHotkeyDpadMask && - (gamepad.buttons & kMotionHotkeyButtonMask) == - kMotionHotkeyButtonMask && - (gamepad.misc_buttons & kMotionHotkeyMiscMask) == - kMotionHotkeyMiscMask; - HotkeyDecision decision{ - kDefaultMotionEnabled, 0, 0, 0, 0}; - + uint8_t slot_index, uni_hid_device_t* device) { + HotkeyDecision decision{kDefaultMotionEnabled}; critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; + const BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device == device) { - if (motion_pressed && !slot.motion_hotkey_latched) { - slot.motion_enabled = !slot.motion_enabled; - if (slot.motion_enabled) { - queue_local_feedback( - slot, kMotionEnabledFeedbackDurationMs, - kMotionEnabledFeedbackWeakMagnitude, - kMotionEnabledFeedbackStrongMagnitude); - } else { - queue_local_feedback( - slot, kMotionDisabledFeedbackDurationMs, - kMotionDisabledFeedbackWeakMagnitude, - kMotionDisabledFeedbackStrongMagnitude); - } - } - slot.motion_hotkey_latched = motion_pressed; decision.motion_enabled = slot.motion_enabled; - 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); return decision; @@ -1105,6 +1093,7 @@ void process_pairing_snapshot_request() { } void apply_connection_policy(); +void recompute_connection_status(); void process_clear_pairings(uint32_t now_ms) { uni_hid_device_t* devices[kSlotCount]{}; @@ -1281,17 +1270,79 @@ void process_configuration_timer(btstack_timer_source_t* timer) { profile_service_task_on_storage_core(now_ms); } +void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, + uint8_t weak, uint8_t strong) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_feedback(device, strong, weak, duration_ms)) { + return; + } +#endif + device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong); +} + +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +void seed_native_host_rumble() { + HapticsExperimentDiagnostics native; + haptics_experiment_snapshot(&native); + if (native.mode != 1 || native.slot >= kSlotCount || + native.run_id == g_seeded_native_run_id || + (native.state != HapticsExperimentState::kPending && + native.state != HapticsExperimentState::kRunning)) { + return; + } + g_seeded_native_run_id = native.run_id; + RumbleEnvelope retained{}; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[native.slot]; + const bool valid = slot.active && slot.device != nullptr && + slot.retained_host_rumble_valid && + slot.connection_generation == native.connection_generation && + slot.retained_host_rumble.connection_generation == + native.connection_generation && + slot.retained_host_rumble.slot == native.slot && + slot.retained_host_rumble.duration_ms == + kXInputHostRumbleDurationMs; + if (valid) { + retained = slot.retained_host_rumble; + // Arming cancels compatibility output even when its mailbox was + // already consumed. Keep that held state available for the next Stop. + slot.pending_rumble = retained; + slot.rumble_pending = true; + } + critical_section_exit(&g_state_lock); + if (valid) { + // Replay once on arm, not on a watchdog cadence. The original timestamp + // keeps a raced newer USB command authoritative in the host timeline. + haptics_experiment_submit_rumble( + native.slot, native.connection_generation, retained.received_us, + retained.rumble.low_frequency_magnitude, + retained.rumble.high_frequency_magnitude); + } +} +#endif + void process_rumble_timer(btstack_timer_source_t* timer) { __atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED); const uint32_t now_ms = btstack_run_loop_get_time_ms(); process_clear_pairings(now_ms); process_pairing_snapshot_request(); - if (update_pairing_window(now_ms)) { + const bool wake_identity_ready = + switch2_wake_ready_for_connections(); + if (g_connection_policy_state == + ConnectionPolicyState::Uninitialized && + wake_identity_ready) { + recompute_connection_status(); + } + if (update_pairing_window(now_ms) && wake_identity_ready) { apply_connection_policy(); } const bool xinput_host_mode = host_rumble_duration_ms() == kXInputHostRumbleDurationMs; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (xinput_host_mode) seed_native_host_rumble(); + haptics_experiment_poll(); +#endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { RumbleEnvelope envelope{}; @@ -1319,6 +1370,16 @@ void process_rumble_timer(btstack_timer_source_t* timer) { slot.retained_host_rumble_valid = false; slot.retained_host_rumble = {}; } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_owns(slot.device) && + !haptics_experiment_gameplay_owns(slot.device)) { + // Fixture/startup/restoration exclusively own output. Preserve + // stateful XInput requests until compatibility restoration ends. + if (!xinput_host_mode) slot.rumble_pending = false; + critical_section_exit(&g_state_lock); + continue; + } +#endif if (slot.profile_feedback.active && slot.profile_feedback.connection_generation != slot.connection_generation) { @@ -1425,11 +1486,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) { static_cast( now_ms - slot.feedback_until_ms) < 0; if (!profile_rumble_dispatch && !feedback_dispatch && - !local_feedback_active && slot.rumble_pending) { + !local_feedback_active && slot.rumble_pending +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + && !(xinput_host_mode && + haptics_experiment_gameplay_owns(slot.device)) +#endif + ) { envelope = slot.pending_rumble; slot.rumble_pending = false; host_dispatch = envelope.slot == slot_index && slot.active && + envelope.duration_ms == host_rumble_duration_ms() && slot.device != nullptr && envelope.connection_generation == slot.connection_generation; @@ -1438,6 +1505,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } } critical_section_exit(&g_state_lock); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (host_dispatch && haptics_experiment_gameplay_owns(device)) { + // Switch commands have already entered the timestamped timeline. + // Consume their finite fallback, never turn it into a PCM overlay + // or emit compatibility reports while gameplay owns the device. + host_dispatch = false; + } +#endif if (profile_lighting_restore && lighting_target_is_current( @@ -1458,15 +1533,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) { device->report_parser.play_dual_rumble != nullptr) { __atomic_add_fetch( &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - device->report_parser.play_dual_rumble( - device, 0, kProfileFeedbackPhaseDurationMs, - kProfileFeedbackWeakMagnitude, - kProfileFeedbackStrongMagnitude); + dispatch_rumble( + device, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); } else if (feedback_dispatch) { __atomic_add_fetch( &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - device->report_parser.play_dual_rumble( - device, 0, feedback.duration_ms, + dispatch_rumble( + device, feedback.duration_ms, feedback.weak_magnitude, feedback.strong_magnitude); } else if (host_dispatch && device->report_parser.play_dual_rumble != nullptr) { @@ -1475,8 +1549,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { const bool stop = envelope.rumble.low_frequency_magnitude == 0 && envelope.rumble.high_frequency_magnitude == 0; - device->report_parser.play_dual_rumble( - device, 0, stop ? 0 : envelope.duration_ms, + dispatch_rumble( + device, stop ? 0 : envelope.duration_ms, envelope.rumble.high_frequency_magnitude, envelope.rumble.low_frequency_magnitude); } @@ -1509,6 +1583,7 @@ void platform_on_init_complete() { g_identity_event_callback.callback = handle_btstack_event; sm_add_event_handler(&g_identity_event_callback); hci_add_event_handler(&g_pairing_event_callback); + switch2_wake_initialize(); refresh_pairing_snapshot(); // Keep Bluepad32 autoconnect active whenever at least one slot is free. btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); @@ -1520,7 +1595,9 @@ void platform_on_init_complete() { &g_configuration_timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(&g_configuration_timer); __atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE); - recompute_connection_status(); + if (switch2_wake_ready_for_connections()) { + recompute_connection_status(); + } } uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, @@ -1574,6 +1651,9 @@ void platform_on_device_connected(uni_hid_device_t* device) { } void platform_on_device_disconnected(uni_hid_device_t* device) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_detach(device); +#endif const int slot_index = slot_for_device(device); if (slot_index < 0) { return; @@ -1642,6 +1722,17 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { return UNI_ERROR_NO_SLOTS; } if (became_active) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_attach( + static_cast(slot_index), lighting_generation, device); +#ifdef SWITCH_PICO_HD_RUMBLE + if (connection_identity.vendor_id == 0x054c && + (connection_identity.product_id == 0x0ce6 || + connection_identity.product_id == 0x0df2)) { + haptics_experiment_request(2, static_cast(slot_index)); + } +#endif +#endif if (lighting_target_is_current( static_cast(slot_index), lighting_generation, device)) { @@ -1671,13 +1762,14 @@ 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); + if (wake_chord_rising_edge( + static_cast(slot_index), device, + pre_hotkey_button_mask)) { + switch2_wake_request(); + } 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); + static_cast(slot_index), device); + const uint16_t output_button_mask = pre_hotkey_button_mask; publish_device_state( static_cast(slot_index), device, pre_hotkey_button_mask, @@ -1750,6 +1842,64 @@ uni_platform* get_platform() { } // namespace +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +extern "C" bool uni_platform_on_l2cap_can_send_now( + uni_hid_device_t* device, uint16_t cid) { + return haptics_experiment_on_can_send_now(device, cid); +} +#endif + +bool bluepad32_input_backend_capture_start( + uint8_t slot, uint32_t connection_generation, const CaptureOptions& options) { + if (!g_initialized || slot >= kSlotCount) return false; + critical_section_enter_blocking(&g_state_lock); + const BackendSlot& current = g_slots[slot]; + const bool accepted = current.active && + current.connection_generation == connection_generation && + g_macro_capture.start(slot, connection_generation, options, + time_us_32(), current.state); + critical_section_exit(&g_state_lock); + return accepted; +} + +bool bluepad32_input_backend_capture_stop(uint32_t run_id) { + if (!g_initialized || run_id == 0) return false; + critical_section_enter_blocking(&g_state_lock); + const bool matches = run_id == g_macro_capture.run_id(); + if (matches) g_macro_capture.stop(time_us_32()); + critical_section_exit(&g_state_lock); + return matches; +} + +bool bluepad32_input_backend_capture_page( + uint32_t run_id, uint16_t first_index, Bluepad32CaptureSnapshot* output) { + if (!g_initialized || output == nullptr) return false; + critical_section_enter_blocking(&g_state_lock); + g_macro_capture.tick(time_us_32()); + if ((run_id != 0 && run_id != g_macro_capture.run_id()) || + first_index > g_macro_capture.event_count()) { + critical_section_exit(&g_state_lock); + return false; + } + *output = {}; + output->run_id = g_macro_capture.run_id(); + output->connection_generation = g_macro_capture.generation(); + output->elapsed_us = g_macro_capture.elapsed_us(time_us_32()); + output->slot = g_macro_capture.slot(); + output->state = g_macro_capture.state(); + output->options = g_macro_capture.options(); + output->total_events = g_macro_capture.event_count(); + output->first_index = first_index; + const uint16_t remaining = output->total_events - first_index; + output->event_count = remaining < BLUEPAD32_CAPTURE_PAGE_EVENTS + ? remaining : BLUEPAD32_CAPTURE_PAGE_EVENTS; + for (uint8_t index = 0; index < output->event_count; ++index) { + g_macro_capture.event(first_index + index, &output->events[index]); + } + critical_section_exit(&g_state_lock); + return true; +} + void bluepad32_input_backend_init() { if (g_initialized) { return; @@ -1758,6 +1908,9 @@ void bluepad32_input_backend_init() { critical_section_init(&g_state_lock); configuration_service_prepare(); profile_service_prepare(); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_prepare(); +#endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; slot = {}; @@ -1810,7 +1963,8 @@ void bluepad32_input_backend_start() { g_started = true; - multicore_launch_core1(core1_main); + multicore_launch_core1_with_stack( + core1_main, g_core1_stack, sizeof(g_core1_stack)); } void bluepad32_input_backend_open_pairing_window() { @@ -1939,6 +2093,63 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index, g_last_snapshot_generation[slot_index] = state_generation; } +void bluepad32_input_backend_playtest_snapshot( + uint8_t slot_index, Bluepad32PlaytestSnapshot* out) { + if (out == nullptr) { + return; + } + *out = {}; + if (!valid_slot(slot_index) || !g_initialized) { + return; + } + + critical_section_enter_blocking(&g_state_lock); + const BackendSlot& slot = g_slots[slot_index]; + out->active = slot.active; + out->connection_generation = slot.connection_generation; + out->state_generation = slot.state_generation; + out->identity = slot.identity; + out->physical_button_mask = slot.pre_hotkey_button_mask; + out->state = slot.state; + if (slot.device != nullptr) { + out->battery = slot.device->controller.battery; + out->capabilities = + (slot.device->report_parser.play_dual_rumble != nullptr ? 1u : 0u) | + (slot.device->report_parser.set_lightbar_color != nullptr ? 2u : 0u) | + (slot.device->report_parser.set_player_leds != nullptr ? 4u : 0u) | + (slot.state.motion_sample_count != 0 ? 8u : 0u); + } + critical_section_exit(&g_state_lock); +} + +bool bluepad32_input_backend_toggle_motion( + uint8_t slot_index, uint32_t connection_generation) { + if (!g_initialized || !valid_slot(slot_index)) { + return false; + } + bool toggled = false; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && + slot.connection_generation == connection_generation) { + slot.motion_enabled = !slot.motion_enabled; + if (slot.motion_enabled) { + queue_local_feedback( + slot, kMotionEnabledFeedbackDurationMs, + kMotionEnabledFeedbackWeakMagnitude, + kMotionEnabledFeedbackStrongMagnitude); + } else { + queue_local_feedback( + slot, kMotionDisabledFeedbackDurationMs, + kMotionDisabledFeedbackWeakMagnitude, + kMotionDisabledFeedbackStrongMagnitude); + } + toggled = true; + } + critical_section_exit(&g_state_lock); + return toggled; +} + void bluepad32_input_backend_report_sent(uint8_t slot_index) { if (!g_initialized || !valid_slot(slot_index)) { return; @@ -1952,13 +2163,26 @@ void bluepad32_input_backend_queue_rumble( return; } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const uint64_t received_us = time_us_64(); + uint32_t native_generation = 0; + bool native_candidate = false; +#endif 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) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + native_generation = slot.connection_generation; + native_candidate = true; +#endif const RumbleEnvelope envelope{ slot_index, slot.connection_generation, rumble, - duration_ms}; + duration_ms +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + , received_us +#endif + }; slot.pending_rumble = envelope; slot.rumble_pending = true; __atomic_add_fetch( @@ -1972,6 +2196,49 @@ void bluepad32_input_backend_queue_rumble( } } critical_section_exit(&g_state_lock); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (native_candidate) { + if (duration_ms == kXInputHostRumbleDurationMs) { + haptics_experiment_submit_rumble( + slot_index, native_generation, received_us, + rumble.low_frequency_magnitude, rumble.high_frequency_magnitude); + } else { + haptics_experiment_submit( + slot_index, native_generation, received_us, rumble.hd); + } + } +#endif +} + +bool bluepad32_input_backend_identify( + const ControllerIdentity& identity) { + if (!g_initialized || !identity.stable || + controller_identity_is_global(identity)) { + return false; + } + bool queued = false; + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + if (!slot.active || + !controller_identity_equal(slot.identity, identity)) { + continue; + } + const ProfileFeedbackEnvelope feedback{ + slot.connection_generation, 1, + ControllerProfileConfirmationPolicy::kRumbleAndLed}; + 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; + } + queued = true; + break; + } + critical_section_exit(&g_state_lock); + return queued; } void bluepad32_input_backend_queue_profile_feedback( diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 64f4b83..dc37634 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -6,6 +6,7 @@ #include "core/controller_identity.h" #include "profile/controller_profile.h" #include "core/controller_state.h" +#include "input/controller_macro_capture.h" #include "usb/switch/switch_haptics.h" constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; @@ -62,6 +63,19 @@ struct Bluepad32SlotSnapshot { ControllerState state; }; +// Side-effect-free raw input snapshot for management telemetry. Unlike the +// report-path snapshot, reading this does not consume motion samples. +struct Bluepad32PlaytestSnapshot { + bool active = false; + uint32_t connection_generation = 0; + uint32_t state_generation = 0; + ControllerIdentity identity{}; + uint16_t physical_button_mask = 0; + uint8_t battery = 0; + uint8_t capabilities = 0; + ControllerState state{}; +}; + struct Bluepad32BackendDiagnostics { uint32_t initialization_stage; uint32_t rumble_timer_ticks; @@ -86,19 +100,49 @@ void bluepad32_input_backend_open_pairing_window(); uint32_t bluepad32_input_backend_clear_pairings(); void bluepad32_input_backend_snapshot(uint8_t slot, Bluepad32SlotSnapshot* out); +void bluepad32_input_backend_playtest_snapshot( + uint8_t slot, Bluepad32PlaytestSnapshot* out); void bluepad32_input_backend_request_pairing_snapshot(); void bluepad32_input_backend_pairing_snapshot( Bluepad32PairingSnapshot* out); void bluepad32_input_backend_diagnostics( Bluepad32BackendDiagnostics* out); void bluepad32_input_backend_report_sent(uint8_t slot); +// Toggle motion and queue state feedback only for the matching live +// connection generation. +bool bluepad32_input_backend_toggle_motion( + uint8_t slot, uint32_t connection_generation); void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); +bool bluepad32_input_backend_identify( + const ControllerIdentity& identity); // 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). +// indication after the final gap. Profile numbers are one-based (1..8). void bluepad32_input_backend_queue_profile_feedback( uint8_t slot, uint32_t connection_generation, uint8_t active_profile_number, ControllerProfileConfirmationPolicy policy); + +constexpr uint8_t BLUEPAD32_CAPTURE_PAGE_EVENTS = 32; +struct Bluepad32CaptureSnapshot { + uint32_t run_id = 0; + uint32_t connection_generation = 0; + uint32_t elapsed_us = 0; + uint8_t slot = 0xff; + CaptureState state = CaptureState::kIdle; + CaptureOptions options{}; + uint16_t total_events = 0; + uint16_t first_index = 0; + uint8_t event_count = 0; + CaptureEvent events[BLUEPAD32_CAPTURE_PAGE_EVENTS]{}; +}; + +// Core 0 management operations; recording itself observes Core 1 input before +// profile transforms. All recorder access uses the existing slot-state lock. +bool bluepad32_input_backend_capture_start( + uint8_t slot, uint32_t connection_generation, const CaptureOptions& options); +bool bluepad32_input_backend_capture_stop(uint32_t run_id); +bool bluepad32_input_backend_capture_page( + uint32_t run_id, uint16_t first_index, Bluepad32CaptureSnapshot* output); diff --git a/src/firmware/input/controller_hotkey_config.h b/src/firmware/input/controller_hotkey_config.h index 818515f..7568a94 100644 --- a/src/firmware/input/controller_hotkey_config.h +++ b/src/firmware/input/controller_hotkey_config.h @@ -1,11 +1,7 @@ -// Compile-time AIO controller hotkey configuration. +// Compile-time AIO controller action feedback configuration. #pragma once -// 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 // A longer pulse confirms disabled; a shorter pulse confirms enabled. diff --git a/src/firmware/input/controller_macro_capture.cpp b/src/firmware/input/controller_macro_capture.cpp new file mode 100644 index 0000000..6b36991 --- /dev/null +++ b/src/firmware/input/controller_macro_capture.cpp @@ -0,0 +1,232 @@ +#include "input/controller_macro_capture.h" + +namespace { + +constexpr uint8_t kButtons = 1u << 0; +constexpr uint8_t kLeftStick = 1u << 1; +constexpr uint8_t kRightStick = 1u << 2; +constexpr uint8_t kLeftTrigger = 1u << 3; +constexpr uint8_t kRightTrigger = 1u << 4; +constexpr uint32_t kMaxHoldUs = 10000000; +constexpr uint32_t kMaxDurationMs = 80000; + +uint16_t button_mask(const ControllerState& state) { + return static_cast( + (state.button_south ? 1u << 0 : 0) | + (state.button_east ? 1u << 1 : 0) | + (state.button_west ? 1u << 2 : 0) | + (state.button_north ? 1u << 3 : 0) | + (state.button_left_shoulder ? 1u << 4 : 0) | + (state.button_right_shoulder ? 1u << 5 : 0) | + (state.button_select ? 1u << 6 : 0) | + (state.button_start ? 1u << 7 : 0) | + (state.button_system ? 1u << 8 : 0) | + (state.button_capture ? 1u << 9 : 0) | + (state.button_left_stick ? 1u << 10 : 0) | + (state.button_right_stick ? 1u << 11 : 0) | + (state.dpad_up ? 1u << 12 : 0) | + (state.dpad_down ? 1u << 13 : 0) | + (state.dpad_left ? 1u << 14 : 0) | + (state.dpad_right ? 1u << 15 : 0)); +} + +// Round magnitudes to nearest, with ties away from zero. Keep full scale +// exact and use the same magnitude on both sides of zero where representable. +int16_t quantize_axis(int16_t value, uint16_t quantum) { + if (value == INT16_MIN) { + return value; + } + const int32_t magnitude = value < 0 ? -static_cast(value) : value; + if (magnitude == INT16_MAX) { + return value; + } + int32_t rounded = ((magnitude + quantum / 2) / quantum) * quantum; + if (rounded > INT16_MAX) { + rounded = INT16_MAX; + } + return static_cast(value < 0 ? -rounded : rounded); +} + +uint16_t quantize_trigger(uint16_t value, uint16_t quantum) { + if (value == UINT16_MAX) { + return value; + } + const uint32_t rounded = + ((static_cast(value) + quantum / 2u) / quantum) * quantum; + return static_cast(rounded > UINT16_MAX ? UINT16_MAX : rounded); +} + +bool same_channels(const CaptureEvent& left, const CaptureEvent& right) { + return left.buttons == right.buttons && left.left_x == right.left_x && + left.left_y == right.left_y && left.right_x == right.right_x && + left.right_y == right.right_y && + left.left_trigger == right.left_trigger && + left.right_trigger == right.right_trigger; +} + +} // namespace + +bool ControllerMacroCapture::start(uint8_t slot, uint32_t generation, + const CaptureOptions& options, + uint32_t now_us, + const ControllerState& initial) { + if (state_ == CaptureState::kRecording || slot >= 4 || + options.channels == 0 || options.channels > 31 || + options.axis_quantum == 0 || options.axis_quantum > INT16_MAX || + options.trigger_quantum == 0 || options.max_events == 0 || + options.max_events > kCapacity || options.max_duration_ms == 0 || + options.max_duration_ms > kMaxDurationMs) { + return false; + } + options_ = options; + slot_ = slot; + generation_ = generation; + started_us_ = now_us; + ended_us_ = 0; + ++run_id_; + if (run_id_ == 0) { + ++run_id_; + } + events_[0] = snapshot(initial, 0); + event_count_ = 1; + state_ = CaptureState::kRecording; + return true; +} + +CaptureEvent ControllerMacroCapture::snapshot(const ControllerState& state, + uint32_t at_us) const { + CaptureEvent captured{}; + captured.at_us = at_us; + if ((options_.channels & kButtons) != 0) { + captured.buttons = button_mask(state); + } + if ((options_.channels & kLeftStick) != 0) { + captured.left_x = quantize_axis(state.left_stick_x, options_.axis_quantum); + captured.left_y = quantize_axis(state.left_stick_y, options_.axis_quantum); + } + if ((options_.channels & kRightStick) != 0) { + captured.right_x = quantize_axis(state.right_stick_x, options_.axis_quantum); + captured.right_y = quantize_axis(state.right_stick_y, options_.axis_quantum); + } + if ((options_.channels & kLeftTrigger) != 0) { + captured.left_trigger = + quantize_trigger(state.left_trigger, options_.trigger_quantum); + } + if ((options_.channels & kRightTrigger) != 0) { + captured.right_trigger = + quantize_trigger(state.right_trigger, options_.trigger_quantum); + } + return captured; +} + +void ControllerMacroCapture::finish(CaptureState state, uint32_t at_us) { + state_ = state; + ended_us_ = at_us; +} + +bool ControllerMacroCapture::append(const CaptureEvent& captured) { + if (captured.at_us == events_[event_count_ - 1].at_us) { + events_[event_count_ - 1] = captured; + return true; + } + if (event_count_ >= options_.max_events) { + finish(CaptureState::kFull, captured.at_us); + return false; + } + events_[event_count_++] = captured; + return true; +} + +void ControllerMacroCapture::advance(uint32_t now_us, bool ending) { + if (state_ != CaptureState::kRecording) { + return; + } + const uint32_t elapsed = now_us - started_us_; + const uint32_t duration = options_.max_duration_ms * 1000u; + const bool timed_out = elapsed >= duration; + const uint32_t target = timed_out ? duration : elapsed; + // An ending interval may be exactly 10 seconds; it needs no zero-length + // trailing event. A live boundary can be replaced by an edge at that time. + for (uint16_t step = 0; step < kCapacity; ++step) { + const CaptureEvent& previous = events_[event_count_ - 1]; + const uint32_t boundary = previous.at_us + kMaxHoldUs; + if (boundary > target || + (boundary == target && (ending || timed_out))) { + break; + } + CaptureEvent held = previous; + held.at_us = boundary; + if (!append(held)) { + return; + } + } + if (timed_out) { + finish(CaptureState::kTimedOut, duration); + } +} + +void ControllerMacroCapture::observe(uint8_t slot, uint32_t generation, + uint32_t now_us, + const ControllerState& state) { + if (state_ != CaptureState::kRecording || slot != slot_) { + return; + } + if (generation != generation_) { + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kDisconnected, now_us - started_us_); + } + return; + } + advance(now_us, false); + if (state_ != CaptureState::kRecording) { + return; + } + const CaptureEvent captured = snapshot(state, now_us - started_us_); + if (!same_channels(captured, events_[event_count_ - 1])) { + append(captured); + } +} + +void ControllerMacroCapture::stop(uint32_t now_us) { + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kStopped, now_us - started_us_); + } +} + +void ControllerMacroCapture::disconnect(uint8_t slot, uint32_t generation, + uint32_t now_us) { + if (state_ != CaptureState::kRecording || slot != slot_ || + generation != generation_) { + return; + } + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kDisconnected, now_us - started_us_); + } +} + +void ControllerMacroCapture::tick(uint32_t now_us) { + advance(now_us, false); +} + +uint32_t ControllerMacroCapture::elapsed_us(uint32_t now_us) const { + if (state_ == CaptureState::kIdle) { + return 0; + } + if (state_ != CaptureState::kRecording) { + return ended_us_; + } + const uint32_t elapsed = now_us - started_us_; + const uint32_t duration = options_.max_duration_ms * 1000u; + return elapsed < duration ? elapsed : duration; +} + +bool ControllerMacroCapture::event(uint16_t index, CaptureEvent* output) const { + if (output == nullptr || index >= event_count_) { + return false; + } + *output = events_[index]; + return true; +} diff --git a/src/firmware/input/controller_macro_capture.h b/src/firmware/input/controller_macro_capture.h new file mode 100644 index 0000000..af9dbb6 --- /dev/null +++ b/src/firmware/input/controller_macro_capture.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include "core/controller_state.h" + +enum class CaptureState : uint8_t { + kIdle = 0, + kRecording = 1, + kStopped = 2, + kFull = 3, + kTimedOut = 4, + kDisconnected = 5, +}; + +struct CaptureOptions { + uint8_t channels = 1; + uint16_t axis_quantum = 512; + uint16_t trigger_quantum = 1024; + uint8_t max_events = 8; + uint32_t max_duration_ms = 10000; +}; + +struct CaptureEvent { + uint32_t at_us; + uint16_t buttons; + int16_t left_x; + int16_t left_y; + int16_t right_x; + int16_t right_y; + uint16_t left_trigger; + uint16_t right_trigger; +}; + +// Caller serializes access. Timestamps are a monotonically advancing uint32 +// microsecond clock, including wrap; unselected event channels are zero. +class ControllerMacroCapture { +public: + static constexpr uint16_t kCapacity = 128; + + bool start(uint8_t slot, uint32_t generation, + const CaptureOptions& options, uint32_t now_us, + const ControllerState& initial); + void observe(uint8_t slot, uint32_t generation, uint32_t now_us, + const ControllerState& state); + void stop(uint32_t now_us); + void disconnect(uint8_t slot, uint32_t generation, uint32_t now_us); + void tick(uint32_t now_us); + + CaptureState state() const { return state_; } + const CaptureOptions& options() const { return options_; } + uint8_t slot() const { return slot_; } + uint32_t generation() const { return generation_; } + uint32_t run_id() const { return run_id_; } + uint16_t event_count() const { return event_count_; } + uint32_t elapsed_us(uint32_t now_us) const; + bool event(uint16_t index, CaptureEvent* output) const; + +private: + CaptureEvent snapshot(const ControllerState& state, uint32_t at_us) const; + bool append(const CaptureEvent& event); + void advance(uint32_t now_us, bool ending); + void finish(CaptureState state, uint32_t at_us); + + CaptureOptions options_{}; + CaptureState state_ = CaptureState::kIdle; + uint8_t slot_ = 0; + uint32_t generation_ = 0; + uint32_t run_id_ = 0; + uint32_t started_us_ = 0; + uint32_t ended_us_ = 0; + uint16_t event_count_ = 0; + CaptureEvent events_[kCapacity]{}; +}; diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp new file mode 100644 index 0000000..37b1456 --- /dev/null +++ b/src/firmware/input/haptics_experiment.cpp @@ -0,0 +1,965 @@ +#include "input/haptics_experiment.h" +#include "input/haptics_transport_probe.h" +#include "input/switch_hd_rumble_synth.h" + +#include +#include +#include +#include + +#if SWITCH_PICO_HAPTICS_EXPERIMENT_RAM +#define HAPTICS_HOT(name) __time_critical_func(name) +#define HAPTICS_DATA __not_in_flash("haptics_experiment_waveform") +#else +#define HAPTICS_HOT(name) name +#define HAPTICS_DATA +#endif + +namespace { + +#ifndef SWITCH_PICO_HD_PACKET_FRAMES +#define SWITCH_PICO_HD_PACKET_FRAMES 64 +#endif +constexpr uint32_t kGameplayFrames = SWITCH_PICO_HD_PACKET_FRAMES; +static_assert(kGameplayFrames == 32 || kGameplayFrames == 64); +constexpr uint32_t kPacketDenominator = 3; +constexpr uint32_t kPackets = 288; +constexpr uint32_t kPrimingPackets = 48; +constexpr uint32_t kToneEndPacket = 240; +constexpr uint32_t kPhasePackets = 12; +constexpr uint32_t kDrainTimeoutUs = 100000; +// Briefly retain ownership while compatibility output drains. Its parser timer +// is canceled synchronously; it must not survive a device disconnect/reuse. +constexpr uint32_t kRestoreSettleUs = 3000; +constexpr uint16_t kReportBytes = 143; // A2 + 142-byte report 0x32. +constexpr uint16_t kCrcOffset = kReportBytes - 4; +constexpr uint8_t kNoSlot = 0xff; + +enum Error : uint8_t { + kNoError = 0, + kUnsupported = 1, + kMtu = 2, + kConnection = 3, + kTimeout = 4, + kTransport = 5, + kQueuedOutput = 6, +}; + +enum class Phase { kIdle, kPrepare, kPattern, kDrain, kRestore }; + +struct Attachment { + uni_hid_device_t* device = nullptr; + uint32_t generation = 0; + uint16_t cid = 0; +}; + +struct Command { + bool pending = false; + uint8_t action = 0; + uint8_t slot = kNoSlot; + uint32_t run_id = 0; + Attachment connection{}; + uint8_t mode = 0; +}; + +struct HostUpdate { + uint64_t received_us = 0; + SwitchHapticsFrame frame{}; + bool rumble = false; + uint8_t low = 0; + uint8_t high = 0; +}; +constexpr uint8_t kHostQueueCapacity = 16; + +// Only the attachment identities, mailbox and published snapshot cross cores. +// No BTstack call (including synchronous reentry) holds this lock. +critical_section_t g_lock; +HapticsExperimentDiagnostics g_snapshot; +bool g_snapshot_waiting = false; +uint32_t g_snapshot_request_us = 0; +Command g_command; +bool g_busy = false; +bool g_prepared = false; +bool g_accept_host = false; +HostUpdate g_host_queue[kHostQueueCapacity]; +uint8_t g_host_head = 0; +uint8_t g_host_count = 0; +uint32_t g_host_updates = 0; +uint32_t g_host_drops = 0; + +// Written on core 1 under the lock; core 0 only reads identities for requests. +Attachment g_attachments[4]; + +// All remaining state belongs exclusively to the BTstack core. +HapticsExperimentDiagnostics g_diagnostics; +Attachment g_connection; +Phase g_phase = Phase::kIdle; +HapticsExperimentState g_finish_state = HapticsExperimentState::kCompleted; +btstack_timer_source_t g_cadence_timer{}; +btstack_timer_source_t g_lifecycle_timer{}; +bool g_cadence_armed = false; +bool g_lifecycle_armed = false; +bool g_send_requested = false; +bool g_request_in_progress = false; +bool g_in_callback = false; +bool g_last_was_silence = true; +bool g_first_tone_sent = false; +uint32_t g_next_packet = 0; +uint64_t g_start_us = 0; +uint64_t g_end_us = 0; +uint64_t g_lifecycle_due_us = 0; +uint64_t g_request_us = 0; +uint64_t g_restore_deadline_us = 0; +SwitchHdRumbleSynth g_synth; +bool g_synth_started = false; + +// round(32 * sin(2*pi*n/30)). A stride of one is 100 Hz at 3 kHz; +// a stride of two is 200 Hz. Preserve phase across all 12 packets of a tone. +static const int8_t HAPTICS_DATA kSine[30] = { + 0, 7, 13, 19, 24, 28, 30, 32, 32, 30, 28, 24, 19, 13, 7, + 0, -7, -13, -19, -24, -28, -30, -32, -32, -30, -28, -24, -19, -13, -7, +}; + +void cadence_timer(btstack_timer_source_t*); +void lifecycle_timer(btstack_timer_source_t*); +void request_send(); +void restore_compatibility(HapticsExperimentState state); +void start_stream(); + +bool gameplay() { + return g_diagnostics.mode == 1; +} + +uint32_t packet_numerator_us() { + return (gameplay() ? kGameplayFrames : 64u) * 1000u; +} + +void drain_host_updates() { + // Bound work even if USB keeps publishing while the BT core drains. + for (uint8_t index = 0; index < kHostQueueCapacity; ++index) { + HostUpdate update; + critical_section_enter_blocking(&g_lock); + if (g_host_count == 0) { + critical_section_exit(&g_lock); + break; + } + update = g_host_queue[g_host_head]; + g_host_head = (g_host_head + 1) % kHostQueueCapacity; + --g_host_count; + critical_section_exit(&g_lock); + if (update.rumble) { + g_synth.push_rumble(update.low, update.high, update.received_us); + } else { + g_synth.push(update.frame, update.received_us); + } + } +} + +bool submit_host_update(uint8_t slot, uint32_t generation, + const HostUpdate& update) { + if (!g_prepared || slot >= 4) return false; + critical_section_enter_blocking(&g_lock); + const bool accepted = g_accept_host && g_busy && g_snapshot.mode == 1 && + g_snapshot.slot == slot && + g_snapshot.connection_generation == generation && + g_attachments[slot].device != nullptr && + g_attachments[slot].generation == generation; + if (accepted) { + if (g_host_count == kHostQueueCapacity) { + g_host_head = (g_host_head + 1) % kHostQueueCapacity; + --g_host_count; + if (g_host_drops != UINT32_MAX) ++g_host_drops; + } + const uint8_t index = (g_host_head + g_host_count) % kHostQueueCapacity; + g_host_queue[index] = update; + ++g_host_count; + if (g_host_updates != UINT32_MAX) ++g_host_updates; + } + critical_section_exit(&g_lock); + return accepted; +} + +void update_max(uint32_t* value, uint32_t candidate) { + if (candidate > *value) { + *value = candidate; + } +} + +uint64_t packet_due(uint32_t packet) { + // Round each absolute rational deadline up, never its relative interval. + return g_start_us + + (static_cast(packet) * packet_numerator_us() + + kPacketDenominator - 1) / kPacketDenominator; +} + +bool connection_current() { + if (g_phase == Phase::kIdle || g_diagnostics.slot >= 4) { + return false; + } + const Attachment& attached = g_attachments[g_diagnostics.slot]; + return attached.device == g_connection.device && + attached.generation == g_connection.generation && + attached.cid == g_connection.cid; +} + +void publish(bool finished = false) { + if (g_phase != Phase::kIdle) { + g_diagnostics.elapsed_us = + static_cast(time_us_64() - g_start_us); + } + critical_section_enter_blocking(&g_lock); + // A newly accepted start must not be overwritten by the preceding run. + if (g_snapshot.run_id == g_diagnostics.run_id) { + g_diagnostics.host_updates = g_host_updates; + const uint64_t dropped = uint64_t{g_host_drops} + + (gameplay() && g_synth_started ? g_synth.dropped_updates() : 0); + g_diagnostics.dropped_updates = + dropped > UINT32_MAX ? UINT32_MAX : static_cast(dropped); + g_snapshot = g_diagnostics; + g_snapshot_waiting = g_send_requested; + g_snapshot_request_us = static_cast(g_request_us); + if (finished) { + g_busy = false; + g_accept_host = false; + } + } + critical_section_exit(&g_lock); +} + +void cancel_timer(btstack_timer_source_t* timer, bool* armed) { + if (*armed) { + btstack_run_loop_remove_timer(timer); + *armed = false; + } +} + +void schedule_timer(btstack_timer_source_t* timer, bool* armed, + uint64_t deadline_us) { + cancel_timer(timer, armed); + const uint64_t now_us = time_us_64(); + // Pico's relative timer is floor(now_us/1000) + timeout_ms + 1. + // Aim for the deadline's millisecond (possibly early); the handlers check + // microseconds again. Only that final fractional tick needs a zero-delay + // rearm. There is no permanent millisecond polling timer. + const uint64_t now_ms = now_us / 1000; + const uint64_t due_ms = deadline_us / 1000; + const uint32_t delay_ms = due_ms > now_ms + 1 + ? static_cast(due_ms - now_ms - 1) + : 0; + btstack_run_loop_set_timer(timer, delay_ms); + *armed = true; + btstack_run_loop_add_timer(timer); +} + +void schedule_lifecycle(uint64_t deadline_us) { + g_lifecycle_due_us = deadline_us; + schedule_timer(&g_lifecycle_timer, &g_lifecycle_armed, deadline_us); +} + +void finish(HapticsExperimentState state, uint8_t error) { + haptics_transport_probe_end(); + cancel_timer(&g_cadence_timer, &g_cadence_armed); + cancel_timer(&g_lifecycle_timer, &g_lifecycle_armed); + g_send_requested = false; + g_diagnostics.elapsed_us = + static_cast(time_us_64() - g_start_us); + g_diagnostics.state = state; + if (error != kNoError) { + g_diagnostics.last_error = error; + } + g_phase = Phase::kIdle; + g_connection = {}; + publish(true); +} + +void account_wait(uint64_t now_us) { + update_max(&g_diagnostics.max_request_wait_us, + static_cast(now_us - g_request_us)); +} + +void timeout_drain() { + if (g_send_requested) { + account_wait(time_us_64()); + } + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTimeout; + // The outstanding BTstack notification cannot be canceled. Stop accepting + // it as PCM permission; the normal FIFO may use it for compatibility output. + g_send_requested = false; + restore_compatibility(HapticsExperimentState::kError); +} + +void begin_drain(HapticsExperimentState state, uint64_t deadline_us) { + critical_section_enter_blocking(&g_lock); + g_accept_host = false; + critical_section_exit(&g_lock); + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_phase = Phase::kDrain; + g_finish_state = state; + schedule_lifecycle(deadline_us); + if (time_us_64() >= deadline_us) { + timeout_drain(); + } else if (!g_send_requested) { + request_send(); + } +} + +void end_pattern() { + g_diagnostics.skipped_packets += kPackets - g_next_packet; + g_next_packet = kPackets; + if (!g_send_requested && g_diagnostics.sent_packets != 0 && + g_last_was_silence) { + restore_compatibility(HapticsExperimentState::kCompleted); + } else { + // Do not let a never-delivered CAN_SEND_NOW strand ownership forever. + begin_drain(HapticsExperimentState::kCompleted, + g_end_us + kDrainTimeoutUs); + } +} + +void restore_compatibility(HapticsExperimentState state) { + critical_section_enter_blocking(&g_lock); + g_accept_host = false; + critical_section_exit(&g_lock); + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_send_requested = false; + g_phase = Phase::kRestore; + g_finish_state = g_diagnostics.send_failures != 0 + ? HapticsExperimentState::kError + : state; + // duration=0 is a no-op when the parser already believes rumble is off. + // Force the HAPTICS_SELECT / compatible-vibration report with zero motors. + // Set the phase first: synchronous notifications here belong to that FIFO. + g_connection.device->report_parser.play_dual_rumble( + g_connection.device, 0, 1, 0, 0); + // Immediately cancel the newly installed parser timer and emit its zero + // stop. Upstream device deletion does not remove private parser timers. + // No host effect can interleave between these two calls on the BT core. + g_connection.device->report_parser.play_dual_rumble( + g_connection.device, 0, 0, 0, 0); + const uint64_t now_us = time_us_64(); + g_restore_deadline_us = now_us + kDrainTimeoutUs; + schedule_lifecycle(now_us + kRestoreSettleUs); + publish(); +} + +void request_send() { + if (g_send_requested || g_request_in_progress || + (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { + return; + } + g_request_us = time_us_64(); + g_send_requested = true; + g_request_in_progress = true; + ++g_diagnostics.can_send_requests; + if (gameplay() && g_phase == Phase::kPattern) { + schedule_lifecycle(g_request_us + kDrainTimeoutUs); + } + const uint8_t status = + l2cap_request_can_send_now_event(g_connection.cid); + g_request_in_progress = false; + if (status == ERROR_CODE_SUCCESS && !g_send_requested) { + // The synchronous callback already published its result and armed the + // next deadline. Do not touch that timer or copy its snapshot again. + return; + } + if (status != ERROR_CODE_SUCCESS && g_send_requested) { + g_send_requested = false; + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTransport; + // A failed request is not permission. Retry on a future deadline, not + // through a recursive callback or a tight loop. + if (g_phase == Phase::kPattern) { + const uint64_t now_us = time_us_64(); + if (now_us >= g_end_us) { + end_pattern(); + } else { + const uint32_t current = static_cast( + ((now_us - g_start_us) * kPacketDenominator) / + packet_numerator_us()); + g_diagnostics.skipped_packets += current + 1 - g_next_packet; + g_next_packet = current + 1; + if (gameplay() || g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + } + } else if (g_phase == Phase::kDrain) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + time_us_64() + (packet_numerator_us() + 2) / 3); + } + } + publish(); +} + +// Returns whether the block contains a scheduled tone (not actuator evidence). +bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet, + bool silence) { + for (uint16_t i = 0; i < kReportBytes; ++i) { + report[i] = 0; + } + report[0] = 0xa2; + report[1] = 0x32; + uint16_t sample_offset; + uint32_t frames; + if (g_diagnostics.sent_packets == 0) { + // Explicitly leave compatibility mode with a sized 0x10 state block. + // All other state-write flags remain clear, preserving other outputs. + report[3] = 0x90; + report[4] = 63; + report[68] = 0x92; + report[69] = 64; + sample_offset = 70; + frames = 32; // Initial mode handoff occupies the first silent interval. + } else { + // Compact controls leave room for either one or two 64-byte blocks. + frames = gameplay() ? kGameplayFrames : 64u; + report[3] = 0x91; + report[4] = 3; + report[5] = 0x62; + report[6] = 16; + report[7] = static_cast(g_diagnostics.sent_packets * (frames / 32)); + report[8] = frames == 64 ? 0xd2 : 0x92; + report[9] = 64; + sample_offset = 10; + } + bool tone = false; + if (gameplay() && !silence) { + drain_host_updates(); + // One report of causal lookback preserves every 8 ms USB command, + // including substeps arriving between Bluetooth sends. + if (packet != 0 && g_diagnostics.sent_packets != 0) { + const uint64_t first_sample = uint64_t{packet - 1} * frames; + g_synth.render(first_sample, frames, report + sample_offset); + for (uint32_t frame = 0; frame < frames; ++frame) { + if (report[sample_offset + frame * 2] != 0 || + report[sample_offset + frame * 2 + 1] != 0) { + tone = true; + if (!g_first_tone_sent) { + g_diagnostics.first_tone_due_us = static_cast( + g_start_us + ((first_sample + frame) * 1000 + 2) / 3); + } + break; + } + } + } + } else if (!gameplay() && !silence && packet >= kPrimingPackets && + packet < kToneEndPacket) { + const uint32_t relative = packet - kPrimingPackets; + const uint32_t phase = (relative / kPhasePackets) % 4; + if (phase == 0 || phase == 2) { + tone = true; + const uint32_t stride = phase == 0 ? 1 : 2; + uint32_t wave = ((relative % kPhasePackets) * 64 * stride) % 30; + const uint32_t channel = phase == 0 ? 0 : 1; + for (uint32_t frame = 0; frame < frames; ++frame) { + report[sample_offset + frame * 2 + channel] = + static_cast(kSine[wave]); + wave += stride; + if (wave >= 30) { + wave -= 30; + } + } + } + } + // Bluetooth CRC includes the A2 transaction byte and excludes only CRC. + uint32_t crc = 0xffffffffu; + for (uint16_t i = 0; i < kCrcOffset; ++i) { + crc ^= report[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + } + } + crc = ~crc; + for (uint8_t byte = 0; byte < 4; ++byte) { + report[kCrcOffset + byte] = static_cast(crc >> (byte * 8)); + } + return tone; +} + +void cadence_timer(btstack_timer_source_t*) { + g_cadence_armed = false; + if (!connection_current()) { + if (g_phase != Phase::kIdle) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + return; + } + const uint64_t now_us = time_us_64(); + if (g_phase == Phase::kPrepare) { + if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) { + start_stream(); + } else if (now_us >= g_lifecycle_due_us) { + finish(HapticsExperimentState::kError, kQueuedOutput); + } else { + schedule_timer(&g_cadence_timer, &g_cadence_armed, now_us + 2000); + } + return; + } + if (g_phase == Phase::kPattern) { + const uint64_t due_us = packet_due(g_next_packet); + haptics_transport_probe_timer( + now_us > due_us ? static_cast(now_us - due_us) : 0); + } + if (g_phase == Phase::kPattern) { + if (now_us >= g_end_us) { + end_pattern(); + } else if (now_us < packet_due(g_next_packet)) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } else { + request_send(); + } + } else if (g_phase == Phase::kDrain) { + if (now_us >= g_lifecycle_due_us) { + timeout_drain(); + } else { + request_send(); + } + } +} + +void lifecycle_timer(btstack_timer_source_t*) { + g_lifecycle_armed = false; + if (!connection_current()) { + if (g_phase != Phase::kIdle) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + return; + } + if (time_us_64() < g_lifecycle_due_us) { + schedule_lifecycle(g_lifecycle_due_us); + return; + } + if (g_phase == Phase::kPrepare) { + finish(HapticsExperimentState::kError, kQueuedOutput); + } else if (g_phase == Phase::kPattern) { + if (gameplay()) { + timeout_drain(); + } else { + end_pattern(); + } + } else if (g_phase == Phase::kDrain) { + timeout_drain(); + } else if (g_phase == Phase::kRestore) { + if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) { + finish(g_finish_state, kNoError); + } else if (time_us_64() < g_restore_deadline_us) { + schedule_lifecycle(g_restore_deadline_us); + } else { + ++g_diagnostics.send_failures; + finish(HapticsExperimentState::kError, kTimeout); + } + } +} + +void start_stream() { + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_start_us = time_us_64(); + g_end_us = gameplay() ? UINT64_MAX : g_start_us + 6144000; + g_diagnostics.start_us = static_cast(g_start_us); + g_diagnostics.first_tone_due_us = + gameplay() ? 0 : static_cast(packet_due(kPrimingPackets)); + g_diagnostics.state = HapticsExperimentState::kRunning; + g_phase = Phase::kPattern; + g_next_packet = 0; + g_send_requested = false; + g_last_was_silence = true; + g_first_tone_sent = false; + if (gameplay()) { + g_synth.reset(g_start_us); + g_synth_started = true; + } + schedule_lifecycle(gameplay() ? g_start_us + kDrainTimeoutUs : g_end_us); + request_send(); +} + +void start(const Command& command) { + g_diagnostics = {}; + g_diagnostics.run_id = command.run_id; + g_diagnostics.slot = command.slot; + g_diagnostics.mode = command.action == 2 ? 1 : 0; + g_synth_started = false; + g_connection = command.connection; + g_diagnostics.connection_generation = g_connection.generation; + g_start_us = time_us_64(); + g_diagnostics.start_us = static_cast(g_start_us); + uni_hid_device_t* device = g_connection.device; + const Attachment& attached = g_attachments[command.slot]; + const bool current = device != nullptr && attached.device == device && + attached.generation == g_connection.generation && + attached.cid == g_connection.cid; + haptics_transport_probe_begin( + command.run_id, g_connection.generation, + current ? device->conn.handle : 0xffff); + if (!current) { + finish(HapticsExperimentState::kDisconnected, kConnection); + return; + } + if (device->vendor_id != 0x054c || + (device->product_id != 0x0ce6 && device->product_id != 0x0df2) || + gap_get_connection_type(device->conn.handle) != GAP_CONNECTION_ACL || + device->report_parser.play_dual_rumble == nullptr) { + finish(HapticsExperimentState::kUnsupported, kUnsupported); + return; + } + if (g_connection.cid == 0 || !device->conn.connected || + device->conn.interrupt_cid != g_connection.cid) { + finish(HapticsExperimentState::kDisconnected, kConnection); + return; + } + if (l2cap_get_remote_mtu_for_local_cid(g_connection.cid) < kReportBytes) { + finish(HapticsExperimentState::kUnsupported, kMtu); + return; + } + // Never discard unrelated LED/control reports or allow them to switch the + // controller back to compatibility midstream. A queued start is retryable + // once the ordinary sender has drained it. + if (!gameplay() && !uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + finish(HapticsExperimentState::kError, kQueuedOutput); + return; + } + // Cancel any existing parser duration/delayed-start timer before taking + // over. In the already-disabled case this deliberately emits no report. + device->report_parser.play_dual_rumble(device, 0, 0, 0, 0); + btstack_run_loop_set_timer_handler(&g_cadence_timer, cadence_timer); + btstack_run_loop_set_timer_handler(&g_lifecycle_timer, lifecycle_timer); + if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + if (gameplay()) { + // Let connection setup/LED reports drain before taking over. + g_phase = Phase::kPrepare; + g_diagnostics.state = HapticsExperimentState::kPending; + schedule_lifecycle(time_us_64() + kDrainTimeoutUs); + schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 2000); + publish(); + } else { + finish(HapticsExperimentState::kError, kQueuedOutput); + } + return; + } + start_stream(); +} + +} // namespace + +void haptics_experiment_prepare() { + if (!g_prepared) { + critical_section_init(&g_lock); + haptics_transport_probe_prepare(); + g_prepared = true; + } +} + +bool haptics_experiment_request(uint8_t action, uint8_t slot) { + if (action > 2 || slot >= 4) { + return false; + } + critical_section_enter_blocking(&g_lock); + bool accepted = true; + if (action != 0) { + if (g_busy) { + accepted = false; + } else { + const uint32_t run_id = g_snapshot.run_id + 1; + g_snapshot = {}; + g_snapshot.run_id = run_id; + g_snapshot.slot = slot; + g_snapshot.connection_generation = g_attachments[slot].generation; + g_snapshot.state = HapticsExperimentState::kPending; + g_snapshot.mode = action == 2 ? 1 : 0; + g_accept_host = action == 2; + g_host_head = 0; + g_host_count = 0; + g_host_updates = 0; + g_host_drops = 0; + g_snapshot_waiting = false; + g_busy = true; + g_command = {true, action, slot, run_id, g_attachments[slot], g_snapshot.mode}; + } + } else if (g_busy) { + if (slot != g_snapshot.slot) { + accepted = false; + } else { + g_accept_host = false; + // Replaces even an unconsumed start, without a FIFO of commands. + g_command = { + true, action, slot, g_snapshot.run_id, + {nullptr, g_snapshot.connection_generation, 0}, g_snapshot.mode}; + } + } + critical_section_exit(&g_lock); + return accepted; +} + +bool haptics_experiment_submit(uint8_t slot, uint32_t generation, + uint64_t received_us, + const SwitchHapticsFrame& frame) { + if (frame.actuators[0].sample_count > 3 || frame.actuators[1].sample_count > 3 || + (frame.actuators[0].sample_count == 0 && frame.actuators[1].sample_count == 0)) { + return false; + } + return submit_host_update(slot, generation, {received_us, frame}); +} + +bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation, + uint64_t received_us, + uint8_t low, uint8_t high) { + return submit_host_update(slot, generation, {received_us, {}, true, low, high}); +} + +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_snapshot; + const bool waiting = g_snapshot_waiting; + const uint32_t requested_us = g_snapshot_request_us; + critical_section_exit(&g_lock); + output->packet_frames = output->mode == 1 ? kGameplayFrames : 64; + if (output->state == HapticsExperimentState::kRunning) { + const uint32_t now_us = static_cast(time_us_64()); + output->elapsed_us = now_us - output->start_us; + if (waiting) { + update_max(&output->max_request_wait_us, now_us - requested_us); + } + } +} + +void haptics_experiment_attach(uint8_t slot, uint32_t generation, + uni_hid_device_t* device) { + if (slot >= 4 || device == nullptr) { + return; + } + const uint16_t cid = device->conn.interrupt_cid; + if (g_phase != Phase::kIdle && + (g_diagnostics.slot == slot || g_connection.device == device) && + (g_diagnostics.slot != slot || g_connection.device != device || + g_connection.generation != generation || g_connection.cid != cid)) { + finish(HapticsExperimentState::kDisconnected, kConnection); + } + // A reused instance must not remain selectable through an old slot. + critical_section_enter_blocking(&g_lock); + for (Attachment& attached : g_attachments) { + if (attached.device == device) { + attached = {}; + } + } + g_attachments[slot] = {device, generation, cid}; + critical_section_exit(&g_lock); +} + +void haptics_experiment_detach(uni_hid_device_t* device) { + if (device == nullptr) { + return; + } + if (g_phase != Phase::kIdle && g_connection.device == device) { + // Do not dereference the device or send restoration on a dead link. + finish(HapticsExperimentState::kDisconnected, kConnection); + } + critical_section_enter_blocking(&g_lock); + for (Attachment& attached : g_attachments) { + if (attached.device == device) { + attached = {}; + } + } + critical_section_exit(&g_lock); +} + +void haptics_experiment_poll() { + critical_section_enter_blocking(&g_lock); + const Command command = g_command; + g_command.pending = false; + critical_section_exit(&g_lock); + if (!command.pending) { + return; + } + if (command.action != 0) { + start(command); + } else if (g_phase != Phase::kIdle && + g_diagnostics.run_id == command.run_id) { + if (g_phase == Phase::kPrepare) { + finish(HapticsExperimentState::kStopped, kNoError); + } else if (g_phase == Phase::kRestore) { + if (g_finish_state != HapticsExperimentState::kError) { + g_finish_state = HapticsExperimentState::kStopped; + } + } else if (g_phase != Phase::kDrain || + g_finish_state != HapticsExperimentState::kStopped) { + begin_drain(HapticsExperimentState::kStopped, + time_us_64() + kDrainTimeoutUs); + } + } else { + // Stop preempted a start still in the mailbox, or raced completion. + if (g_diagnostics.run_id != command.run_id) { + g_diagnostics = {}; + g_diagnostics.run_id = command.run_id; + g_diagnostics.slot = command.slot; + g_diagnostics.connection_generation = command.connection.generation; + g_diagnostics.state = HapticsExperimentState::kStopped; + g_diagnostics.mode = command.mode; + haptics_transport_probe_begin( + command.run_id, command.connection.generation, 0xffff); + haptics_transport_probe_end(); + } + publish(true); + } +} + +bool haptics_experiment_owns(const uni_hid_device_t* device) { + return device != nullptr && device == g_connection.device && + connection_current(); +} + +bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device) { + return gameplay() && g_phase == Phase::kPattern && + haptics_experiment_owns(device); +} + +bool haptics_experiment_feedback(uni_hid_device_t* device, + uint8_t low, uint8_t high, uint16_t duration_ms) { + if (!haptics_experiment_gameplay_owns(device)) return false; + drain_host_updates(); + g_synth.feedback(time_us_64(), uint32_t{duration_ms} * 1000, low, high); + return true; +} + + +bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, + uint16_t cid) { + if (device != g_connection.device || !connection_current() || + (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { + return false; + } + if (gameplay() && g_phase == Phase::kPattern && !g_in_callback && + !uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + // Gameplay permits ordinary LED reports, never compatibility rumble. + // Yield this credit to the generic FIFO, then request fresh permission. + if (cid == g_connection.cid && g_send_requested) { + account_wait(time_us_64()); + g_send_requested = false; + schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 1000); + } + return false; + } + // The generic FIFO is device-wide, not CID-specific. Consume control-CID + // and unsolicited events too, without treating them as PCM permission. + if (cid != g_connection.cid || !g_send_requested || g_in_callback) { + return true; + } + g_send_requested = false; + g_in_callback = true; + if (g_request_in_progress) { + ++g_diagnostics.synchronous_callbacks; + } + const uint64_t now_us = time_us_64(); + account_wait(now_us); + haptics_transport_probe_permission( + static_cast(now_us - g_request_us)); + if (g_phase == Phase::kPattern && now_us >= g_end_us) { + g_diagnostics.skipped_packets += kPackets - g_next_packet; + g_next_packet = kPackets; + g_phase = Phase::kDrain; + g_finish_state = HapticsExperimentState::kCompleted; + schedule_lifecycle(g_end_us + kDrainTimeoutUs); + } + if (g_phase == Phase::kDrain && now_us >= g_lifecycle_due_us) { + timeout_drain(); + g_in_callback = false; + return true; + } + if (g_phase == Phase::kPattern && now_us < packet_due(g_next_packet)) { + // Notifications are not reservations of credit for a future deadline. + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + g_in_callback = false; + publish(); + return true; + } + const bool stopping = g_phase == Phase::kDrain; + uint64_t due_us = now_us; + if (!stopping) { + const uint32_t current = static_cast( + ((now_us - g_start_us) * kPacketDenominator) / packet_numerator_us()); + g_diagnostics.skipped_packets += current - g_next_packet; + g_next_packet = current; + due_us = packet_due(current); + } + uint8_t report[kReportBytes]; + const uint64_t generate_start_us = time_us_64(); + const bool tone = generate_packet(report, g_next_packet, stopping); + const uint64_t submit_us = time_us_64(); + update_max(&g_diagnostics.max_generate_us, + static_cast(submit_us - generate_start_us)); + ++g_diagnostics.generated_packets; + update_max(&g_diagnostics.max_lateness_us, + static_cast(submit_us - due_us)); + if (!stopping && submit_us >= packet_due(g_next_packet + 1)) { + // A flash/interrupt stall can occur during synthesis as well as before + // CAN_SEND_NOW. Never submit a now-obsolete tone after its phase ended. + ++g_diagnostics.skipped_packets; + ++g_next_packet; + if (gameplay() || g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + g_in_callback = false; + publish(); + return true; + } + if (stopping && submit_us >= g_lifecycle_due_us) { + timeout_drain(); + g_in_callback = false; + return true; + } + const uint64_t send_started_us = time_us_64(); + const uint8_t status = l2cap_send(cid, report, sizeof(report)); + const uint64_t send_returned_us = time_us_64(); + haptics_transport_probe_send( + static_cast(send_returned_us - send_started_us), + static_cast(send_returned_us), + tone && !g_first_tone_sent && status == ERROR_CODE_SUCCESS); + if (!connection_current()) { + // A transport may synchronously report teardown. Detach already + // published the terminal state; do not rearm a timer on its old CID. + g_in_callback = false; + return true; + } + if (status == ERROR_CODE_SUCCESS) { + if (g_diagnostics.sent_packets != 0) { + update_max(&g_diagnostics.max_send_gap_us, + static_cast(submit_us) - g_diagnostics.last_sent_us); + } + ++g_diagnostics.sent_packets; + g_diagnostics.last_sent_us = static_cast(submit_us); + g_last_was_silence = !tone; + g_diagnostics.last_packet_nonzero = tone; + if (tone && !g_first_tone_sent) { + g_first_tone_sent = true; + g_diagnostics.first_tone_sent_us = static_cast(submit_us); + } + } else { + ++g_diagnostics.send_failures; + g_diagnostics.last_error = kTransport; + } + if (stopping) { + if (status == ERROR_CODE_SUCCESS) { + restore_compatibility(g_finish_state); + } else { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + time_us_64() + (packet_numerator_us() + 2) / 3); + } + } else { + ++g_next_packet; + if (gameplay() || g_next_packet < kPackets) { + schedule_timer(&g_cadence_timer, &g_cadence_armed, + packet_due(g_next_packet)); + } + if (gameplay()) { + schedule_lifecycle(packet_due(g_next_packet) + kDrainTimeoutUs); + } + } + g_in_callback = false; + publish(); + return true; +} diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h new file mode 100644 index 0000000..e040ede --- /dev/null +++ b/src/firmware/input/haptics_experiment.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +struct uni_hid_device_s; +typedef struct uni_hid_device_s uni_hid_device_t; +struct SwitchHapticsFrame; + +enum class HapticsExperimentState : uint8_t { + kIdle = 0, + kPending = 1, + kRunning = 2, + kCompleted = 3, + kStopped = 4, + kDisconnected = 5, + kUnsupported = 6, + kError = 7, +}; + +struct HapticsExperimentDiagnostics { + uint32_t run_id = 0; + uint32_t connection_generation = 0; + uint32_t start_us = 0; + uint32_t generated_packets = 0; + uint32_t sent_packets = 0; + uint32_t skipped_packets = 0; + uint32_t send_failures = 0; + uint32_t can_send_requests = 0; + uint32_t synchronous_callbacks = 0; + uint32_t max_generate_us = 0; + uint32_t max_send_gap_us = 0; + uint32_t max_lateness_us = 0; + uint32_t max_request_wait_us = 0; + uint32_t first_tone_due_us = 0; + uint32_t first_tone_sent_us = 0; + uint32_t last_sent_us = 0; + uint32_t elapsed_us = 0; + HapticsExperimentState state = HapticsExperimentState::kIdle; + uint8_t slot = 0xff; + uint8_t last_error = 0; + uint8_t mode = 0; + uint32_t host_updates = 0; + uint32_t dropped_updates = 0; + uint8_t packet_frames = 64; + bool last_packet_nonzero = false; +}; + +// Core 0 before launching BTstack; request/snapshot are cross-core safe. +void haptics_experiment_prepare(); +bool haptics_experiment_request(uint8_t action, uint8_t slot); +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output); +// Core 0 USB delivery. Rejects non-selected/stale connections; never buffers PCM. +bool haptics_experiment_submit(uint8_t slot, uint32_t generation, + uint64_t received_us, + const SwitchHapticsFrame& frame); +// Stateful, already profile-scaled XInput strengths, including explicit zero. +bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation, + uint64_t received_us, + uint8_t low, uint8_t high); + +// Core 1 / BTstack only. Poll consumes management requests, not PCM cadence. +void haptics_experiment_attach(uint8_t slot, uint32_t generation, + uni_hid_device_t* device); +void haptics_experiment_detach(uni_hid_device_t* device); +void haptics_experiment_poll(); +bool haptics_experiment_owns(const uni_hid_device_t* device); +bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device); +bool haptics_experiment_feedback(uni_hid_device_t* device, + uint8_t low, uint8_t high, uint16_t duration_ms); +bool haptics_experiment_on_can_send_now(uni_hid_device_t* device, + uint16_t cid); diff --git a/src/firmware/input/haptics_transport_probe.cpp b/src/firmware/input/haptics_transport_probe.cpp new file mode 100644 index 0000000..839ec14 --- /dev/null +++ b/src/firmware/input/haptics_transport_probe.cpp @@ -0,0 +1,348 @@ +#include "input/haptics_transport_probe.h" + +#include +#include +#include +#if PICO_ON_DEVICE +#include "platform/pico/system_clock.h" +#endif + +extern "C" { +#include + +// Derive the declarations from the SDK: in particular write uses size_t, +// whereas read's capacity and output length are uint32_t even on a 64-bit host. +decltype(cyw43_bluetooth_hci_write) __real_cyw43_bluetooth_hci_write; +decltype(cyw43_bluetooth_hci_write) __wrap_cyw43_bluetooth_hci_write; +decltype(cyw43_bluetooth_hci_read) __real_cyw43_bluetooth_hci_read; +decltype(cyw43_bluetooth_hci_read) __wrap_cyw43_bluetooth_hci_read; +decltype(btstack_run_loop_base_poll_data_sources) + __real_btstack_run_loop_base_poll_data_sources; +decltype(btstack_run_loop_base_poll_data_sources) + __wrap_btstack_run_loop_base_poll_data_sources; +} + +namespace { + +critical_section_t g_lock; +HapticsTransportProbe g_probe; +bool g_prepared = false; + +// Only the snapshot crosses cores. All other state, including wrapper entry, +// belongs to core 1; the lock never spans SDK calls or event delivery. +uint64_t g_epoch = 0; +bool g_registered = false; +bool g_credit_sampled = false; +bool g_tone_recorded = false; +bool g_have_completion = false; +bool g_have_poll = false; +uint32_t g_last_completion_us = 0; +uint32_t g_last_poll_us = 0; +bool g_in_write = false; +bool g_in_read = false; +bool g_in_poll = false; +bool g_receive_work = false; +uint16_t g_controller_acl_bytes = 0; +uint16_t g_controller_acl_count = 0; +btstack_packet_callback_registration_t g_registration{}; + +uint32_t now_us() { + return static_cast(time_us_64()); +} + +void add(uint32_t& total, uint32_t value) { + total = value > UINT32_MAX - total ? UINT32_MAX : total + value; +} + +void maximum(uint32_t& peak, uint32_t value) { + if (value > peak) peak = value; +} + +void duration(uint32_t& calls, uint32_t& peak, uint32_t& total, + uint32_t elapsed) { + add(calls, 1); + maximum(peak, elapsed); + add(total, elapsed); +} + +bool current(uint64_t epoch) { + return g_probe.active != 0 && g_epoch == epoch; +} + +void sample_credits(uint64_t epoch) { + if (!current(epoch) || g_probe.connection_handle > 0x0fff) return; + const auto handle = static_cast(g_probe.connection_handle); + // SDK lookup is fresh each time: never retain a connection across I/O or + // callbacks, which can synchronously disconnect and free it. + const hci_connection_t* connection = hci_connection_for_handle(handle); + if (connection == nullptr) return; + const uint32_t outstanding = connection->num_packets_sent; + const int free_slots = hci_number_free_acl_slots_for_handle(handle); + if (!current(epoch) || free_slots < 0) return; + critical_section_enter_blocking(&g_lock); + maximum(g_probe.max_outstanding_acl, outstanding); + const auto available = static_cast(free_slots); + if (!g_credit_sampled || available < g_probe.min_free_acl) { + g_probe.min_free_acl = available; + } + g_credit_sampled = true; + critical_section_exit(&g_lock); +} + +// Suppress only same-boundary recursive measurement, not its real call. Poll, +// read, write and sender totals are inclusive and deliberately overlap. Epoch +// checks discard a call that spans finish or a new begin, even with reused IDs. +struct Call { + bool& entered; + const bool outer; + const uint64_t epoch; + const bool measured; + uint32_t start_us = 0; + + explicit Call(bool& guard) + : entered(guard), outer(!guard), epoch(g_epoch), + measured(outer && g_probe.active != 0) { + entered = true; + if (measured) { + sample_credits(epoch); + start_us = now_us(); + } + } + + ~Call() { + if (outer) entered = false; + } + + bool live() const { + return measured && current(epoch); + } +}; + +uint16_t read_le16(const uint8_t* bytes) { + return static_cast(bytes[0] | (uint16_t{bytes[1]} << 8)); +} +void capture_controller_capacity(const uint8_t* buffer, uint32_t length) { + // CYW43's four-byte header precedes HCI Command Complete (Read Buffer Size). + // Capture the raw response before BTstack applies its software buffer cap. + if (length < 17 || buffer[3] != HCI_EVENT_PACKET || buffer[4] != 0x0e || + buffer[5] != 11 || buffer[7] != 0x05 || buffer[8] != 0x10 || + buffer[9] != 0) { + return; + } + g_controller_acl_bytes = read_le16(buffer + 10); + g_controller_acl_count = read_le16(buffer + 13); + if (g_prepared) { + critical_section_enter_blocking(&g_lock); + g_probe.controller_acl_packet_bytes = g_controller_acl_bytes; + g_probe.controller_acl_packet_count = g_controller_acl_count; + critical_section_exit(&g_lock); + } +} + + +void handle_event(uint8_t packet_type, uint16_t, uint8_t* packet, + uint16_t size) { + if (!g_probe.active || packet_type != HCI_EVENT_PACKET || packet == nullptr || + size < 3 || packet[0] != HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS || + size != uint16_t{packet[1]} + 2u || + size != 3u + uint16_t{packet[2]} * 4u || + g_probe.connection_handle > 0x0fff) { + return; + } + uint32_t completed = 0; + bool selected = false; + for (uint16_t offset = 3; offset < size; offset += 4) { + if ((read_le16(packet + offset) & 0x0fff) == g_probe.connection_handle) { + selected = true; + completed += read_le16(packet + offset + 2); + } + } + if (!selected) return; + const uint64_t epoch = g_epoch; + const uint32_t timestamp = now_us(); + sample_credits(epoch); + if (!current(epoch)) return; + critical_section_enter_blocking(&g_lock); + add(g_probe.completion_events, 1); + add(g_probe.completed_packets, completed); + // Gap between selected-handle events, not time from run start to first. + if (g_have_completion) { + maximum(g_probe.max_completion_gap_us, timestamp - g_last_completion_us); + } + g_have_completion = true; + g_last_completion_us = timestamp; + critical_section_exit(&g_lock); + // BTstack updates credit accounting before delivering this event. These + // samples and the surrounding I/O samples are observed extrema only. + sample_credits(epoch); +} + +} // namespace + +void haptics_transport_probe_prepare() { + if (!g_prepared) { + critical_section_init(&g_lock); + g_prepared = true; + } +} + +void haptics_transport_probe_snapshot(HapticsTransportProbe* output) { + if (output == nullptr) return; + critical_section_enter_blocking(&g_lock); + *output = g_probe; + critical_section_exit(&g_lock); +#if PICO_ON_DEVICE + const SystemClockStatus clock = system_clock_status(); + output->requested_sys_khz = clock.requested_sys_khz; + output->measured_sys_khz = clock.measured_sys_khz; + output->measured_usb_khz = clock.measured_usb_khz; + output->core_voltage_mv = clock.core_voltage_mv; + output->flash_clock_divider = clock.flash_clock_divider; + output->cyw43_pio_divider256 = clock.cyw43_pio_divider256; + output->temperature_millicelsius = clock.temperature_millicelsius; +#endif +#ifdef SWITCH_PICO_CYW43_PACKET_READ + output->packet_read_optimized = 1; +#endif +} + +void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation, + uint16_t handle) { + ++g_epoch; + critical_section_enter_blocking(&g_lock); + g_probe = {}; + g_probe.run_id = run_id; + g_probe.connection_generation = generation; + g_probe.connection_handle = handle; + g_probe.controller_acl_packet_bytes = g_controller_acl_bytes; + g_probe.controller_acl_packet_count = g_controller_acl_count; + g_probe.active = 1; + g_credit_sampled = false; + g_tone_recorded = false; + g_have_completion = false; + g_have_poll = false; + critical_section_exit(&g_lock); + if (!g_registered) { + g_registered = true; + g_registration.callback = handle_event; + hci_add_event_handler(&g_registration); + } + sample_credits(g_epoch); +} + +void haptics_transport_probe_end() { + sample_credits(g_epoch); + critical_section_enter_blocking(&g_lock); + g_probe.active = 0; + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_timer(uint32_t lateness_us) { + if (!g_probe.active) return; + critical_section_enter_blocking(&g_lock); + duration(g_probe.timer_wakes, g_probe.max_timer_lateness_us, + g_probe.total_timer_lateness_us, lateness_us); + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_permission(uint32_t wait_us) { + if (!g_probe.active) return; + sample_credits(g_epoch); + critical_section_enter_blocking(&g_lock); + duration(g_probe.permission_callbacks, g_probe.max_permission_wait_us, + g_probe.total_permission_wait_us, wait_us); + critical_section_exit(&g_lock); +} + +void haptics_transport_probe_send(uint32_t duration_us, uint32_t return_us, + bool first_tone_success) { + if (!g_probe.active) return; + critical_section_enter_blocking(&g_lock); + duration(g_probe.send_calls, g_probe.max_send_us, g_probe.total_send_us, + duration_us); + if (first_tone_success && !g_tone_recorded) { + g_probe.first_tone_send_return_us = return_us; + g_tone_recorded = true; + } + critical_section_exit(&g_lock); + sample_credits(g_epoch); +} + +extern "C" int __wrap_cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) { + Call call(g_in_write); + const bool acl = buffer != nullptr && length >= 4 && buffer[3] == 2; + const bool completed = buffer != nullptr && length >= 6 && + buffer[3] == 1 && buffer[4] == 0x35 && buffer[5] == 0x0c; + const int result = __real_cyw43_bluetooth_hci_write(buffer, length); + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.write_calls, g_probe.max_write_us, + g_probe.total_write_us, elapsed); + add(g_probe.host_completed_writes, completed ? 1 : 0); + add(g_probe.acl_writes, acl ? 1 : 0); + add(g_probe.other_writes, !acl && !completed ? 1 : 0); + add(g_probe.write_failures, result != 0 ? 1 : 0); + critical_section_exit(&g_lock); + } + } + return result; +} + +extern "C" int __wrap_cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, + uint32_t* length) { + Call call(g_in_read); + const int result = __real_cyw43_bluetooth_hci_read(buffer, capacity, length); + if (result == 0 && length != nullptr && *length <= capacity && buffer != nullptr) { + capture_controller_capacity(buffer, *length); + } + // A nonempty read may have left more input queued. The experiment limits + // each transport poll to one packet so timers can run between packets. + g_receive_work = result == 0 && length != nullptr && *length > 0; + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + // Match the transport's successful/nonempty read criterion. An error + // may leave length untouched, so never inspect it on an error return. + const bool packet = result == 0 && length != nullptr && *length > 0; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.read_calls, g_probe.max_read_us, + g_probe.total_read_us, elapsed); + if (packet) add(g_probe.read_packets, 1); + critical_section_exit(&g_lock); + } + } + return result; +} + +extern "C" void __wrap_btstack_run_loop_base_poll_data_sources() { + Call call(g_in_poll); + const bool have_previous = g_have_poll; + const uint32_t gap = call.start_us - g_last_poll_us; + if (call.measured) { + g_have_poll = true; + g_last_poll_us = call.start_us; + } + if (call.outer) g_receive_work = false; + __real_btstack_run_loop_base_poll_data_sources(); + if (call.live()) { + const uint32_t elapsed = now_us() - call.start_us; + sample_credits(call.epoch); + if (call.live()) { + critical_section_enter_blocking(&g_lock); + duration(g_probe.poll_calls, g_probe.max_poll_us, + g_probe.total_poll_us, elapsed); + if (have_previous) maximum(g_probe.max_poll_gap_us, gap); + critical_section_exit(&g_lock); + } + } + if (call.outer && g_receive_work) { + // Mark future work; never recursively drain from the current callback. + // This also runs while measurements are inactive so input cannot be + // stranded waiting for a new hardware IRQ edge after a bounded poll. + btstack_run_loop_poll_data_sources_from_irq(); + } +} diff --git a/src/firmware/input/haptics_transport_probe.h b/src/firmware/input/haptics_transport_probe.h new file mode 100644 index 0000000..e99f1cd --- /dev/null +++ b/src/firmware/input/haptics_transport_probe.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +// Operation 0x41, schema 3: 44 little-endian 32-bit fields. +// All durations are host-side microseconds, not radio/actuator latency. +struct HapticsTransportProbe { + uint32_t run_id = 0; + uint32_t connection_generation = 0; + uint32_t connection_handle = 0xffff; + uint32_t timer_wakes = 0; + uint32_t max_timer_lateness_us = 0; + uint32_t total_timer_lateness_us = 0; + uint32_t send_calls = 0; + uint32_t max_send_us = 0; + uint32_t total_send_us = 0; + uint32_t write_calls = 0; + uint32_t max_write_us = 0; + uint32_t total_write_us = 0; + uint32_t read_calls = 0; + uint32_t read_packets = 0; + uint32_t max_read_us = 0; + uint32_t total_read_us = 0; + uint32_t poll_calls = 0; + uint32_t max_poll_us = 0; + uint32_t total_poll_us = 0; + uint32_t completion_events = 0; + uint32_t completed_packets = 0; + uint32_t max_completion_gap_us = 0; + uint32_t max_outstanding_acl = 0; + uint32_t min_free_acl = 0; + uint32_t first_tone_send_return_us = 0; + uint32_t active = 0; + uint32_t max_permission_wait_us = 0; + uint32_t total_permission_wait_us = 0; + uint32_t permission_callbacks = 0; + uint32_t max_poll_gap_us = 0; + uint32_t controller_acl_packet_bytes = 0; + uint32_t controller_acl_packet_count = 0; + uint32_t requested_sys_khz = 0; + uint32_t measured_sys_khz = 0; + uint32_t measured_usb_khz = 0; + uint32_t core_voltage_mv = 0; + uint32_t flash_clock_divider = 0; + uint32_t cyw43_pio_divider256 = 0; + int32_t temperature_millicelsius = 0; + uint32_t host_completed_writes = 0; + uint32_t acl_writes = 0; + uint32_t other_writes = 0; + uint32_t write_failures = 0; + uint32_t packet_read_optimized = 0; +}; + +// Prepare before core 1 starts. Snapshot alone is called from USB/core 0. +void haptics_transport_probe_prepare(); +void haptics_transport_probe_snapshot(HapticsTransportProbe* output); +// Remaining API belongs to core 1. No probe lock may span a stack call. +void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation, + uint16_t handle); +void haptics_transport_probe_end(); +void haptics_transport_probe_timer(uint32_t lateness_us); +void haptics_transport_probe_permission(uint32_t wait_us); +void haptics_transport_probe_send(uint32_t duration_us, + uint32_t return_us, bool first_tone_success); diff --git a/src/firmware/input/switch2_wake.cpp b/src/firmware/input/switch2_wake.cpp new file mode 100644 index 0000000..4304fd8 --- /dev/null +++ b/src/firmware/input/switch2_wake.cpp @@ -0,0 +1,338 @@ +#include "input/switch2_wake.h" + +#include + +#include + +#ifndef SWITCH2_WAKE_CONFIGURED +#define SWITCH2_WAKE_CONFIGURED 0 +#endif +#if SWITCH2_WAKE_CONFIGURED && \ + !defined(SWITCH2_WAKE_SOURCE_ADDRESS_BYTES) +#include "platform/pico/switch2_wake_config.h" +#endif + +namespace { + +constexpr uint32_t kRetryIntervalMs = 5; +constexpr uint32_t kCommandTimeoutMs = 1000; +constexpr uint32_t kBurstDurationMs = 2000; +constexpr uint16_t kAdvertisingIntervalUnits = 0x0020; // 20 ms + BLE delay. +constexpr uint8_t kAdvertisingTypeNonConnectable = 3; +constexpr uint16_t kWritePublicAddressOpcode = 0xfc01; + +#if SWITCH2_WAKE_CONFIGURED +const uint8_t kWakeAddress[6] = SWITCH2_WAKE_SOURCE_ADDRESS_BYTES; +uint8_t kWakeAdvertisement[31] = SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES; +#endif + +const hci_cmd_t kWritePublicAddress = {kWritePublicAddressOpcode, "B"}; +const bd_addr_t kUnusedPeerAddress{}; + +enum class Phase : uint8_t { + kDisabled, + kSetStableAddress, + kVerifyStableAddress, + kIdle, + kSetParameters, + kSetData, + kEnableAdvertising, + kAdvertising, + kDisableAdvertising, + kFailed, +}; + +Phase g_phase = Phase::kDisabled; +btstack_packet_callback_registration_t g_event_registration{}; +btstack_timer_source_t g_timer{}; +uint16_t g_pending_opcode = 0; +uint32_t g_command_deadline_ms = 0; +uint32_t g_burst_deadline_ms = 0; +uint32_t g_accepted_requests = 0; +uint32_t g_completed_bursts = 0; +uint32_t g_failures = 0; +bool g_initialized = false; +bool g_configured = false; +bool g_identity_ready = false; +bool g_timer_armed = false; +bool g_advertising = false; + +bool deadline_reached(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} + +bool phase_needs_work() { + return g_phase != Phase::kDisabled && + g_phase != Phase::kIdle && + g_phase != Phase::kFailed; +} + +void cancel_task() { + if (g_timer_armed) { + btstack_run_loop_remove_timer(&g_timer); + g_timer_armed = false; + } +} + +void schedule_task(uint32_t delay_ms) { + cancel_task(); + btstack_run_loop_set_timer(&g_timer, delay_ms); + btstack_run_loop_add_timer(&g_timer); + g_timer_armed = true; +} + +void schedule_for_phase(uint32_t now_ms) { + if (g_pending_opcode != 0) { + schedule_task( + deadline_reached(now_ms, g_command_deadline_ms) + ? 0 + : g_command_deadline_ms - now_ms); + } else if (g_phase == Phase::kAdvertising) { + schedule_task( + deadline_reached(now_ms, g_burst_deadline_ms) + ? 0 + : g_burst_deadline_ms - now_ms); + } else if (phase_needs_work()) { + schedule_task(kRetryIntervalMs); + } else { + cancel_task(); + } +} + +#if SWITCH2_WAKE_CONFIGURED +bool configured_packet_valid() { + static const uint8_t prefix[] = { + 0x02, 0x01, 0x06, 0x1b, 0xff, 0x53, 0x05, + 0x01, 0x00, 0x03, 0x7e, 0x05, + }; + static const uint8_t suffix[] = { + 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + uint8_t address_any = 0; + uint8_t target_any = 0; + for (uint8_t value : kWakeAddress) { + address_any |= value; + } + for (size_t index = 17; index < 23; ++index) { + target_any |= kWakeAdvertisement[index]; + } + return address_any != 0 && target_any != 0 && + memcmp(kWakeAdvertisement, prefix, sizeof(prefix)) == 0 && + kWakeAdvertisement[14] == 0x00 && + kWakeAdvertisement[15] == 0x01 && + kWakeAdvertisement[16] == 0x81 && + memcmp(&kWakeAdvertisement[23], suffix, sizeof(suffix)) == 0; +} +#endif + +void recover_from_failure() { + ++g_failures; + g_pending_opcode = 0; + if (g_phase == Phase::kSetStableAddress || + g_phase == Phase::kVerifyStableAddress) { + // Wake stays disabled, but controller input must remain available. + g_identity_ready = true; + g_phase = Phase::kFailed; + } else if (g_advertising) { + g_phase = Phase::kDisableAdvertising; + } else { + g_phase = Phase::kIdle; + } +} + +void begin_command(uint16_t opcode, uint32_t now_ms) { + g_pending_opcode = opcode; + g_command_deadline_ms = now_ms + kCommandTimeoutMs; +} + +void submit_phase_command(uint32_t now_ms) { + if (g_pending_opcode != 0 || !hci_can_send_command_packet_now()) { + return; + } + + uint8_t result = ERROR_CODE_SUCCESS; + switch (g_phase) { + case Phase::kSetStableAddress: + begin_command(kWritePublicAddress.opcode, now_ms); +#if SWITCH2_WAKE_CONFIGURED + result = hci_send_cmd(&kWritePublicAddress, kWakeAddress); +#endif + break; + case Phase::kVerifyStableAddress: + begin_command(hci_read_bd_addr.opcode, now_ms); + result = hci_send_cmd(&hci_read_bd_addr); + break; + case Phase::kSetParameters: + begin_command(hci_le_set_advertising_parameters.opcode, now_ms); + result = hci_send_cmd( + &hci_le_set_advertising_parameters, + kAdvertisingIntervalUnits, kAdvertisingIntervalUnits, + kAdvertisingTypeNonConnectable, + BD_ADDR_TYPE_LE_PUBLIC, BD_ADDR_TYPE_LE_PUBLIC, + kUnusedPeerAddress, 7, 0); + break; + case Phase::kSetData: + begin_command(hci_le_set_advertising_data.opcode, now_ms); +#if SWITCH2_WAKE_CONFIGURED + result = hci_send_cmd( + &hci_le_set_advertising_data, + static_cast(sizeof(kWakeAdvertisement)), + kWakeAdvertisement); +#endif + break; + case Phase::kEnableAdvertising: + case Phase::kDisableAdvertising: + begin_command(hci_le_set_advertise_enable.opcode, now_ms); + result = hci_send_cmd( + &hci_le_set_advertise_enable, + g_phase == Phase::kEnableAdvertising ? 1 : 0); + break; + default: + return; + } + + if (result != ERROR_CODE_SUCCESS) { + recover_from_failure(); + } +} + +void handle_command_complete(uint8_t* packet, uint16_t size) { + if (size < 6 || g_pending_opcode == 0) { + return; + } + const uint16_t opcode = + hci_event_command_complete_get_command_opcode(packet); + if (opcode != g_pending_opcode) { + return; + } + const uint8_t status = + hci_event_command_complete_get_return_parameters(packet)[0]; + g_pending_opcode = 0; + if (status != ERROR_CODE_SUCCESS) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + + switch (g_phase) { + case Phase::kSetStableAddress: + g_phase = Phase::kVerifyStableAddress; + break; + case Phase::kVerifyStableAddress: +#if SWITCH2_WAKE_CONFIGURED + if (size < 12) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + for (size_t index = 0; index < sizeof(kWakeAddress); ++index) { + if (packet[6 + index] != + kWakeAddress[sizeof(kWakeAddress) - 1 - index]) { + recover_from_failure(); + schedule_for_phase(btstack_run_loop_get_time_ms()); + return; + } + } +#endif + g_identity_ready = true; + g_phase = Phase::kIdle; + break; + case Phase::kSetParameters: + g_phase = Phase::kSetData; + break; + case Phase::kSetData: + g_phase = Phase::kEnableAdvertising; + break; + case Phase::kEnableAdvertising: + g_advertising = true; + g_burst_deadline_ms = + btstack_run_loop_get_time_ms() + kBurstDurationMs; + g_phase = Phase::kAdvertising; + break; + case Phase::kDisableAdvertising: + g_advertising = false; + ++g_completed_bursts; + g_phase = Phase::kIdle; + break; + default: + recover_from_failure(); + break; + } + schedule_for_phase(btstack_run_loop_get_time_ms()); +} + +void handle_hci_event(uint8_t packet_type, uint16_t, + uint8_t* packet, uint16_t size) { + if (packet_type == HCI_EVENT_PACKET && size >= 2 && + hci_event_packet_get_type(packet) == HCI_EVENT_COMMAND_COMPLETE) { + handle_command_complete(packet, size); + } +} + +void task(btstack_timer_source_t*) { + g_timer_armed = false; + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + if (g_pending_opcode != 0 && + deadline_reached(now_ms, g_command_deadline_ms)) { + recover_from_failure(); + } + if (g_phase == Phase::kAdvertising && + deadline_reached(now_ms, g_burst_deadline_ms)) { + g_phase = Phase::kDisableAdvertising; + } + submit_phase_command(now_ms); + schedule_for_phase(now_ms); +} + +} // namespace + +void switch2_wake_initialize() { + if (g_initialized) { + return; + } + g_initialized = true; +#if SWITCH2_WAKE_CONFIGURED + if (!configured_packet_valid()) { + g_identity_ready = true; + g_phase = Phase::kFailed; + ++g_failures; + return; + } + g_configured = true; + g_event_registration.callback = handle_hci_event; + hci_add_event_handler(&g_event_registration); + btstack_run_loop_set_timer_handler(&g_timer, task); + g_phase = Phase::kSetStableAddress; + schedule_task(0); +#else + g_identity_ready = true; +#endif +} + +bool switch2_wake_ready_for_connections() { + return g_identity_ready; +} + +bool switch2_wake_request() { + if (g_phase != Phase::kIdle) { + return false; + } + ++g_accepted_requests; + g_phase = Phase::kSetParameters; + schedule_task(0); + return true; +} + +void switch2_wake_diagnostics(Switch2WakeDiagnostics* out) { + if (out == nullptr) { + return; + } + *out = { + g_configured, + g_phase != Phase::kDisabled && g_phase != Phase::kIdle && + g_phase != Phase::kFailed, + g_accepted_requests, + g_completed_bursts, + g_failures, + }; +} diff --git a/src/firmware/input/switch2_wake.h b/src/firmware/input/switch2_wake.h new file mode 100644 index 0000000..2f32020 --- /dev/null +++ b/src/firmware/input/switch2_wake.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +struct Switch2WakeDiagnostics { + bool configured; + bool busy; + uint32_t accepted_requests; + uint32_t completed_bursts; + uint32_t failures; +}; + +// Installs BTstack callbacks and applies the configured stable public identity. +// Call once from the BTstack core before admitting controller connections. +void switch2_wake_initialize(); +bool switch2_wake_ready_for_connections(); + +// Starts one wake burst when configured and idle. Calls while busy coalesce. +bool switch2_wake_request(); + +void switch2_wake_diagnostics(Switch2WakeDiagnostics* out); diff --git a/src/firmware/input/switch_hd_rumble_envelope.h b/src/firmware/input/switch_hd_rumble_envelope.h new file mode 100644 index 0000000..5bb82b7 --- /dev/null +++ b/src/firmware/input/switch_hd_rumble_envelope.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +// round(65535 * (i / 256)^0.8), i=0..256. Interpolate between entries +// once per envelope segment, never run pow() in the PCM loop. Zero stays zero. +namespace SwitchHdRumbleEnvelope { +constexpr uint16_t kLevel[257] = { + 0, 776, 1351, 1869, 2352, 2812, 3254, 3681, 4096, 4501, 4896, 5284, 5665, 6040, 6409, 6773, + 7131, 7486, 7836, 8182, 8525, 8865, 9201, 9534, 9864, 10191, 10516, 10839, 11159, 11476, 11792, 12105, + 12417, 12726, 13034, 13339, 13643, 13946, 14247, 14546, 14843, 15139, 15434, 15727, 16019, 16310, 16599, 16887, + 17174, 17460, 17744, 18028, 18310, 18591, 18871, 19150, 19428, 19705, 19981, 20256, 20531, 20804, 21076, 21348, + 21618, 21888, 22157, 22425, 22693, 22959, 23225, 23490, 23755, 24018, 24281, 24543, 24805, 25065, 25325, 25585, + 25844, 26102, 26359, 26616, 26872, 27128, 27383, 27637, 27891, 28144, 28397, 28649, 28901, 29152, 29402, 29652, + 29902, 30151, 30399, 30647, 30895, 31141, 31388, 31634, 31879, 32124, 32369, 32613, 32856, 33100, 33342, 33585, + 33826, 34068, 34309, 34549, 34789, 35029, 35268, 35507, 35746, 35984, 36222, 36459, 36696, 36933, 37169, 37405, + 37640, 37875, 38110, 38344, 38578, 38812, 39045, 39278, 39510, 39743, 39975, 40206, 40437, 40668, 40899, 41129, + 41359, 41589, 41818, 42047, 42276, 42504, 42732, 42960, 43187, 43414, 43641, 43868, 44094, 44320, 44546, 44771, + 44996, 45221, 45446, 45670, 45894, 46118, 46341, 46564, 46787, 47010, 47232, 47455, 47676, 47898, 48119, 48341, + 48561, 48782, 49002, 49223, 49442, 49662, 49881, 50101, 50319, 50538, 50757, 50975, 51193, 51410, 51628, 51845, + 52062, 52279, 52496, 52712, 52928, 53144, 53360, 53575, 53790, 54006, 54220, 54435, 54649, 54864, 55078, 55291, + 55505, 55718, 55932, 56145, 56357, 56570, 56782, 56994, 57206, 57418, 57630, 57841, 58052, 58263, 58474, 58685, + 58895, 59105, 59315, 59525, 59735, 59945, 60154, 60363, 60572, 60781, 60989, 61198, 61406, 61614, 61822, 62030, + 62237, 62445, 62652, 62859, 63066, 63272, 63479, 63685, 63891, 64097, 64303, 64509, 64715, 64920, 65125, 65330, + 65535, +}; +} // namespace SwitchHdRumbleEnvelope diff --git a/src/firmware/input/switch_hd_rumble_synth.cpp b/src/firmware/input/switch_hd_rumble_synth.cpp new file mode 100644 index 0000000..0cb6ef0 --- /dev/null +++ b/src/firmware/input/switch_hd_rumble_synth.cpp @@ -0,0 +1,398 @@ +#include "input/switch_hd_rumble_synth.h" +#include "input/switch_hd_rumble_envelope.h" + +#include +#include +#include + +namespace { + +constexpr int64_t kWatchdogSamples = 150; // 50 ms at 3 kHz. +constexpr unsigned kWindowSamples = 24; // 8 ms, independently split per side. + +// round(40 * 2^(index/32) * 2^32 / 3000), index 0..159. High-band +// indices address the same logarithmic table with an offset of 32 (80 Hz base). +constexpr uint32_t kPhaseIncrement[160] = { + 57266231u, 58520198u, 59801623u, 61111108u, 62449267u, 63816728u, 65214133u, 66642136u, + 68101409u, 69592636u, 71116516u, 72673765u, 74265113u, 75891307u, 77553110u, 79251302u, + 80986680u, 82760057u, 84572267u, 86424158u, 88316601u, 90250483u, 92226711u, 94246213u, + 96309936u, 98418849u, 100573941u, 102776224u, 105026730u, 107326516u, 109676661u, 112078267u, + 114532461u, 117040396u, 119603246u, 122222217u, 124898535u, 127633456u, 130428265u, 133284272u, + 136202818u, 139185271u, 142233032u, 145347530u, 148530226u, 151782614u, 155106221u, 158502605u, + 161973360u, 165520115u, 169144533u, 172848316u, 176633202u, 180500965u, 184453421u, 188492425u, + 192619872u, 196837698u, 201147882u, 205552448u, 210053460u, 214653032u, 219353321u, 224156534u, + 229064922u, 234080791u, 239206493u, 244444433u, 249797069u, 255266913u, 260856530u, 266568545u, + 272405636u, 278370542u, 284466063u, 290695059u, 297060452u, 303565229u, 310212442u, 317005210u, + 323946720u, 331040229u, 338289067u, 345696633u, 353266403u, 361001930u, 368906843u, 376984851u, + 385239744u, 393675396u, 402295765u, 411104895u, 420106920u, 429306064u, 438706642u, 448313067u, + 458129845u, 468161582u, 478412986u, 488888866u, 499594138u, 510533826u, 521713061u, 533137089u, + 544811271u, 556741085u, 568932127u, 581390118u, 594120904u, 607130458u, 620424884u, 634010420u, + 647893440u, 662080459u, 676578133u, 691393265u, 706532806u, 722003860u, 737813686u, 753969702u, + 770479489u, 787350793u, 804591530u, 822209790u, 840213840u, 858612128u, 877413285u, 896626134u, + 916259690u, 936323164u, 956825972u, 977777733u, 999188277u, 1021067651u, 1043426121u, 1066274178u, + 1089622542u, 1113482169u, 1137864254u, 1162780236u, 1188241808u, 1214260916u, 1240849767u, 1268020839u, + 1295786880u, 1324160918u, 1353156266u, 1382786530u, 1413065613u, 1444007720u, 1475627372u, 1507939404u, + 1540958977u, 1574701585u, 1609183060u, 1644419580u, 1680427680u, 1717224255u, 1754826570u, 1793252268u, +}; + +// round(127 * 256 * sin(2*pi*index/256)). Linear interpolation retains +// sub-byte precision until the final two-band mix; opposite phases are exact +// negatives, including rounding, so quantization cannot introduce a DC bias. +constexpr int16_t kSine[256] = { + 0, 798, 1595, 2392, 3187, 3980, 4771, 5558, 6343, 7123, 7900, 8671, 9438, 10198, 10953, 11701, + 12442, 13175, 13901, 14618, 15326, 16025, 16715, 17394, 18063, 18721, 19367, 20002, 20625, 21236, 21834, 22418, + 22989, 23547, 24090, 24618, 25132, 25631, 26114, 26581, 27033, 27468, 27886, 28288, 28673, 29041, 29390, 29723, + 30037, 30333, 30611, 30871, 31112, 31334, 31538, 31722, 31887, 32033, 32160, 32267, 32355, 32424, 32473, 32502, + 32512, 32502, 32473, 32424, 32355, 32267, 32160, 32033, 31887, 31722, 31538, 31334, 31112, 30871, 30611, 30333, + 30037, 29723, 29390, 29041, 28673, 28288, 27886, 27468, 27033, 26581, 26114, 25631, 25132, 24618, 24090, 23547, + 22989, 22418, 21834, 21236, 20625, 20002, 19367, 18721, 18063, 17394, 16715, 16025, 15326, 14618, 13901, 13175, + 12442, 11701, 10953, 10198, 9438, 8671, 7900, 7123, 6343, 5558, 4771, 3980, 3187, 2392, 1595, 798, + 0, -798, -1595, -2392, -3187, -3980, -4771, -5558, -6343, -7123, -7900, -8671, -9438, -10198, -10953, -11701, + -12442, -13175, -13901, -14618, -15326, -16025, -16715, -17394, -18063, -18721, -19367, -20002, -20625, -21236, -21834, -22418, + -22989, -23547, -24090, -24618, -25132, -25631, -26114, -26581, -27033, -27468, -27886, -28288, -28673, -29041, -29390, -29723, + -30037, -30333, -30611, -30871, -31112, -31334, -31538, -31722, -31887, -32033, -32160, -32267, -32355, -32424, -32473, -32502, + -32512, -32502, -32473, -32424, -32355, -32267, -32160, -32033, -31887, -31722, -31538, -31334, -31112, -30871, -30611, -30333, + -30037, -29723, -29390, -29041, -28673, -28288, -27886, -27468, -27033, -26581, -26114, -25631, -25132, -24618, -24090, -23547, + -22989, -22418, -21834, -21236, -20625, -20002, -19367, -18721, -18063, -17394, -16715, -16025, -15326, -14618, -13901, -13175, + -12442, -11701, -10953, -10198, -9438, -8671, -7900, -7123, -6343, -5558, -4771, -3980, -3187, -2392, -1595, -798, +}; + +bool due(int64_t sample, uint64_t cursor) { + return sample <= 0 || static_cast(sample) <= cursor; +} + +bool older(uint64_t timestamp, uint64_t previous) { + return timestamp - previous > static_cast(INT64_MAX); +} + +int32_t rounded_shift(int32_t value, unsigned shift) { + const int32_t half = int32_t{1} << (shift - 1); + return value < 0 ? -((-value + half) >> shift) + : (value + half) >> shift; +} + +int32_t sine(uint32_t phase) { + const unsigned index = phase >> 24; + const int32_t first = kSine[index]; + const int32_t difference = kSine[(index + 1) & 255u] - first; + const int32_t fraction = static_cast((phase >> 8) & 65535u); + return first + rounded_shift(difference * fraction, 16); +} + +void apply_host_gain(uint16_t& low, uint16_t& high) { + if ((low | high) == 0) return; + const uint32_t weighted_low = uint32_t{low} * 2; + const uint32_t weighted_high = uint32_t{high} * 2; + const uint32_t total = weighted_low + weighted_high; + uint32_t target = 65535u; + if (total < 65535u) { + // A gentle 0.8-power curve lifts quiet/mid-level effects. Apply it + // jointly so band balance is unchanged, not separately to each voice. + const uint32_t index = total >> 8; + const uint32_t fraction = total & 255u; + const uint32_t first = SwitchHdRumbleEnvelope::kLevel[index]; + const uint32_t difference = SwitchHdRumbleEnvelope::kLevel[index + 1] - first; + target = first + ((difference * fraction + 128u) >> 8); + } + // Product <= 65536*65535 fits uint32; floor rounding keeps the combined + // weights <= 65535 and each weight representable in uint16. No clipping. + low = static_cast(weighted_low * target / total); + high = static_cast(weighted_high * target / total); +} + +uint8_t mix(uint32_t low_phase, uint32_t high_phase, + uint16_t low, uint16_t high) { + const int32_t sum = (low ? sine(low_phase) * low : 0) + + (high ? sine(high_phase) * high : 0); + // Q8 sine times band weights with a combined ceiling of 65536. The + // 24-bit shift maps that ceiling to +/-127 without int32 overflow. + return static_cast(rounded_shift(sum, 24)); +} + +} // namespace + +void SwitchHdRumbleSynth::reset(uint64_t epoch_us) { + epoch_us_ = epoch_us; + cursor_ = 0; + last_host_us_ = last_feedback_us_ = 0; + have_host_ = have_feedback_ = false; + head_ = count_ = 0; + dropped_updates_ = 0; + for (unsigned side = 0; side < 2; ++side) { + sides_[side] = Side{}; + phase_[side][0] = phase_[side][1] = 0; + } + feedback_expires_ = 0; + feedback_low_ = feedback_high_ = 0; +} + +void SwitchHdRumbleSynth::count_drop() { + if (dropped_updates_ != UINT32_MAX) { + ++dropped_updates_; + } +} + +bool SwitchHdRumbleSynth::timestamp_sample(uint64_t timestamp_us, + int64_t& sample) const { + const uint64_t elapsed = timestamp_us - epoch_us_; + if (elapsed <= static_cast(INT64_MAX)) { + // ceil(elapsed * 3 / 1000), without overflowing an intermediate. + sample = static_cast((elapsed / 1000) * 3 + + ((elapsed % 1000) * 3 + 999) / 1000); + return true; + } + const uint64_t before = epoch_us_ - timestamp_us; + if (before > static_cast(INT64_MAX)) { + return false; // Exactly half the clock range has ambiguous ordering. + } + sample = -static_cast((before / 1000) * 3 + + (before % 1000) * 3 / 1000); + return true; +} + +bool SwitchHdRumbleSynth::push(const SwitchHapticsFrame& frame, + uint64_t received_us) { + Command command; + if ((have_host_ && older(received_us, last_host_us_)) || + !timestamp_sample(received_us, command.sample) || + command.sample + kWatchdogSamples <= 0) { + count_drop(); + return false; + } + bool has_update = false; + for (const auto& actuator : frame.actuators) { + if (actuator.sample_count > 3) { + count_drop(); + return false; + } + has_update |= actuator.sample_count != 0; + for (unsigned index = 0; index < actuator.sample_count; ++index) { + const auto& sample = actuator.samples[index]; + if (sample.low_frequency_index > 127 || + sample.high_frequency_index > 127 || + sample.low_amplitude_q15 > 32768 || + sample.high_amplitude_q15 > 32768) { + count_drop(); + return false; + } + } + } + have_host_ = true; + last_host_us_ = received_us; + if (has_update) { + command.frame = frame; + enqueue(command); + } + return true; +} + +bool SwitchHdRumbleSynth::push_rumble(uint8_t low_magnitude, + uint8_t high_magnitude, + uint64_t received_us) { + Command command; + if ((have_host_ && older(received_us, last_host_us_)) || + !timestamp_sample(received_us, command.sample)) { + count_drop(); + return false; + } + have_host_ = true; + last_host_us_ = received_us; + command.persistent = true; + command.frame.actuators[0].sample_count = 1; + command.frame.actuators[1].sample_count = 1; + command.frame.actuators[0].samples[0].low_amplitude_q15 = + (static_cast(low_magnitude) * 32768u + 127u) / 255u; + command.frame.actuators[1].samples[0].high_amplitude_q15 = + (static_cast(high_magnitude) * 32768u + 127u) / 255u; + enqueue(command); + return true; +} + +void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low_magnitude, + uint8_t high_magnitude) { + Command command; + if ((have_feedback_ && older(at_us, last_feedback_us_)) || + !timestamp_sample(at_us, command.sample) || + !timestamp_sample(at_us + duration_us, command.expires)) { + count_drop(); + return; + } + have_feedback_ = true; + last_feedback_us_ = at_us; + command.is_feedback = true; + command.low = (static_cast(low_magnitude) * 32768u + 127u) / 255u; + command.high = (static_cast(high_magnitude) * 32768u + 127u) / 255u; + enqueue(command); +} + +void SwitchHdRumbleSynth::enqueue(const Command& command) { + if (count_ == kCapacity) { + const int64_t oldest_sample = commands_[head_].sample; + const int64_t watermark = command.sample < oldest_sample + ? command.sample : oldest_sample; + advance_to(watermark > 0 ? static_cast(watermark) : 0, true); + if (count_ == kCapacity) { + // An older command from the other producer is itself the oldest. + // Fold it directly into the baseline without losing either side. + apply(command); + count_drop(); + return; + } + } + unsigned position = count_; + while (position && commands_[(head_ + position - 1) % kCapacity].sample > + command.sample) { + commands_[(head_ + position) % kCapacity] = + commands_[(head_ + position - 1) % kCapacity]; + --position; + } + commands_[(head_ + position) % kCapacity] = command; + ++count_; +} + +void SwitchHdRumbleSynth::apply(const Command& command) { + if (command.is_feedback) { + feedback_expires_ = command.expires; + feedback_low_ = command.low; + feedback_high_ = command.high; + return; + } + for (unsigned side = 0; side < 2; ++side) { + if (command.frame.actuators[side].sample_count) { + sides_[side].frame = command.frame.actuators[side]; + sides_[side].start = command.sample; + sides_[side].expires = command.sample + kWatchdogSamples; + sides_[side].persistent = command.persistent; + } + } +} + +void SwitchHdRumbleSynth::apply_due(bool discarded) { + while (count_ && due(commands_[head_].sample, cursor_)) { + apply(commands_[head_]); + head_ = (head_ + 1) % kCapacity; + --count_; + if (discarded) { + count_drop(); + } + } +} + +const SwitchHapticsSample& SwitchHdRumbleSynth::host_sample(unsigned side) const { + const Side& state = sides_[side]; + const unsigned spacing = kWindowSamples / state.frame.sample_count; + unsigned index = 0; + while (index + 1 < state.frame.sample_count && + due(state.start + (index + 1) * spacing, cursor_)) { + ++index; + } + return state.frame.samples[index]; +} + +uint64_t SwitchHdRumbleSynth::next_boundary(uint64_t limit) const { + const auto consider = [this, &limit](int64_t boundary) { + if (!due(boundary, cursor_) && static_cast(boundary) < limit) { + limit = static_cast(boundary); + } + }; + if (count_) { + consider(commands_[head_].sample); + } + for (const Side& side : sides_) { + const unsigned spacing = kWindowSamples / side.frame.sample_count; + for (unsigned index = 1; index < side.frame.sample_count; ++index) { + consider(side.start + index * spacing); + } + if (!side.persistent) consider(side.expires); + } + consider(feedback_expires_); + return limit; +} + +void SwitchHdRumbleSynth::advance_phases(uint64_t samples) { + // Multiplication modulo 2^32 skips arbitrarily large intervals in O(1). + const uint32_t count = static_cast(samples); + for (unsigned side = 0; side < 2; ++side) { + const auto& sample = host_sample(side); + phase_[side][0] += kPhaseIncrement[sample.low_frequency_index] * count; + phase_[side][1] += kPhaseIncrement[sample.high_frequency_index + 32] * count; + } +} + +void SwitchHdRumbleSynth::advance_to(uint64_t sample, bool discarded) { + apply_due(discarded); + while (cursor_ < sample) { + const uint64_t boundary = next_boundary(sample); + advance_phases(boundary - cursor_); + cursor_ = boundary; + apply_due(discarded); + } +} + +void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames, + uint8_t* interleaved_stereo) { + if (!frames) { + advance_to(first_sample); + return; + } + if (!interleaved_stereo) { + return; + } + if (first_sample > UINT64_MAX - frames) { + memset(interleaved_stereo, 0, static_cast(frames) * 2); + return; + } + if (first_sample < cursor_) { + const uint64_t consumed = cursor_ - first_sample; + const uint32_t silence = consumed < frames + ? static_cast(consumed) : frames; + memset(interleaved_stereo, 0, static_cast(silence) * 2); + interleaved_stereo += static_cast(silence) * 2; + first_sample += silence; + frames -= silence; + } + advance_to(first_sample); + const uint64_t end = first_sample + frames; + while (cursor_ < end) { + const uint64_t boundary = next_boundary(end); + uint32_t increment[2][2]; + uint16_t amplitude[2][2]; + const bool overlay = !due(feedback_expires_, cursor_) && + (feedback_low_ || feedback_high_); + for (unsigned side = 0; side < 2; ++side) { + const auto& sample = host_sample(side); + increment[side][0] = kPhaseIncrement[sample.low_frequency_index]; + increment[side][1] = kPhaseIncrement[sample.high_frequency_index + 32]; + const bool expired = !sides_[side].persistent && + due(sides_[side].expires, cursor_); + amplitude[side][0] = expired ? 0 : sample.low_amplitude_q15; + amplitude[side][1] = expired ? 0 : sample.high_amplitude_q15; + if (!overlay) apply_host_gain(amplitude[side][0], amplitude[side][1]); + } + uint32_t feedback_low_phase = kPhaseIncrement[64] * + static_cast(cursor_); + uint32_t feedback_high_phase = kPhaseIncrement[96] * + static_cast(cursor_); + for (; cursor_ < boundary; ++cursor_) { + if (overlay) { + const uint8_t value = mix(feedback_low_phase, feedback_high_phase, + feedback_low_, feedback_high_); + *interleaved_stereo++ = value; + *interleaved_stereo++ = value; + feedback_low_phase += kPhaseIncrement[64]; + feedback_high_phase += kPhaseIncrement[96]; + } else { + for (unsigned side = 0; side < 2; ++side) { + *interleaved_stereo++ = mix(phase_[side][0], phase_[side][1], + amplitude[side][0], amplitude[side][1]); + } + } + for (unsigned side = 0; side < 2; ++side) { + phase_[side][0] += increment[side][0]; + phase_[side][1] += increment[side][1]; + } + } + apply_due(); + } +} diff --git a/src/firmware/input/switch_hd_rumble_synth.h b/src/firmware/input/switch_hd_rumble_synth.h new file mode 100644 index 0000000..369f3fd --- /dev/null +++ b/src/firmware/input/switch_hd_rumble_synth.h @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include "usb/switch/switch_haptics.h" + +// Single-core, allocation-free 3 kHz stereo PCM timeline. All times use the same +// 64-bit microsecond clock; unsigned clock rollover is supported for intervals +// shorter than 2^63 us. reset() establishes sample zero and zero oscillator phase. +// Host PCM gets balanced 2x gain and a joint 0.8-power amplitude curve after +// profile scaling, bounded to mixer headroom. Feedback gain remains unchanged. +class SwitchHdRumbleSynth { +public: + void reset(uint64_t epoch_us); + + // Zero-count sides are untouched (including their independent watchdog). + // Updated sides resume the 50 ms watchdog even after persistent rumble. + // Duplicate timestamps are accepted in call order; the last update wins. + // Out-of-order or malformed batches are rejected and counted. Pre-epoch + // batches retain their original substep position and expiry; already + // expired pre-epoch batches are rejected. Late ordered batches take effect + // at the render cursor's current substep, never replaying earlier substeps + // or extending their original expiry. + bool push(const SwitchHapticsFrame& frame, uint64_t received_us); + + // Profile-scaled conventional host rumble: low drives left at 160 Hz and + // high drives right at 320 Hz, with the opposite bands silent. Both sides + // persist until replaced or reset; zero stops both. Pre-epoch commands do + // not expire. Host timestamp ordering is shared with push(), including + // duplicate last-wins and late-command phase/history behavior. + bool push_rumble(uint8_t low_magnitude, uint8_t high_magnitude, + uint64_t received_us); + + // Signed int8 PCM encoded in bytes, left then right. Calls normally advance + // monotonically; forward gaps advance phase analytically, not sample by + // sample. Already consumed samples are returned as silence, never replayed. + void render(uint64_t first_sample, uint32_t frames, + uint8_t* interleaved_stereo); + + // Timestamped conventional override on both sides, at 160/320 Hz. Zero + // duration or two zero magnitudes cancels only the override at at_us. + // Host oscillators and updates continue underneath it; expiry reveals the + // current host state. Override phases also free-run from the stream epoch. + // Feedback timestamps are chronological independently of both host APIs. + void feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low_magnitude, uint8_t high_magnitude); + + uint32_t dropped_updates() const { return dropped_updates_; } + +private: + static constexpr uint8_t kCapacity = 16; + + struct Command { + int64_t sample = 0; + int64_t expires = 0; + SwitchHapticsFrame frame{}; + uint16_t low = 0; + uint16_t high = 0; + bool is_feedback = false; + bool persistent = false; + }; + + struct Side { + SwitchHapticsActuatorFrame frame{1, {}}; + bool persistent = false; + int64_t start = 0; + int64_t expires = 0; + }; + + // A fixed ring shared by host and feedback commands, ordered by sample. + // Overflow consumes the oldest command analytically into the live baseline, + // advancing a discard watermark. Unrendered PCM before that watermark is + // silence. Thus missing history cannot replay later, and partial-side state + // and oscillator phase survive eviction without an additional history ring. + void enqueue(const Command& command); + void apply(const Command& command); + void apply_due(bool discarded = false); + void advance_to(uint64_t sample, bool discarded = false); + void advance_phases(uint64_t samples); + uint64_t next_boundary(uint64_t limit) const; + const SwitchHapticsSample& host_sample(unsigned side) const; + bool timestamp_sample(uint64_t timestamp_us, int64_t& sample) const; + void count_drop(); + + uint64_t epoch_us_ = 0; + uint64_t cursor_ = 0; + uint64_t last_host_us_ = 0; + uint64_t last_feedback_us_ = 0; + bool have_host_ = false; + bool have_feedback_ = false; + uint8_t head_ = 0; + uint8_t count_ = 0; + uint32_t dropped_updates_ = 0; + Command commands_[kCapacity]{}; + Side sides_[2]{}; + uint32_t phase_[2][2]{}; + int64_t feedback_expires_ = 0; + uint16_t feedback_low_ = 0; + uint16_t feedback_high_ = 0; +}; diff --git a/src/firmware/main.cpp b/src/firmware/main.cpp index bd3a2af..619f1eb 100644 --- a/src/firmware/main.cpp +++ b/src/firmware/main.cpp @@ -10,6 +10,7 @@ #include "adapter/adapter_mode_controller.h" #include "input/bluepad32_input_backend.h" #include "platform/pico/bootsel_pairing_button.h" +#include "platform/pico/system_clock.h" #include "profile/controller_profile_runtime.h" #endif @@ -210,6 +211,9 @@ static void log_usb_state() { } int main() { +#ifdef SWITCH_PICO_BLUEPAD32 + system_clock_initialize(); +#endif board_init(); stdio_init_all(); diff --git a/src/firmware/platform/pico/cyw43_packet_transport.c b/src/firmware/platform/pico/cyw43_packet_transport.c new file mode 100644 index 0000000..d52a06a --- /dev/null +++ b/src/firmware/platform/pico/cyw43_packet_transport.c @@ -0,0 +1,182 @@ +/* + * Opt-in GNU --wrap=cyw43_btbus_read / --wrap=cyw43_btbus_init transport. + * Requires the build-local cybt_shared_bus_driver.c status-propagation patch; + * the stock driver's memory-read helpers discard SPI errors. + */ +#include +#include +#include + +#include "cyw43_btbus.h" +#include "cyw43_config.h" +#include "cybt_shared_bus_driver.h" + +#define PACKET_HEADER_SIZE 4u +#define RING_MASK (BTSDIO_FWBUF_SIZE - 1u) +#define RING_CAPACITY (BTSDIO_FWBUF_SIZE - 4u) +#define WAKE_RETRIES 300u + +/* + * Preserve cybt_hci_read's overflow check, not its cached indices. This lower + * bound is reset with every controller initialization, under the driver lock. + * All actual indices and packet availability are fetched afresh on each call. + */ +static uint32_t minimum_available; + +int __real_cyw43_btbus_init(cyw43_ll_t *self); + +int __wrap_cyw43_btbus_init(cyw43_ll_t *self) { + CYW43_THREAD_ENTER + minimum_available = 0; + int result = __real_cyw43_btbus_init(self); + CYW43_THREAD_EXIT + return result; +} + +static cybt_result_t request_bus(void) { + cybt_result_t result = cybt_set_bt_awake(true); + if (result != CYBT_SUCCESS) { + return result; + } + /* Match the SDK's bounded 301 polls and 1 ms wake-handshake delay. */ + for (uint32_t remaining = WAKE_RETRIES;; --remaining) { + int awake = cybt_awake(); + if (awake == 1) { + return CYBT_SUCCESS; + } + if (awake != 0) { + return CYBT_ERR_HCI_READ_FAILED; + } + cyw43_delay_ms(1); + if (remaining == 0) { + return CYBT_ERR_TIMEOUT; + } + } +} + +/* All offsets, lengths and destinations here are word aligned. */ +static cybt_result_t read_ring(uint32_t offset, uint8_t *buffer, uint32_t length) { + if (length == 0) { + return CYBT_SUCCESS; + } + uint32_t first = BTSDIO_FWBUF_SIZE - offset; + if (first > length) { + first = length; + } + cybt_result_t result = cybt_mem_read_idx(B2H_BUF_ADDR_IDX, offset, buffer, first); + if (result != CYBT_SUCCESS || first == length) { + return result; + } + return cybt_mem_read_idx(B2H_BUF_ADDR_IDX, 0, buffer + first, length - first); +} + +static cybt_result_t read_packet(uint8_t *buffer, uint32_t capacity, uint32_t *size) { + cybt_fw_membuf_index_t indices; + cybt_result_t result = cybt_get_bt_buf_index(&indices); + if (result != CYBT_SUCCESS) { + return result; + } + /* Keep the SDK's four-register corruption checks effective in release. */ + if (indices.host2bt_in_val >= BTSDIO_FWBUF_SIZE || + indices.host2bt_out_val >= BTSDIO_FWBUF_SIZE || + indices.bt2host_in_val >= BTSDIO_FWBUF_SIZE || + indices.bt2host_out_val >= BTSDIO_FWBUF_SIZE || + (indices.bt2host_out_val & 3u) != 0) { + return CYBT_ERR_HCI_READ_FAILED; + } + uint32_t available = (indices.bt2host_in_val - indices.bt2host_out_val) & RING_MASK; + if (available > RING_CAPACITY) { + return CYBT_ERR_HCI_READ_FAILED; + } + if (available < minimum_available) { + panic("cyw43 buffer overflow"); + } + minimum_available = available; + if (available < PACKET_HEADER_SIZE) { + /* No partial-header consumption and no polling for more packet data. */ + return CYBT_SUCCESS; + } + + result = read_ring(indices.bt2host_out_val, buffer, PACKET_HEADER_SIZE); + if (result != CYBT_SUCCESS) { + return result; + } + uint32_t payload_length = (uint32_t)buffer[0] | + ((uint32_t)buffer[1] << 8) | + ((uint32_t)buffer[2] << 16); + if (payload_length > capacity - PACKET_HEADER_SIZE || + payload_length > RING_CAPACITY - PACKET_HEADER_SIZE) { + return CYBT_ERR_BADARG; + } + uint32_t payload_wire_length = (payload_length + 3u) & ~3u; + uint32_t wire_length = PACKET_HEADER_SIZE + payload_wire_length; + if (wire_length > available) { + /* The producer may finish later; retain both header and payload. */ + return CYBT_SUCCESS; + } + + uint32_t payload_offset = (indices.bt2host_out_val + PACKET_HEADER_SIZE) & RING_MASK; + if (wire_length <= capacity) { + result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, payload_wire_length); + } else { + /* A valid unpadded capacity must not permit a 1-3 byte buffer overrun. */ + uint32_t whole_length = payload_length & ~3u; + result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, whole_length); + if (result == CYBT_SUCCESS) { + uint32_t last_word; + result = read_ring((payload_offset + whole_length) & RING_MASK, + (uint8_t *)&last_word, sizeof(last_word)); + if (result == CYBT_SUCCESS) { + memcpy(buffer + PACKET_HEADER_SIZE + whole_length, &last_word, + payload_length - whole_length); + } + } + } + if (result != CYBT_SUCCESS) { + return result; + } + + /* + * The producer publishes only readable bytes and may not overwrite unread + * bytes (the same ring ownership rule as the SDK). One availability snapshot + * therefore covers header, payload and padding until this commit point. + */ + uint32_t next = (indices.bt2host_out_val + wire_length) & RING_MASK; + result = cybt_reg_write_idx(B2H_BUF_OUT_ADDR_IDX, next); + if (result != CYBT_SUCCESS) { + return result; + } + minimum_available = available - wire_length; + /* + * DATA_VALID signals changed host ring state, not an RX IRQ acknowledgement: + * cyw43_ll_bt_has_work separately clears SDIO_INT_STATUS/I_HMB_FC_CHANGE. + * Empty/partial reads change no ring state and need no DATA_VALID toggle. + * The SDK's notification helper always succeeds (its register write is void). + * If that contract changes, a failure here cannot safely roll back the + * consumer: the controller already owns the reclaimed bytes. + */ + result = cybt_toggle_bt_intr(); + if (result != CYBT_SUCCESS) { + return result; + } + *size = PACKET_HEADER_SIZE + payload_length; + return CYBT_SUCCESS; +} + +int __wrap_cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size) { + if (size == NULL) { + return -1; + } + *size = 0; + if (buffer == NULL || capacity < PACKET_HEADER_SIZE || ((uintptr_t)buffer & 3u) != 0) { + return -1; + } + + CYW43_THREAD_ENTER + cybt_result_t result = request_bus(); + if (result == CYBT_SUCCESS) { + result = read_packet(buffer, capacity, size); + } + CYW43_THREAD_EXIT + return result == CYBT_SUCCESS ? 0 : -1; +} diff --git a/src/firmware/platform/pico/pico_profile_storage.cpp b/src/firmware/platform/pico/pico_profile_storage.cpp index 196052e..4654b55 100644 --- a/src/firmware/platform/pico/pico_profile_storage.cpp +++ b/src/firmware/platform/pico/pico_profile_storage.cpp @@ -1,4 +1,5 @@ #include "platform/pico/pico_profile_storage.h" + #include #include "configuration/configuration_storage.h" @@ -19,132 +20,105 @@ constexpr uint32_t kProfileStorageOffset = kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000; -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(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE); +static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE); static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= - kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE, - "profile storage offset underflows flash"); + kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE); static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <= - kConfigurationStorageOffset, - "profile storage overlaps adapter configuration storage"); + kConfigurationStorageOffset); 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"); + PICO_FLASH_BANK_STORAGE_OFFSET); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + PICO_FLASH_BANK_TOTAL_SIZE <= + PICO_FLASH_SIZE_BYTES); -struct FlashBankReplacement { - uint8_t bank; - const uint8_t* payload; - size_t payload_size; - const uint8_t* header; - bool replaced; +struct EraseOperation { + uint32_t offset; }; -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; +struct ProgramOperation { + uint32_t offset; + const uint8_t *page; +}; - for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE; - offset += FLASH_SECTOR_SIZE) { - flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE); - } +void perform_erase(void *context) { + const auto *operation = static_cast(context); + flash_range_erase(operation->offset, PROFILE_STORAGE_ARENA_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; +void perform_program(void *context) { + const auto *operation = static_cast(context); + flash_range_program(operation->offset, operation->page, + PROFILE_STORAGE_PAGE_SIZE); } bool storage_region_available() { - const uintptr_t binary_end = - reinterpret_cast(&__flash_binary_end) - XIP_BASE; - return binary_end <= kProfileStorageOffset; + 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, +bool read_storage(void *, uint8_t arena, 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; + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset || + !storage_region_available()) { + return false; + } + const uintptr_t address = XIP_BASE + kProfileStorageOffset + + arena * PROFILE_STORAGE_ARENA_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; +bool erase_arena(void *, uint8_t arena) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || !storage_region_available()) { + return false; + } + EraseOperation operation{ + kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE, + }; + if (flash_safe_execute(perform_erase, &operation, + kFlashSafeExecuteTimeoutMs) != PICO_OK) { + return false; + } + const auto *stored = + reinterpret_cast(XIP_BASE + operation.offset); + for (size_t offset = 0; offset < PROFILE_STORAGE_ARENA_SIZE; ++offset) { + if (stored[offset] != 0xff) { + return false; } - FlashBankReplacement replacement{ - bank, - payload, - payload_size, - header, - false, - }; - return flash_safe_execute( - perform_flash_bank_replacement, &replacement, - kFlashSafeExecuteTimeoutMs) == PICO_OK && - replacement.replaced; + } + return true; } -} // namespace +bool program_page(void *, uint8_t arena, size_t offset, const uint8_t *page, + size_t size) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset || + !storage_region_available()) { + return false; + } + ProgramOperation operation{ + kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset, + page, + }; + return flash_safe_execute(perform_program, &operation, + kFlashSafeExecuteTimeoutMs) == PICO_OK && + memcmp(reinterpret_cast(XIP_BASE + operation.offset), + page, size) == 0; +} + +} // namespace ProfileStorageIo pico_profile_storage_io() { - return { - nullptr, - PROFILE_STORAGE_BANK_SIZE, - FLASH_SECTOR_SIZE, - FLASH_PAGE_SIZE, - read_storage, - replace_storage_bank, - }; + return { + nullptr, PROFILE_STORAGE_ARENA_SIZE, + FLASH_SECTOR_SIZE, FLASH_PAGE_SIZE, + read_storage, erase_arena, + program_page, + }; } diff --git a/src/firmware/platform/pico/system_clock.cpp b/src/firmware/platform/pico/system_clock.cpp new file mode 100644 index 0000000..6c9dce6 --- /dev/null +++ b/src/firmware/platform/pico/system_clock.cpp @@ -0,0 +1,73 @@ +#include "platform/pico/system_clock.h" + +#include "hardware/adc.h" +#include "hardware/clocks.h" +#include "hardware/powman.h" +#include "hardware/regs/clocks.h" +#include "hardware/regs/qmi.h" +#include "hardware/structs/qmi.h" +#include "hardware/vreg.h" +#include "pico/stdlib.h" + +namespace { +SystemClockStatus g_status{}; +} + +void system_clock_initialize() { + static_assert(SWITCH_PICO_SYS_CLOCK_MHZ == 150 || + SWITCH_PICO_SYS_CLOCK_MHZ == 300 || + SWITCH_PICO_SYS_CLOCK_MHZ == 400); + // Flash timing was established by boot stage 2. Do not raise clk_sys if + // another boot configuration failed to provide the required divider. + const uint32_t flash_divider = + (qmi_hw->m[0].timing & QMI_M0_TIMING_CLKDIV_BITS) >> + QMI_M0_TIMING_CLKDIV_LSB; + if (flash_divider < (SWITCH_PICO_SYS_CLOCK_MHZ + 74u) / 75u) { + panic("unsafe overclock flash divider"); + } +#if SWITCH_PICO_SYS_CLOCK_MHZ == 150 + constexpr auto voltage = VREG_VOLTAGE_1_10; +#elif SWITCH_PICO_OVERCLOCK_MV == 1400 + constexpr auto voltage = VREG_VOLTAGE_1_40; + vreg_disable_voltage_limit(); +#else + constexpr auto voltage = VREG_VOLTAGE_1_30; +#endif + if (vreg_get_voltage() != voltage) { + vreg_set_voltage(voltage); + sleep_us(1000); + } +#if SWITCH_PICO_SYS_CLOCK_MHZ != 400 || SWITCH_PICO_OVERCLOCK_MV != 1400 + // A warm reboot from the explicit 1.4 V image must not leave its lifted + // voltage limit behind. Lower voltage before reinstating the limit. + powman_clear_bits(&powman_hw->vreg_ctrl, + POWMAN_VREG_CTRL_DISABLE_VOLTAGE_LIMIT_BITS); +#endif +#if SWITCH_PICO_SYS_CLOCK_MHZ != 150 + set_sys_clock_khz(SWITCH_PICO_SYS_CLOCK_MHZ * 1000u, true); +#endif + g_status.requested_sys_khz = SWITCH_PICO_SYS_CLOCK_MHZ * 1000u; + g_status.measured_sys_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS); + g_status.measured_usb_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_USB); + // Regulator setting, not an externally calibrated voltage measurement. + g_status.core_voltage_mv = 550u + 50u * vreg_get_voltage(); + g_status.flash_clock_divider = flash_divider; + g_status.cyw43_pio_divider256 = + CYW43_PIO_CLOCK_DIV_INT * 256u + CYW43_PIO_CLOCK_DIV_FRAC8; + adc_init(); + adc_set_temp_sensor_enabled(true); + adc_select_input(4); +} + +SystemClockStatus system_clock_status() { + uint32_t sum = 0; + for (unsigned sample = 0; sample < 8; ++sample) sum += adc_read(); + // Datasheet nominal transfer: 27 C - (Vadc - 0.706 V) / 0.001721. + // The 3.3 V reference and sensor offset make this an estimate, not a + // calibrated thermal qualification. Use signed integer microvolts. + const int32_t microvolts = static_cast( + (uint64_t{sum} * 3300000u + 16384u) / 32768u); + g_status.temperature_millicelsius = + 27000 - static_cast((int64_t{microvolts - 706000} * 1000) / 1721); + return g_status; +} diff --git a/src/firmware/platform/pico/system_clock.h b/src/firmware/platform/pico/system_clock.h new file mode 100644 index 0000000..749c936 --- /dev/null +++ b/src/firmware/platform/pico/system_clock.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +struct SystemClockStatus { + uint32_t requested_sys_khz; + uint32_t measured_sys_khz; + uint32_t measured_usb_khz; + uint32_t core_voltage_mv; + uint32_t flash_clock_divider; + uint32_t cyw43_pio_divider256; + int32_t temperature_millicelsius; +}; + +// Core 0, before board/peripheral initialization and before launching core 1. +void system_clock_initialize(); +// Core 0 only; reads the dedicated on-chip temperature ADC channel. +SystemClockStatus system_clock_status(); diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index 304ba84..df14959 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -17,6 +17,8 @@ constexpr uint8_t kMacroOverrideMask = kControllerProfileOverrideLeftTrigger | kControllerProfileOverrideRightTrigger; constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000; +constexpr uint32_t kLogicalControlMask = + (1u << CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) - 1u; uint16_t profile_read_u16(const uint8_t* input) { return static_cast(input[0]) | @@ -50,18 +52,21 @@ bool valid_button(uint8_t button) { 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 || +bool valid_control_output(uint8_t output) { + return output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + output == CONTROLLER_PROFILE_NO_BUTTON; +} + +bool valid_turbo_settings(const ControllerProfileTurboSettings& settings) { + return settings.rate_hz >= CONTROLLER_PROFILE_TURBO_RATE_MIN && + settings.rate_hz <= CONTROLLER_PROFILE_TURBO_RATE_MAX && + settings.duty_percent >= CONTROLLER_PROFILE_TURBO_DUTY_MIN && + settings.duty_percent <= CONTROLLER_PROFILE_TURBO_DUTY_MAX && + settings.burst_count >= CONTROLLER_PROFILE_TURBO_BURST_MIN; +} + +bool valid_macro_step(const ControllerProfileMacroStep& step) { + if ((step.override_flags & ~kMacroOverrideMask) != 0 || step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) { return false; } @@ -88,6 +93,112 @@ bool valid_macro_step(const ControllerProfileMacroStep& step, return true; } +size_t sparse_macro_step_size(const ControllerProfileMacroStep& step) { + size_t size = 3; + size += (step.override_flags & kControllerProfileOverrideButtons) != 0 + ? 2 + : 0; + size += (step.override_flags & kControllerProfileOverrideLeftStick) != 0 + ? 4 + : 0; + size += (step.override_flags & kControllerProfileOverrideRightStick) != 0 + ? 4 + : 0; + size += (step.override_flags & kControllerProfileOverrideLeftTrigger) != 0 + ? 2 + : 0; + size += (step.override_flags & kControllerProfileOverrideRightTrigger) != 0 + ? 2 + : 0; + return size; +} + +bool encode_sparse_macro_step( + const ControllerProfileMacroStep& step, uint8_t* output, + size_t output_size, size_t* encoded_size) { + const size_t required = sparse_macro_step_size(step); + if (!valid_macro_step(step) || output == nullptr || + encoded_size == nullptr || required > output_size) { + return false; + } + size_t offset = 0; + output[offset++] = step.override_flags; + profile_write_u16(&output[offset], step.duration_ms); + offset += 2; + if ((step.override_flags & kControllerProfileOverrideButtons) != 0) { + profile_write_u16(&output[offset], step.output_button_mask); + offset += 2; + } + if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) { + profile_write_i16(&output[offset], step.left_stick_x); + profile_write_i16(&output[offset + 2], step.left_stick_y); + offset += 4; + } + if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) { + profile_write_i16(&output[offset], step.right_stick_x); + profile_write_i16(&output[offset + 2], step.right_stick_y); + offset += 4; + } + if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) { + profile_write_u16(&output[offset], step.left_trigger); + offset += 2; + } + if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) { + profile_write_u16(&output[offset], step.right_trigger); + offset += 2; + } + *encoded_size = offset; + return true; +} + +bool decode_sparse_macro_step( + const uint8_t* input, size_t input_size, + ControllerProfileMacroStep* output, size_t* decoded_size) { + if (input == nullptr || output == nullptr || decoded_size == nullptr || + input_size < 3) { + return false; + } + ControllerProfileMacroStep step{}; + step.override_flags = input[0]; + step.duration_ms = profile_read_u16(&input[1]); + if (!valid_macro_step(step)) { + return false; + } + const size_t required = sparse_macro_step_size(step); + if (required > input_size) { + return false; + } + size_t offset = 3; + if ((step.override_flags & kControllerProfileOverrideButtons) != 0) { + step.output_button_mask = profile_read_u16(&input[offset]); + offset += 2; + } + if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) { + step.left_stick_x = profile_read_i16(&input[offset]); + step.left_stick_y = profile_read_i16(&input[offset + 2]); + offset += 4; + } + if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) { + step.right_stick_x = profile_read_i16(&input[offset]); + step.right_stick_y = profile_read_i16(&input[offset + 2]); + offset += 4; + } + if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) { + step.left_trigger = profile_read_u16(&input[offset]); + offset += 2; + } + if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) { + step.right_trigger = profile_read_u16(&input[offset]); + offset += 2; + } + if (!valid_macro_step(step)) { + return false; + } + *output = step; + *decoded_size = offset; + return true; +} + void copy_overlap(size_t range_offset, uint8_t* output, size_t output_size, size_t field_offset, const uint8_t* field, size_t field_size) { @@ -140,66 +251,167 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity, stick.invert_x = false; stick.invert_y = false; } - for (ControllerProfileTriggerConfiguration& trigger : profile.triggers) { + for (uint8_t index = 0; index < 2; ++index) { + ControllerProfileTriggerConfiguration& trigger = + profile.triggers[index]; trigger.lower_deadzone = 0; trigger.upper_saturation = UINT16_MAX; trigger.curve_q8_8 = 256; trigger.digital_threshold = CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + trigger.output = static_cast( + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index); } 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; + profile.motion_toggle_chord = 0; + for (ControllerProfileMacro& macro : profile.macros) { + macro = {}; + macro.cancel_control = CONTROLLER_PROFILE_NO_BUTTON; + } + profile.macro_step_count = 0; for (ControllerProfileMacroStep& step : profile.macro_steps) { step = {}; - step.type = ControllerProfileMacroStepType::kEnd; } return profile; } bool controller_profile_validate(const ControllerProfile& profile) { for (uint8_t output : profile.button_map) { + if (!valid_control_output(output)) { + return false; + } + } + if (!valid_control_output(profile.shortcuts.modifier) || + !valid_control_output(profile.shift.modifier) || + static_cast(profile.shift.mode) > + static_cast(ControllerProfileShiftMode::kToggle) || + (profile.shift.mode != ControllerProfileShiftMode::kOff && + profile.shift.modifier == CONTROLLER_PROFILE_NO_BUTTON) || + !valid_turbo_settings(profile.turbo_defaults)) { + return false; + } + uint16_t selectors = 0; + for (uint8_t selector : profile.shortcuts.selectors) { + if (selector == CONTROLLER_PROFILE_NO_BUTTON) { + continue; + } + if (!(selector < 4 || (selector >= 12 && selector < 16)) || + selector == profile.shortcuts.modifier || + profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON || + (selectors & (1u << selector)) != 0) { + return false; + } + selectors |= static_cast(1u << selector); + } + for (uint8_t output : profile.shift.button_map) { if (!valid_button(output)) { return false; } } + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0 && + !valid_turbo_settings(profile.turbo_overrides[button])) { + 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; } } + bool routed_triggers[2]{}; for (const ControllerProfileTriggerConfiguration& trigger : profile.triggers) { if (trigger.lower_deadzone >= trigger.upper_saturation || - trigger.curve_q8_8 == 0) { + trigger.curve_q8_8 == 0 || + !valid_control_output(trigger.output)) { return false; } + if (trigger.output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + trigger.output <= CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL) { + const uint8_t target = static_cast( + trigger.output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + if (routed_triggers[target]) { + return false; + } + routed_triggers[target] = true; + } } 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.switching_chord & ~kLogicalControlMask) != 0 || + (profile.motion_toggle_chord & ~kLogicalControlMask) != 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)) { + uint8_t expected_first_step = 0; + size_t encoded_macro_size = 0; + uint32_t trigger_masks[CONTROLLER_PROFILE_MACRO_COUNT]{}; + for (uint8_t macro_index = 0; + macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) { + const ControllerProfileMacro& macro = + profile.macros[macro_index]; + if ((macro.trigger_mask & ~kLogicalControlMask) != 0 || + !valid_control_output(macro.cancel_control) || + static_cast(macro.mode) > + static_cast(ControllerProfileMacroMode::kRepeat) || + macro.repeat_count == 0 || + macro.first_step != expected_first_step || + macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO || + macro.step_count > + profile.macro_step_count - expected_first_step) { + return false; + } + for (uint8_t previous = 0; previous < macro_index; ++previous) { + if (macro.trigger_mask != 0 && + trigger_masks[previous] == macro.trigger_mask) { + return false; + } + } + trigger_masks[macro_index] = macro.trigger_mask; + uint32_t duration_ms = 0; + for (uint8_t step = 0; step < macro.step_count; ++step) { + const ControllerProfileMacroStep& value = + profile.macro_steps[expected_first_step + step]; + if (!valid_macro_step(value)) { + return false; + } + encoded_macro_size += sparse_macro_step_size(value); + duration_ms += value.duration_ms; + } + if (macro.trigger_mask != 0 && macro.step_count != 0 && + macro.mode != ControllerProfileMacroMode::kOnce && + duration_ms == 0) { + return false; + } + expected_first_step = + static_cast(expected_first_step + macro.step_count); + } + if (expected_first_step != profile.macro_step_count || + encoded_macro_size > CONTROLLER_PROFILE_MACRO_STREAM_SIZE) { + return false; + } + for (uint8_t index = profile.macro_step_count; + index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) { + const ControllerProfileMacroStep& step = profile.macro_steps[index]; + if (step.override_flags != 0 || step.duration_ms != 0 || + step.output_button_mask != 0 || step.left_stick_x != 0 || + step.left_stick_y != 0 || step.right_stick_x != 0 || + step.right_stick_y != 0 || step.left_trigger != 0 || + step.right_trigger != 0) { return false; } } - for (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)) { + for (ControllerProfileTurboMode mode : profile.turbo_modes) { + if (static_cast(mode) > + static_cast(ControllerProfileTurboMode::kBurst)) { return false; } } @@ -237,53 +449,125 @@ bool controller_profile_encode(const ControllerProfile& profile, 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); + encoded[8] = trigger.output; } 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; + output[75] = static_cast( + ((profile.switching_chord >> 16) & 0x03u) | + (((profile.motion_toggle_chord >> 16) & 0x03u) << 4)); + profile_write_u16( + &output[76], static_cast(profile.switching_chord)); + profile_write_u16( + &output[78], static_cast(profile.motion_toggle_chord)); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { - output[82 + index] = + output[80 + 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); + + size_t stream_offset = 0; + for (uint8_t macro_index = 0; + macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) { + const ControllerProfileMacro& macro = + profile.macros[macro_index]; + uint8_t* descriptor = + &output[96 + macro_index * + CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE]; + profile_write_u16( + descriptor, static_cast(macro.trigger_mask)); + const uint8_t cancel = + macro.cancel_control == CONTROLLER_PROFILE_NO_BUTTON + ? 0x1fu + : macro.cancel_control; + descriptor[2] = static_cast( + ((macro.trigger_mask >> 16) & 0x03u) | + (cancel << 2)); + descriptor[3] = static_cast(stream_offset); + descriptor[4] = macro.step_count; + const size_t macro_start = stream_offset; + for (uint8_t step_index = 0; + step_index < macro.step_count; ++step_index) { + size_t encoded_size = 0; + if (!encode_sparse_macro_step( + profile.macro_steps[macro.first_step + step_index], + &output[120 + stream_offset], + CONTROLLER_PROFILE_MACRO_STREAM_SIZE - stream_offset, + &encoded_size)) { + return false; + } + stream_offset += encoded_size; + } + descriptor[5] = + static_cast(stream_offset - macro_start); + output[336 + macro_index * 2] = static_cast(macro.mode); + output[337 + macro_index * 2] = macro.repeat_count; } - return true; + output[256] = profile.shortcuts.modifier; + memcpy(&output[257], profile.shortcuts.selectors, + CONTROLLER_PROFILE_COUNT); + output[265] = static_cast(profile.shift.mode); + output[266] = profile.shift.modifier; + memcpy(&output[267], profile.shift.button_map, + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + output[283] = profile.turbo_defaults.rate_hz; + output[284] = profile.turbo_defaults.duty_percent; + output[285] = profile.turbo_defaults.burst_count; + profile_write_u16(&output[286], profile.turbo_override_mask); + size_t settings_offset = 288; + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0) { + const ControllerProfileTurboSettings& settings = + profile.turbo_overrides[button]; + output[settings_offset++] = settings.rate_hz; + output[settings_offset++] = settings.duty_percent; + output[settings_offset++] = settings.burst_count; + } + } + return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } 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) { + (input_size != CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE && + 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 || + const size_t expected_size = + schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION + ? CONTROLLER_PROFILE_ENCODED_SIZE + : CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION || + schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION || + input_size != expected_size || + profile_read_u16(&input[2]) != expected_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)) { + !profile_bytes_are_zero(&input[47], 5)) { + return false; + } + const bool has_control_mapping = + schema_version >= CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION; + const bool has_action_controls = + schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION; + const bool sparse_macros = + schema_version >= CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION; + if ((has_control_mapping + ? input[61] != 0 || input[71] != 0 + : !profile_bytes_are_zero(&input[60], 2) || + !profile_bytes_are_zero(&input[70], 2)) || + (sparse_macros + ? (input[75] & 0xccu) != 0 + : (has_action_controls ? (input[75] & 0xc0u) != 0 + : input[75] != 0)) || + (!has_control_mapping && + (input[81] != 0 || + !profile_bytes_are_zero(&input[98], 2))) || + (!sparse_macros && + !profile_bytes_are_zero(&input[252], 4))) { return false; } @@ -293,7 +577,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, for (uint8_t index = 0; index < 2; ++index) { ControllerProfileStickConfiguration& stick = profile.sticks[index]; const uint8_t* encoded = &input[20 + index * 16]; - if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0) { + if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0 || + !profile_bytes_are_zero(&encoded[11], 5)) { return false; } stick.center_x = profile_read_i16(&encoded[0]); @@ -312,6 +597,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, 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]); + trigger.output = + has_control_mapping + ? encoded[8] + : static_cast( + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index); if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && trigger.digital_threshold == kLegacyDefaultDigitalThreshold) { trigger.digital_threshold = @@ -322,32 +612,188 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, 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) { + + if (sparse_macros) { + profile.switching_chord = + profile_read_u16(&input[76]) | + (static_cast(input[75] & 0x03u) << 16); + profile.motion_toggle_chord = + profile_read_u16(&input[78]) | + (static_cast((input[75] >> 4) & 0x03u) << 16); + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + profile.turbo_modes[index] = + static_cast(input[80 + index]); + } + + size_t expected_stream_offset = 0; + uint8_t decoded_step_count = 0; + for (uint8_t macro_index = 0; + macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) { + const uint8_t* descriptor = + &input[96 + macro_index * + CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE]; + if ((descriptor[2] & 0x80u) != 0 || + descriptor[3] != expected_stream_offset || + descriptor[4] > + CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO || + expected_stream_offset + descriptor[5] > + CONTROLLER_PROFILE_MACRO_STREAM_SIZE || + decoded_step_count + descriptor[4] > + CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) { + return false; + } + ControllerProfileMacro& macro = profile.macros[macro_index]; + macro.trigger_mask = + profile_read_u16(descriptor) | + (static_cast(descriptor[2] & 0x03u) << 16); + const uint8_t cancel = + static_cast((descriptor[2] >> 2) & 0x1fu); + if (cancel > CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + cancel != 0x1fu) { + return false; + } + macro.cancel_control = + cancel == 0x1fu ? CONTROLLER_PROFILE_NO_BUTTON : cancel; + macro.first_step = decoded_step_count; + macro.step_count = descriptor[4]; + if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + macro.mode = static_cast( + input[336 + macro_index * 2]); + macro.repeat_count = input[337 + macro_index * 2]; + } + + size_t consumed = 0; + for (uint8_t step_index = 0; + step_index < macro.step_count; ++step_index) { + size_t step_size = 0; + if (!decode_sparse_macro_step( + &input[120 + expected_stream_offset + consumed], + descriptor[5] - consumed, + &profile.macro_steps[decoded_step_count], + &step_size)) { + return false; + } + consumed += step_size; + ++decoded_step_count; + } + if (consumed != descriptor[5]) { + return false; + } + expected_stream_offset += consumed; + } + if (!profile_bytes_are_zero( + &input[120 + expected_stream_offset], + CONTROLLER_PROFILE_MACRO_STREAM_SIZE - + expected_stream_offset)) { return false; } - 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]); + profile.macro_step_count = decoded_step_count; + } else { + profile.switching_chord = profile_read_u16(&input[76]); + profile.motion_toggle_chord = + has_control_mapping ? profile_read_u16(&input[98]) : 0; + ControllerProfileMacro& macro = profile.macros[0]; + if (has_control_mapping) { + macro.trigger_mask = profile_read_u16(&input[78]); + macro.cancel_control = input[81]; + } else { + if (!valid_button(input[78])) { + return false; + } + macro.trigger_mask = + input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT + ? static_cast(1u << input[78]) + : 0; + macro.cancel_control = input[79]; + } + if (has_action_controls) { + profile.switching_chord |= + static_cast(input[75] & 0x03u) << 16; + macro.trigger_mask |= + static_cast((input[75] >> 2) & 0x03u) << 16; + profile.motion_toggle_chord |= + static_cast((input[75] >> 4) & 0x03u) << 16; + } + const uint8_t legacy_step_count = input[80]; + if (legacy_step_count == 0 || + legacy_step_count > + CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY) { + return false; + } + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY; + ++index) { + const uint8_t* encoded = &input[100 + index * 19]; + const bool must_end = index >= legacy_step_count - 1; + if (must_end) { + if (encoded[0] != 1 || + !profile_bytes_are_zero(&encoded[1], 18)) { + return false; + } + continue; + } + if (encoded[0] != 0 || encoded[18] != 0) { + return false; + } + ControllerProfileMacroStep& step = profile.macro_steps[index]; + step.override_flags = encoded[1]; + step.duration_ms = profile_read_u16(&encoded[2]); + step.output_button_mask = profile_read_u16(&encoded[4]); + step.left_stick_x = profile_read_i16(&encoded[6]); + step.left_stick_y = profile_read_i16(&encoded[8]); + step.right_stick_x = profile_read_i16(&encoded[10]); + step.right_stick_y = profile_read_i16(&encoded[12]); + step.left_trigger = profile_read_u16(&encoded[14]); + step.right_trigger = profile_read_u16(&encoded[16]); + if (!valid_macro_step(step)) { + return false; + } + } + macro.first_step = 0; + macro.step_count = static_cast(legacy_step_count - 1u); + profile.macro_step_count = macro.step_count; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].first_step = profile.macro_step_count; + } + for (uint8_t index = 0; + index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { + profile.turbo_modes[index] = + static_cast(input[82 + index]); + } + } + if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + profile.shortcuts.modifier = input[256]; + memcpy(profile.shortcuts.selectors, &input[257], + CONTROLLER_PROFILE_COUNT); + profile.shift.mode = static_cast(input[265]); + profile.shift.modifier = input[266]; + memcpy(profile.shift.button_map, &input[267], + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + profile.turbo_defaults = {input[283], input[284], input[285]}; + profile.turbo_override_mask = profile_read_u16(&input[286]); + size_t settings_offset = 288; + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0) { + profile.turbo_overrides[button] = { + input[settings_offset], input[settings_offset + 1], + input[settings_offset + 2]}; + settings_offset += 3; + } + } + if (!profile_bytes_are_zero( + &input[settings_offset], 336 - settings_offset) || + !profile_bytes_are_zero(&input[344], 40)) { + return false; + } + } else { + for (ControllerProfileTurboMode mode : profile.turbo_modes) { + if (static_cast(mode) > + static_cast(ControllerProfileTurboMode::kAutoBurst)) { + return false; + } + } } if (!controller_profile_validate(profile)) { return false; @@ -503,15 +949,28 @@ bool controller_profile_database_decode( return false; } const uint16_t schema_version = profile_read_u16(&header[4]); + const bool legacy = + schema_version < CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION; + const uint8_t profile_count = header[9]; + const size_t profile_size = legacy + ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE + : CONTROLLER_PROFILE_ENCODED_SIZE; + const size_t entry_size = + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + profile_count * profile_size; + const size_t entries_offset = + kFallbackOffset + profile_count * profile_size; + const size_t encoded_size = + entries_offset + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * entry_size; 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 || + schema_version < CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION || + schema_version > CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION || + profile_read_u16(&header[6]) != encoded_size || header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || - header[9] != CONTROLLER_PROFILE_COUNT || - header[10] >= CONTROLLER_PROFILE_COUNT || + (profile_count != CONTROLLER_PROFILE_COUNT && + !(schema_version == CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION && + profile_count == 4)) || + header[10] >= profile_count || header[11] > CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || !profile_bytes_are_zero(&header[12], sizeof(header) - 12)) { return false; @@ -521,14 +980,14 @@ bool controller_profile_database_decode( 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) { + profile_index < profile_count; ++profile_index) { const size_t profile_offset = kFallbackOffset + - profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + profile_index * profile_size; if (!read(context, profile_offset, encoded_profile, - sizeof(encoded_profile)) || + profile_size) || !controller_profile_decode( - encoded_profile, sizeof(encoded_profile), + encoded_profile, profile_size, &output->fallback_profiles[profile_index])) { controller_profile_database_default(output); return false; @@ -540,7 +999,7 @@ bool controller_profile_database_decode( entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++entry_index) { const size_t entry_offset = - kEntriesOffset + entry_index * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; + entries_offset + entry_index * entry_size; uint8_t entry_header[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE]{}; if (!read(context, entry_offset, entry_header, sizeof(entry_header))) { @@ -552,15 +1011,14 @@ bool controller_profile_database_decode( !read_zero_region( read, context, entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE, - CONTROLLER_PROFILE_COUNT * - CONTROLLER_PROFILE_ENCODED_SIZE)) { + profile_count * profile_size)) { controller_profile_database_default(output); return false; } continue; } if (entry_header[15] != 1 || - entry_header[14] >= CONTROLLER_PROFILE_COUNT) { + entry_header[14] >= profile_count) { controller_profile_database_default(output); return false; } @@ -577,14 +1035,14 @@ bool controller_profile_database_decode( entry.active_profile = entry_header[14]; ++decoded_used_count; for (uint8_t profile_index = 0; - profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) { + profile_index < profile_count; ++profile_index) { const size_t profile_offset = entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + - profile_index * CONTROLLER_PROFILE_ENCODED_SIZE; + profile_index * profile_size; if (!read(context, profile_offset, encoded_profile, - sizeof(encoded_profile)) || + profile_size) || !controller_profile_decode(encoded_profile, - sizeof(encoded_profile), + profile_size, &entry.profiles[profile_index])) { controller_profile_database_default(output); return false; diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index 84ffea4..c745842 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -6,22 +6,49 @@ #include "core/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 uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2; +constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; +constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4; +constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 6; +constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256; +constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384; +constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; -constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8; +constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; +constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17; +constexpr uint8_t CONTROLLER_PROFILE_MACRO_COUNT = 4; +constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 16; +constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO = 8; +constexpr uint8_t CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY = 8; +constexpr size_t CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE = 6; +constexpr size_t CONTROLLER_PROFILE_MACRO_STREAM_SIZE = 136; constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000; constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff; constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff; // 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_TURBO_RATE_MIN = 1; +constexpr uint8_t CONTROLLER_PROFILE_TURBO_RATE_MAX = 30; +constexpr uint8_t CONTROLLER_PROFILE_TURBO_DUTY_MIN = 1; +constexpr uint8_t CONTROLLER_PROFILE_TURBO_DUTY_MAX = 99; +constexpr uint8_t CONTROLLER_PROFILE_TURBO_BURST_MIN = 1; +constexpr uint8_t CONTROLLER_PROFILE_TURBO_BURST_MAX = 255; 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 uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 3; 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_LEGACY_ENTRY_SIZE = + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; +constexpr size_t CONTROLLER_PROFILE_DATABASE_LEGACY_ENCODED_SIZE = + CONTROLLER_PROFILE_DATABASE_HEADER_SIZE + + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE + + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * + CONTROLLER_PROFILE_DATABASE_LEGACY_ENTRY_SIZE; constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE = CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE + CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE; @@ -31,7 +58,9 @@ constexpr size_t CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE; -static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 17696, +static_assert(CONTROLLER_PROFILE_DATABASE_LEGACY_ENCODED_SIZE == 35104, + "legacy profile database wire size changed"); +static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 52512, "profile database wire size changed"); enum class ControllerProfileLogicalButton : uint8_t { kSouth = 0, @@ -67,13 +96,42 @@ enum class ControllerProfileTurboMode : uint8_t { kOff = 0, kTurbo = 1, kAutoBurst = 2, + kBurst = 3, }; -enum class ControllerProfileMacroStepType : uint8_t { - kState = 0, - kEnd = 1, +enum class ControllerProfileShiftMode : uint8_t { + kOff = 0, + kHold = 1, + kToggle = 2, }; +enum class ControllerProfileMacroMode : uint8_t { + kOnce = 0, + kWhileHeld = 1, + kToggle = 2, + kRepeat = 3, +}; + +struct ControllerProfileShortcutConfiguration { + uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON; + uint8_t selectors[CONTROLLER_PROFILE_COUNT]{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +}; + +struct ControllerProfileShiftConfiguration { + ControllerProfileShiftMode mode = ControllerProfileShiftMode::kOff; + uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON; + uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; +}; + +struct ControllerProfileTurboSettings { + uint8_t rate_hz = 15; + uint8_t duty_percent = 50; + uint8_t burst_count = 3; +}; + + enum ControllerProfileMacroOverride : uint8_t { kControllerProfileOverrideButtons = 1u << 0, kControllerProfileOverrideLeftStick = 1u << 1, @@ -99,11 +157,11 @@ struct ControllerProfileTriggerConfiguration { // Compared against the transformed uint16 trigger output. uint16_t digital_threshold = CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + // Logical control output (button, analog trigger, or NO_BUTTON). + uint8_t output = CONTROLLER_PROFILE_NO_BUTTON; }; struct ControllerProfileMacroStep { - ControllerProfileMacroStepType type = - ControllerProfileMacroStepType::kEnd; uint8_t override_flags = 0; uint16_t duration_ms = 0; uint16_t output_button_mask = 0; @@ -115,6 +173,15 @@ struct ControllerProfileMacroStep { uint16_t right_trigger = 0; }; +struct ControllerProfileMacro { + uint32_t trigger_mask = 0; + uint8_t cancel_control = CONTROLLER_PROFILE_NO_BUTTON; + uint8_t first_step = 0; + uint8_t step_count = 0; + ControllerProfileMacroMode mode = ControllerProfileMacroMode::kOnce; + uint8_t repeat_count = 1; +}; + struct ControllerProfile { uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; ControllerProfileStickConfiguration sticks[2]{}; @@ -123,14 +190,20 @@ struct ControllerProfile { 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; + uint32_t switching_chord = 0; + uint32_t motion_toggle_chord = 0; + ControllerProfileMacro macros[CONTROLLER_PROFILE_MACRO_COUNT]{}; + uint8_t macro_step_count = 0; ControllerProfileTurboMode turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; ControllerProfileMacroStep macro_steps[CONTROLLER_PROFILE_MACRO_STEP_CAPACITY]{}; + ControllerProfileShortcutConfiguration shortcuts{}; + ControllerProfileShiftConfiguration shift{}; + ControllerProfileTurboSettings turbo_defaults{}; + uint16_t turbo_override_mask = 0; + ControllerProfileTurboSettings + turbo_overrides[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; }; struct ControllerProfileDatabaseEntry { diff --git a/src/firmware/profile/controller_profile_runtime.cpp b/src/firmware/profile/controller_profile_runtime.cpp index c02f97f..158aaac 100644 --- a/src/firmware/profile/controller_profile_runtime.cpp +++ b/src/firmware/profile/controller_profile_runtime.cpp @@ -8,6 +8,7 @@ namespace { struct ControllerProfileRuntimeContext { bool active = false; + bool connection_seen = false; uint32_t connection_generation = 0; ControllerIdentity identity{}; uint32_t database_generation = 0; @@ -18,12 +19,13 @@ struct ControllerProfileRuntimeContext { 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; + uint32_t previous_hotkey_control_mask = 0; + uint32_t held_hotkey_chord = 0; + uint32_t held_hotkey_release_mask = 0; + uint32_t held_shortcut_selectors = 0; + bool activation_requested = true; + uint8_t target_profile_index = 0; + uint32_t activation_transaction_id = 0; bool profile_change_pending = false; ControllerProfileRuntimeProfileChangeEvent pending_profile_change{}; bool initial_profile_indication_resolved = false; @@ -49,12 +51,33 @@ uint32_t next_activation_transaction_id() { return transaction_id; } -uint16_t effective_switching_chord(const ControllerProfile& profile) { +uint32_t effective_switching_chord(const ControllerProfile& profile) { return profile.switching_chord == 0 ? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD : profile.switching_chord; } +uint32_t effective_motion_toggle_chord( + const ControllerProfile& profile) { + return profile.motion_toggle_chord == 0 + ? CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD + : profile.motion_toggle_chord; +} + +bool chord_held(uint32_t controls, uint32_t chord) { + return chord != 0 && (controls & chord) == chord; +} + +uint32_t shortcut_selector_mask(const ControllerProfile& profile) { + uint32_t mask = 0; + for (const uint8_t selector : profile.shortcuts.selectors) { + if (selector < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + mask |= 1u << selector; + } + } + return mask; +} + void initialize_defaults() { if (g_initialized) { return; @@ -77,7 +100,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context, const ControllerIdentity& identity, uint32_t connection_generation, uint32_t observed_database_generation, - uint16_t current_input_button_mask) { + const Bluepad32SlotSnapshot& input_snapshot) { ProfileServiceActiveProfileSnapshot snapshot{}; profile_service_active_profile_snapshot(identity, &snapshot); @@ -91,14 +114,15 @@ void refresh_profile(ControllerProfileRuntimeContext* context, context->active_profile_index; const bool previous_profile_valid = context->profile_snapshot_valid; + const bool had_connection = context->connection_seen; 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->previous_hotkey_control_mask = 0; + context->held_hotkey_chord = 0; + context->held_hotkey_release_mask = 0; + context->held_shortcut_selectors = 0; + context->activation_requested = true; + context->activation_transaction_id = 0; context->profile_change_pending = false; context->pending_profile_change = {}; context->initial_profile_indication_resolved = false; @@ -106,9 +130,8 @@ void refresh_profile(ControllerProfileRuntimeContext* context, context->pending_initial_profile_indication = {}; } - controller_synthetic_input_cancel(&context->synthetic, - current_input_button_mask); context->active = true; + context->connection_seen = true; context->connection_generation = connection_generation; context->identity = identity; context->database_generation = snapshot.valid @@ -117,6 +140,19 @@ void refresh_profile(ControllerProfileRuntimeContext* context, 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; + const uint32_t current_input_control_mask = + controller_profile_extract_control_mask( + input_snapshot.state, context->profile); + controller_synthetic_input_cancel( + &context->synthetic, current_input_control_mask); + const uint32_t hotkey_control_mask = + (current_input_control_mask & ~0xffffu) | + input_snapshot.pre_hotkey_button_mask; + // Refreshes must not turn a held input into a fresh command. The first + // connection retains the existing ability to start a held cycle chord. + context->previous_hotkey_control_mask = + had_connection ? hotkey_control_mask : + hotkey_control_mask & effective_motion_toggle_chord(context->profile); if (!context->initial_profile_indication_resolved && snapshot.valid) { context->initial_profile_indication_resolved = true; const uint8_t policy = static_cast( @@ -132,13 +168,8 @@ void refresh_profile(ControllerProfileRuntimeContext* context, 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); - } + // A pending target and its consuming chord belong to the observation that + // started them, not to the newly loaded profile's bindings. if (same_identity && previous_profile_valid && snapshot.valid && previous_profile_index != context->active_profile_index) { context->pending_profile_change = { @@ -161,6 +192,7 @@ ControllerProfileRuntimeContext* update_context( if (!snapshot.active) { if (context.active) { clear_context(&context); + context.connection_seen = true; } return nullptr; } @@ -173,81 +205,107 @@ ControllerProfileRuntimeContext* update_context( context.database_generation != database_generation) { refresh_profile( &context, snapshot.identity, snapshot.connection_generation, - database_generation, - controller_profile_extract_button_mask(snapshot.state)); + database_generation, snapshot); } 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) { +void request_activation(ControllerProfileRuntimeContext* context, + const ControllerIdentity& identity) { + if (context->activation_requested) { 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; + if (context->target_profile_index == context->active_profile_index) { + context->activation_requested = 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); + context->activation_transaction_id, identity, + context->target_profile_index); if (status != ConfigurationTransactionStatus::kBusy) { - context->switching_activation_requested = true; + context->activation_requested = true; } } +uint32_t process_hotkeys(ControllerProfileRuntimeContext* context, + uint8_t slot, uint32_t controls) { + const ControllerProfile& profile = context->profile; + const uint32_t selectors = shortcut_selector_mask(profile); + const uint32_t modifier = + profile.shortcuts.modifier < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT + ? 1u << profile.shortcuts.modifier : 0; + const uint32_t pressed_selectors = controls & selectors; + const bool direct = (controls & modifier) != 0 && pressed_selectors != 0; + const uint32_t cycle_chord = effective_switching_chord(profile); + const uint32_t motion_chord = effective_motion_toggle_chord(profile); + const bool cycle = chord_held(controls, cycle_chord); + const bool motion = chord_held(controls, motion_chord); + uint32_t consumed = (direct ? modifier | pressed_selectors : 0) | + (cycle ? cycle_chord : 0) | + (motion ? motion_chord : 0); + + if (context->held_hotkey_chord != 0) { + consumed |= context->held_hotkey_chord | + (controls & context->held_shortcut_selectors); + const bool released = context->held_shortcut_selectors != 0 + ? (controls & context->held_hotkey_release_mask) == 0 || + !chord_held(controls, context->held_hotkey_chord & + ~context->held_shortcut_selectors) + : !chord_held(controls, context->held_hotkey_chord); + if (released) { + context->held_hotkey_chord = 0; + context->held_hotkey_release_mask = 0; + context->held_shortcut_selectors = 0; + context->activation_requested = true; + context->activation_transaction_id = 0; + } else if (chord_held(controls, context->held_hotkey_chord)) { + request_activation(context, context->identity); + } else { + // An ambiguous direct chord remains rejected until its modifier + // or all of its original selectors are released. + context->activation_requested = true; + } + if (!released) return consumed; + } + if (!direct && !cycle && !motion) { + return consumed; + } + + const uint32_t chord = direct ? modifier | pressed_selectors : + cycle ? cycle_chord : motion_chord; + context->held_hotkey_chord = chord; + context->held_hotkey_release_mask = direct ? pressed_selectors : chord; + context->held_shortcut_selectors = direct ? selectors : 0; + context->activation_requested = true; + if (chord_held(context->previous_hotkey_control_mask, chord)) { + return consumed; + } + if (direct) { + if ((pressed_selectors & (pressed_selectors - 1u)) != 0) { + return consumed; + } + for (uint8_t index = 0; index < CONTROLLER_PROFILE_COUNT; ++index) { + const uint8_t selector = profile.shortcuts.selectors[index]; + if (selector < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT && + (pressed_selectors & (1u << selector)) != 0) { + context->target_profile_index = index; + break; + } + } + } else if (cycle) { + context->target_profile_index = static_cast( + (context->active_profile_index + 1u) % CONTROLLER_PROFILE_COUNT); + } else { + bluepad32_input_backend_toggle_motion( + slot, context->connection_generation); + return consumed; + } + context->activation_requested = false; + context->activation_transaction_id = next_activation_transaction_id(); + request_activation(context, context->identity); + return consumed; +} + } // namespace void controller_profile_runtime_reset() { @@ -268,30 +326,32 @@ ControllerProfileTransformResult controller_profile_runtime_transform( 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) { + const uint32_t state_control_mask = + controller_profile_extract_control_mask( + snapshot.state, context->profile); + const uint32_t input_control_mask = + (state_control_mask & ~0xffffu) | + snapshot.pre_hotkey_button_mask; + if (context->runtime_generations_initialized && + (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; + &context->synthetic, state_control_mask); + context->previous_hotkey_control_mask = input_control_mask; + context->activation_requested = true; + context->activation_transaction_id = 0; } + context->runtime_generations_initialized = true; + context->output_mode = output_mode; + context->configuration_reset_generation = reset_generation; + const uint32_t consumed_controls = + process_hotkeys(context, slot, input_control_mask); + context->previous_hotkey_control_mask = input_control_mask; return controller_synthetic_input_apply( - &context->synthetic, consumed_input, context->profile, now_ms); + &context->synthetic, snapshot.state, context->profile, now_ms, + consumed_controls, consumed_controls != 0); } bool controller_profile_runtime_take_initial_profile_indication( diff --git a/src/firmware/profile/controller_profile_runtime.h b/src/firmware/profile/controller_profile_runtime.h index 96783f9..d39396a 100644 --- a/src/firmware/profile/controller_profile_runtime.h +++ b/src/firmware/profile/controller_profile_runtime.h @@ -18,6 +18,14 @@ constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD = ControllerProfileLogicalButton::kSelect)) | (1u << static_cast( ControllerProfileLogicalButton::kStart))); +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD = + static_cast( + (1u << static_cast( + ControllerProfileLogicalButton::kDpadUp)) | + (1u << static_cast( + ControllerProfileLogicalButton::kRightShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kStart))); struct ControllerProfileRuntimeProfileChangeEvent { uint32_t connection_generation = 0; @@ -42,10 +50,10 @@ struct ControllerProfileRuntimeLocalConfirmation { // 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 +// Refresh slot-local profile and synthetic state on identity or generation +// changes while preserving a latched hotkey target across profile refreshes. +// Arbitrate pre-hotkey shortcuts, cycle and motion before Shift and synthetic +// input. The caller removes the reserved output-mode chord first. Inactive // snapshots return neutral output and invalidate the slot. ControllerProfileTransformResult controller_profile_runtime_transform( uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms, diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 883aa07..97c67eb 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -177,6 +177,26 @@ uint16_t transform_trigger( kQ16One); } +void route_trigger_output( + uint8_t output, uint16_t value, uint16_t digital_threshold, + uint16_t* output_button_mask, uint16_t output_triggers[2], + uint16_t output_thresholds[2]) { + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + if (value >= digital_threshold) { + *output_button_mask |= static_cast(1u << output); + } + return; + } + if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + const uint8_t trigger_index = static_cast( + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + if (value > output_triggers[trigger_index]) { + output_triggers[trigger_index] = value; + } + output_thresholds[trigger_index] = digital_threshold; + } +} + } // namespace uint16_t controller_profile_extract_button_mask(const ControllerState& state) { @@ -232,6 +252,44 @@ uint16_t controller_profile_extract_button_mask(const ControllerState& state) { return mask; } +uint32_t controller_profile_extract_control_mask( + const ControllerState& state, const ControllerProfile& profile) { + uint32_t mask = controller_profile_extract_button_mask(state); + const uint16_t trigger_values[2] = { + transform_trigger(state.left_trigger, profile.triggers[0]), + transform_trigger(state.right_trigger, profile.triggers[1]), + }; + for (uint8_t index = 0; index < 2; ++index) { + if (trigger_values[index] >= + profile.triggers[index].digital_threshold) { + mask |= static_cast( + 1u << (CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index)); + } + } + return mask; +} + +void controller_profile_remove_control_mask( + uint32_t control_mask, ControllerState* state) { + if (state == nullptr) { + return; + } + const uint16_t buttons = + controller_profile_extract_button_mask(*state); + controller_profile_apply_button_mask( + static_cast( + buttons & ~static_cast(control_mask)), + state); + if ((control_mask & + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL)) != 0) { + state->left_trigger = 0; + } + if ((control_mask & + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL)) != 0) { + state->right_trigger = 0; + } +} + void controller_profile_apply_button_mask(uint16_t button_mask, ControllerState* state) { if (state == nullptr) { @@ -276,14 +334,17 @@ void controller_profile_apply_button_mask(uint16_t button_mask, } uint16_t controller_profile_map_button_mask( - uint16_t input_button_mask, const ControllerProfile& profile) { + uint16_t input_button_mask, const ControllerProfile& profile, + const uint8_t* button_map) { + const uint8_t* selected_map = + button_map == nullptr ? profile.button_map : button_map; 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]; + const uint8_t output = selected_map[input]; if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { output_button_mask |= static_cast(1u << output); } @@ -292,14 +353,51 @@ uint16_t controller_profile_map_button_mask( } ControllerProfileTransformResult controller_profile_transform( - const ControllerState& input, const ControllerProfile& profile) { + const ControllerState& input, const ControllerProfile& profile, + const uint8_t* button_map) { + const uint8_t* selected_map = + button_map == nullptr ? profile.button_map : button_map; 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); + uint16_t output_button_mask = + controller_profile_map_button_mask( + input_button_mask, profile, selected_map); + uint16_t output_triggers[2]{}; + uint16_t output_thresholds[2] = { + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + }; + + for (uint8_t input_button = 0; + input_button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + ++input_button) { + if ((input_button_mask & + static_cast(1u << input_button)) == 0) { + continue; + } + const uint8_t output = selected_map[input_button]; + if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + output_triggers[ + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = + UINT16_MAX; + } + } + + const uint16_t transformed_triggers[2] = { + transform_trigger(input.left_trigger, profile.triggers[0]), + transform_trigger(input.right_trigger, profile.triggers[1]), + }; + for (uint8_t input_trigger = 0; input_trigger < 2; ++input_trigger) { + route_trigger_output( + profile.triggers[input_trigger].output, + transformed_triggers[input_trigger], + profile.triggers[input_trigger].digital_threshold, + &output_button_mask, output_triggers, output_thresholds); + } + controller_profile_apply_button_mask(output_button_mask, &result.state); transform_stick(profile.sticks[0], input.left_stick_x, input.left_stick_y, &result.state.left_stick_x, @@ -307,14 +405,10 @@ ControllerProfileTransformResult controller_profile_transform( transform_stick(profile.sticks[1], input.right_stick_x, input.right_stick_y, &result.state.right_stick_x, &result.state.right_stick_y); - 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; + result.state.left_trigger = output_triggers[0]; + result.state.right_trigger = output_triggers[1]; + result.left_trigger_digital_threshold = output_thresholds[0]; + result.right_trigger_digital_threshold = output_thresholds[1]; return result; } @@ -329,12 +423,21 @@ uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, 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), - }; + ControllerRumbleOutput output = input; + output.low_frequency_magnitude = controller_profile_scale_rumble_magnitude( + input.low_frequency_magnitude, profile.strong_rumble_scale); + output.high_frequency_magnitude = controller_profile_scale_rumble_magnitude( + input.high_frequency_magnitude, profile.weak_rumble_scale); + for (SwitchHapticsActuatorFrame& actuator : output.hd.actuators) { + for (uint8_t index = 0; index < actuator.sample_count && index < 3; ++index) { + SwitchHapticsSample& sample = actuator.samples[index]; + sample.low_amplitude_q15 = static_cast( + (uint32_t{sample.low_amplitude_q15} * profile.strong_rumble_scale + 127u) / 255u); + sample.high_amplitude_q15 = static_cast( + (uint32_t{sample.high_amplitude_q15} * profile.weak_rumble_scale + 127u) / 255u); + } + } + return output; } ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 7b4a0d8..3cea832 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -15,13 +15,19 @@ struct ControllerProfileTransformResult { }; uint16_t controller_profile_extract_button_mask(const ControllerState& state); +uint32_t controller_profile_extract_control_mask( + const ControllerState& state, const ControllerProfile& profile); +void controller_profile_remove_control_mask( + uint32_t control_mask, 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); + uint16_t input_button_mask, const ControllerProfile& profile, + const uint8_t* button_map = nullptr); ControllerProfileTransformResult controller_profile_transform( - const ControllerState& input, const ControllerProfile& profile); + const ControllerState& input, const ControllerProfile& profile, + const uint8_t* button_map = nullptr); uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, uint8_t scale); diff --git a/src/firmware/profile/controller_synthetic_input.cpp b/src/firmware/profile/controller_synthetic_input.cpp index 1a57ba6..f95ecf5 100644 --- a/src/firmware/profile/controller_synthetic_input.cpp +++ b/src/firmware/profile/controller_synthetic_input.cpp @@ -2,15 +2,18 @@ namespace { -constexpr uint32_t kTurboTransitionsPerSecond = 30; -constexpr uint32_t kTurboPhaseUnitsPerTransition = 1000; +constexpr uint32_t kTurboCycleUnits = 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; +constexpr uint32_t control_bit(uint8_t control) { + return static_cast(1u << control); +} + +bool is_bound_control(uint8_t control) { + return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; } void clear_binding(ControllerSyntheticBindingState* binding) { @@ -18,87 +21,123 @@ void clear_binding(ControllerSyntheticBindingState* binding) { } void start_binding(ControllerSyntheticBindingState* binding, - uint32_t now_ms) { + uint32_t now_ms, uint8_t burst_count = 0) { + *binding = {}; binding->active = true; binding->phase_on = true; - binding->phase_units = 0; + binding->burst_windows_remaining = burst_count; binding->last_update_ms = now_ms; } void advance_binding(ControllerSyntheticBindingState* binding, + const ControllerProfileTurboSettings& settings, 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); + static_cast(elapsed_ms) * settings.rate_hz; + const uint64_t cycles = total_units / kTurboCycleUnits; + binding->phase_units = + static_cast(total_units % kTurboCycleUnits); + binding->phase_on = binding->phase_units < + static_cast(settings.duty_percent) * 10u; 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); + if (binding->burst_windows_remaining != 0) { + if (cycles >= binding->burst_windows_remaining) { + clear_binding(binding); + return; + } + binding->burst_windows_remaining = static_cast( + binding->burst_windows_remaining - cycles); + if (binding->burst_windows_remaining == 1 && !binding->phase_on) { + clear_binding(binding); + } + } } void stop_macro(ControllerSyntheticInputContext* context) { context->macro_active = false; + context->macro_index = 0; context->macro_step_index = 0; - context->macro_deadline_ms = 0; + context->macro_cycle_duration_ms = 0; + context->macro_cycle_elapsed_ms = 0; + context->macro_last_update_ms = 0; + context->macro_cycles_remaining = 0; } bool start_macro(ControllerSyntheticInputContext* context, - const ControllerProfile& profile, uint32_t now_ms) { - if (profile.macro_step_count == 0 || - profile.macro_steps[0].type != - ControllerProfileMacroStepType::kState) { - stop_macro(context); + const ControllerProfile& profile, uint8_t macro_index, + uint32_t now_ms) { + if (macro_index >= CONTROLLER_PROFILE_MACRO_COUNT) { + return false; + } + const ControllerProfileMacro& macro = profile.macros[macro_index]; + if (macro.step_count == 0 || + macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO || + macro.first_step + macro.step_count > profile.macro_step_count || + macro.first_step + macro.step_count > + CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) { + return false; + } + uint32_t duration_ms = 0; + for (uint8_t step = 0; step < macro.step_count; ++step) { + duration_ms += profile.macro_steps[macro.first_step + step].duration_ms; + } + if (duration_ms == 0 && macro.mode != ControllerProfileMacroMode::kOnce) { return false; } context->macro_active = true; + context->macro_index = macro_index; context->macro_step_index = 0; - context->macro_deadline_ms = - now_ms + profile.macro_steps[0].duration_ms; + context->macro_cycle_duration_ms = duration_ms; + context->macro_cycle_elapsed_ms = 0; + context->macro_last_update_ms = now_ms; + context->macro_cycles_remaining = + macro.mode == ControllerProfileMacroMode::kOnce ? 1 : + macro.mode == ControllerProfileMacroMode::kRepeat ? macro.repeat_count : 0; 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; + if (!context->macro_active) { + return; } + const uint32_t duration_ms = context->macro_cycle_duration_ms; + if (context->macro_index >= CONTROLLER_PROFILE_MACRO_COUNT || + duration_ms == 0) { + stop_macro(context); + return; + } + const uint32_t elapsed_ms = now_ms - context->macro_last_update_ms; + const uint64_t total_ms = + static_cast(context->macro_cycle_elapsed_ms) + elapsed_ms; + const uint64_t cycles = total_ms / duration_ms; + if (context->macro_cycles_remaining != 0) { + if (cycles >= context->macro_cycles_remaining) { + stop_macro(context); + return; + } + context->macro_cycles_remaining = static_cast( + context->macro_cycles_remaining - cycles); + } + context->macro_cycle_elapsed_ms = + static_cast(total_ms % duration_ms); + context->macro_last_update_ms = now_ms; + const ControllerProfileMacro& macro = profile.macros[context->macro_index]; + uint32_t remaining_ms = context->macro_cycle_elapsed_ms; + for (uint8_t step = 0; + step < macro.step_count && + step < CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO; ++step) { + const uint16_t step_duration = + profile.macro_steps[macro.first_step + step].duration_ms; + if (remaining_ms < step_duration) { + context->macro_step_index = step; + return; + } + remaining_ms -= step_duration; + } + stop_macro(context); } void apply_macro_override(const ControllerProfileMacroStep& step, @@ -127,41 +166,97 @@ void apply_macro_override(const ControllerProfileMacroStep& step, void controller_synthetic_input_cancel( ControllerSyntheticInputContext* context, - uint16_t current_input_button_mask) { + uint32_t current_input_control_mask) { if (context == nullptr) { return; } *context = {}; - context->previous_input_button_mask = current_input_button_mask; + context->previous_input_control_mask = current_input_control_mask; } ControllerProfileTransformResult controller_synthetic_input_apply( ControllerSyntheticInputContext* context, const ControllerState& input, - const ControllerProfile& profile, uint32_t now_ms) { + const ControllerProfile& profile, uint32_t now_ms, + uint32_t suppressed_control_mask, bool suppress_shift) { 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; + const uint32_t raw_control_mask = + controller_profile_extract_control_mask(input, profile); + uint32_t consumed_controls = suppressed_control_mask; + const uint32_t raw_rising_mask = + raw_control_mask & ~context->previous_input_control_mask; + if (profile.shift.mode != ControllerProfileShiftMode::kOff && + is_bound_control(profile.shift.modifier)) { + const uint32_t modifier = control_bit(profile.shift.modifier); + const bool pressed = (raw_control_mask & modifier) != 0; + const bool allowed = !suppress_shift && + (suppressed_control_mask & modifier) == 0; + if (profile.shift.mode == ControllerProfileShiftMode::kHold) { + context->shift_active = pressed && allowed; + } else if (allowed && (raw_rising_mask & modifier) != 0) { + context->shift_active = !context->shift_active; + } + consumed_controls |= modifier; + } else { + context->shift_active = false; + } + const uint32_t input_control_mask = raw_control_mask & ~consumed_controls; + uint32_t rising_control_mask = raw_rising_mask & ~consumed_controls; + bool cancel_pressed = false; + for (const ControllerProfileMacro& macro : profile.macros) { + if (is_bound_control(macro.cancel_control) && + (input_control_mask & control_bit(macro.cancel_control)) != 0) { + cancel_pressed = true; + consumed_controls |= control_bit(macro.cancel_control); + } + } if (cancel_pressed) { - controller_synthetic_input_cancel(context, input_button_mask); - rising_button_mask = 0; + controller_synthetic_input_cancel(context, raw_control_mask); + rising_control_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); + const bool macro_was_active = context->macro_active; + if (macro_was_active) { + const ControllerProfileMacro& macro = + profile.macros[context->macro_index]; + const bool held = (input_control_mask & macro.trigger_mask) == + macro.trigger_mask; + consumed_controls |= macro.trigger_mask; + if ((macro.mode == ControllerProfileMacroMode::kWhileHeld && !held) || + (macro.mode == ControllerProfileMacroMode::kToggle && held && + (rising_control_mask & macro.trigger_mask) != 0)) { + stop_macro(context); + } + } + if (!cancel_pressed && !macro_was_active) { + for (uint8_t macro_index = 0; + macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) { + const ControllerProfileMacro& macro = + profile.macros[macro_index]; + const bool completed = + macro.trigger_mask != 0 && macro.step_count != 0 && + (input_control_mask & macro.trigger_mask) == + macro.trigger_mask && + (rising_control_mask & macro.trigger_mask) != 0; + if (completed && + start_macro(context, profile, macro_index, now_ms)) { + macro_started = true; + consumed_controls |= macro.trigger_mask; + break; + } + } } if (!macro_started) { advance_macro(context, profile, now_ms); } + if (context->macro_active && + context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) { + consumed_controls |= + profile.macros[context->macro_index].trigger_mask; + } uint16_t gated_input_button_mask = 0; for (uint8_t input_button = 0; @@ -169,15 +264,17 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ++input_button) { ControllerSyntheticBindingState& binding = context->bindings[input_button]; - if (input_button == profile.macro_trigger || - input_button == profile.macro_cancel) { + const uint16_t bit = button_bit(input_button); + if ((consumed_controls & bit) != 0) { 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; + const bool pressed = (input_control_mask & bit) != 0; + const bool rising = (rising_control_mask & bit) != 0; + const ControllerProfileTurboSettings& settings = + (profile.turbo_override_mask & bit) != 0 + ? profile.turbo_overrides[input_button] + : profile.turbo_defaults; switch (profile.turbo_modes[input_button]) { case ControllerProfileTurboMode::kOff: clear_binding(&binding); @@ -193,7 +290,7 @@ ControllerProfileTransformResult controller_synthetic_input_apply( if (!binding.active) { start_binding(&binding, now_ms); } else { - advance_binding(&binding, now_ms); + advance_binding(&binding, settings, now_ms); } if (binding.phase_on) { gated_input_button_mask |= bit; @@ -207,7 +304,17 @@ ControllerProfileTransformResult controller_synthetic_input_apply( start_binding(&binding, now_ms); } } else if (binding.active) { - advance_binding(&binding, now_ms); + advance_binding(&binding, settings, now_ms); + } + if (binding.active && binding.phase_on) { + gated_input_button_mask |= bit; + } + break; + case ControllerProfileTurboMode::kBurst: + if (rising) { + start_binding(&binding, now_ms, settings.burst_count); + } else if (binding.active) { + advance_binding(&binding, settings, now_ms); } if (binding.active && binding.phase_on) { gated_input_button_mask |= bit; @@ -219,19 +326,27 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ControllerState gated_input = input; controller_profile_apply_button_mask(gated_input_button_mask, &gated_input); + controller_profile_remove_control_mask( + consumed_controls, &gated_input); + ControllerProfileTransformResult result = - controller_profile_transform(gated_input, profile); + controller_profile_transform( + gated_input, profile, + context->shift_active ? profile.shift.button_map : nullptr); if (context->macro_active && - context->macro_step_index < profile.macro_step_count) { - const ControllerProfileMacroStep& step = - profile.macro_steps[context->macro_step_index]; - if (step.type == ControllerProfileMacroStepType::kState) { - apply_macro_override(step, &result.state); + context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) { + const ControllerProfileMacro& macro = + profile.macros[context->macro_index]; + if (context->macro_step_index < macro.step_count) { + apply_macro_override( + profile.macro_steps[ + macro.first_step + context->macro_step_index], + &result.state); } else { stop_macro(context); } } - context->previous_input_button_mask = input_button_mask; + context->previous_input_control_mask = raw_control_mask; return result; } diff --git a/src/firmware/profile/controller_synthetic_input.h b/src/firmware/profile/controller_synthetic_input.h index 36c0bf9..b2f4fe5 100644 --- a/src/firmware/profile/controller_synthetic_input.h +++ b/src/firmware/profile/controller_synthetic_input.h @@ -8,27 +8,35 @@ struct ControllerSyntheticBindingState { bool active = false; bool phase_on = false; uint16_t phase_units = 0; + uint8_t burst_windows_remaining = 0; uint32_t last_update_ms = 0; }; struct ControllerSyntheticInputContext { bool macro_active = false; + uint8_t macro_index = 0; uint8_t macro_step_index = 0; - uint32_t macro_deadline_ms = 0; - uint16_t previous_input_button_mask = 0; + uint32_t macro_cycle_duration_ms = 0; + uint32_t macro_cycle_elapsed_ms = 0; + uint32_t macro_last_update_ms = 0; + uint8_t macro_cycles_remaining = 0; + bool shift_active = false; + uint32_t previous_input_control_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. +// Clear every synthetic source, including toggle Shift. Inputs already held at +// cancellation cannot restart edge-triggered Macro, Burst, Auto Burst or Shift +// until they are released and pressed again. void controller_synthetic_input_cancel( ControllerSyntheticInputContext* context, - uint16_t current_input_button_mask = 0); + uint32_t current_input_control_mask = 0); -// Apply raw-input consumption, mapped physical contributions, Turbo/Auto Burst -// gating, and finally the active macro step's field overrides. +// Consume reserved controls and Shift before physical macro/Turbo arbitration. +// Only the selected button map changes; base analog tuning and final macro +// overrides retain their precedence. Suppressed inputs still track raw edges. ControllerProfileTransformResult controller_synthetic_input_apply( ControllerSyntheticInputContext* context, const ControllerState& input, - const ControllerProfile& profile, uint32_t now_ms); + const ControllerProfile& profile, uint32_t now_ms, + uint32_t suppressed_control_mask = 0, bool suppress_shift = false); diff --git a/src/firmware/profile/profile_service.cpp b/src/firmware/profile/profile_service.cpp index 4a29b2d..078c732 100644 --- a/src/firmware/profile/profile_service.cpp +++ b/src/firmware/profile/profile_service.cpp @@ -9,664 +9,683 @@ namespace { constexpr uint32_t kMinimumCommitIntervalMs = 1000; constexpr uint32_t kInternalTransactionIdMask = 0x80000000u; +static_assert(PROFILE_SERVICE_METADATA_MAX_BYTES == + PROFILE_STORAGE_METADATA_MAX_BYTES); enum class PendingCommandType : uint8_t { - kNone = 0, - kReset = 1, - kActivate = 2, + kNone = 0, + kReset = 1, + kActivate = 2, + kSetAlias = 3, + kSetProfileName = 4, }; struct PendingCommand { - PendingCommandType type = PendingCommandType::kNone; - uint32_t transaction_id = 0; - ControllerIdentity identity{}; - uint8_t profile_index = 0; + PendingCommandType type = PendingCommandType::kNone; + uint32_t transaction_id = 0; + ControllerIdentity identity{}; + uint8_t profile_index = 0; + uint8_t value_size = 0; + char value[PROFILE_SERVICE_METADATA_MAX_BYTES]{}; }; struct ProfileTransaction { - ControllerIdentity identity{}; - uint8_t profile_index = 0; - ConfigurationTransactionSnapshot snapshot{}; - uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + 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{}; + 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]{}; +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)); +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 = ProfileServiceListSnapshot{}; - 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; + g_list = ProfileServiceListSnapshot{}; + g_list.metadata = g_metadata; + for (uint8_t index = 0; index < g_storage.identity_count() && + g_list.count < PROFILE_SERVICE_LIST_CAPACITY; + ++index) { + const ProfileStorageIdentityIndex *entry = g_storage.identity(index); + if (entry == nullptr || !entry->used) { + continue; } + ProfileServiceListRow &row = g_list.rows[g_list.count++]; + if (g_storage.get_alias( + entry->identity, row.alias, + sizeof(row.alias)) != ProfileStorageResult::kOk) { + row.alias[0] = '\0'; + } + 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]; + g_active_profile_count = 0; + for (uint8_t index = 0; + index < g_storage.identity_count() && + g_active_profile_count < PROFILE_SERVICE_LIST_CAPACITY; + ++index) { + const ProfileStorageIdentityIndex *entry = g_storage.identity(index); + if (entry == nullptr || !entry->used) { + continue; } + PublishedActiveProfile &active = g_active_profiles[g_active_profile_count]; + active.identity = entry->identity; + active.profile_index = entry->active_profile; + if (g_storage.get(entry->identity, entry->active_profile, + &active.profile) != ProfileStorageResult::kOk) { + continue; + } + ++g_active_profile_count; + } } 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; + g_selected.metadata = g_metadata; + if (g_storage.get(g_selected.identity, g_selected.profile_index, + &g_selected.profile) != ProfileStorageResult::kOk) { + g_selected.valid = false; + g_selected.status = ConfigurationTransactionStatus::kMalformed; + return; + } + 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); + 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; - } +ConfigurationTransactionStatus +storage_result_status(ProfileStorageResult result) { + switch (result) { + case ProfileStorageResult::kOk: + return ConfigurationTransactionStatus::kCommitted; + case ProfileStorageResult::kUnchanged: + return ConfigurationTransactionStatus::kUnchanged; + case ProfileStorageResult::kInvalidArgument: + return ConfigurationTransactionStatus::kMalformed; + case ProfileStorageResult::kFull: + return ConfigurationTransactionStatus::kTooLarge; + case ProfileStorageResult::kIoError: return ConfigurationTransactionStatus::kStorageError; + } + return ConfigurationTransactionStatus::kStorageError; } bool mutation_ready(uint32_t now_ms) { - return !g_has_committed || - static_cast(now_ms - g_last_commit_ms) >= - kMinimumCommitIntervalMs; + 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); + critical_section_enter_blocking(&g_lock); + g_transaction.snapshot.status = status; + g_transaction.snapshot.stored_generation = + g_storage.snapshot().valid ? g_storage.snapshot().generation : 0; + g_transaction.snapshot.stored_crc = + g_storage.snapshot().valid ? g_storage.snapshot().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); +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 +} // 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 = ProfileServiceListSnapshot{}; - 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; + if (g_prepared) { + return; + } + critical_section_init(&g_lock); + g_metadata = {}; + __atomic_store_n(&g_published_generation, 0, __ATOMIC_RELAXED); + g_list = ProfileServiceListSnapshot{}; + 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_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); + if (!g_prepared) { + profile_service_prepare(); + } + const bool initialized = g_storage.initialize(pico_profile_storage_io()); + 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; + const ControllerIdentity &identity) { + if (!g_prepared || !identity.stable || + controller_identity_is_global(identity) || !valid_identity(identity)) { + return false; + } + critical_section_enter_blocking(&g_lock); + const bool ready = g_metadata.state == ProfileServiceState::kReady; + critical_section_exit(&g_lock); + if (!ready) { + return false; + } + const ProfileStorageResult result = g_storage.ensure_identity(identity); + if (result != ProfileStorageResult::kOk && + result != ProfileStorageResult::kUnchanged) { + return false; + } + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(ProfileServiceState::kReady); + critical_section_exit(&g_lock); + return true; } - 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]{}; + PendingCommand command{}; + bool process_write = false; + bool process_internal_activation = 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_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; } - critical_section_exit(&g_lock); + } + critical_section_exit(&g_lock); - if (!process_write && !process_identity && - !process_internal_activation && - command.type == PendingCommandType::kNone) { - return; - } + if (!process_write && !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); + ProfileStorageResult storage_result = ProfileStorageResult::kInvalidArgument; + if (process_write) { + ControllerProfile profile{}; + if (controller_profile_decode(write_payload, sizeof(write_payload), + &profile)) { + storage_result = + g_storage.set(write_identity, write_profile_index, profile); } + } else if (command.type == PendingCommandType::kReset) { + storage_result = g_storage.reset(command.identity, command.profile_index); + } else if (command.type == PendingCommandType::kActivate) { + storage_result = + g_storage.activate(command.identity, command.profile_index); + } else if (command.type == PendingCommandType::kSetAlias) { + storage_result = g_storage.set_alias( + command.identity, command.value, command.value_size); + } else if (command.type == PendingCommandType::kSetProfileName) { + storage_result = g_storage.set_profile_name( + command.identity, command.profile_index, + command.value, command.value_size); + } + const ConfigurationTransactionStatus status = + storage_result_status(storage_result); + if (status == ConfigurationTransactionStatus::kCommitted) { + g_has_committed = true; + g_last_commit_ms = now_ms; + } + 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_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 || - (transaction_id & kInternalTransactionIdMask) != 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; +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 status; + 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 || + (transaction_id & kInternalTransactionIdMask) != 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) { - if ((transaction_id & kInternalTransactionIdMask) != 0) { - return ConfigurationTransactionStatus::kMalformed; - } - 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; +ConfigurationTransactionStatus profile_service_append(uint32_t transaction_id, + size_t offset, + const uint8_t *data, + size_t size) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (g_transaction.snapshot.status != + ConfigurationTransactionStatus::kReceiving) { critical_section_exit(&g_lock); - return status; + 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) { - if ((transaction_id & kInternalTransactionIdMask) != 0) { - return ConfigurationTransactionStatus::kMalformed; - } - 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_commit(uint32_t transaction_id) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + 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 || - (transaction_id & kInternalTransactionIdMask) != 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; +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::kPending; + 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 || + (transaction_id & kInternalTransactionIdMask) != 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 || - (transaction_id & kInternalTransactionIdMask) != 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; +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::kPending; + 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 || + (transaction_id & kInternalTransactionIdMask) != 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 & kInternalTransactionIdMask) == 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; +ConfigurationTransactionStatus profile_service_set_metadata( + uint32_t transaction_id, const ControllerIdentity &identity, + uint8_t profile_index, const char *value, size_t value_size) { + 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::kPending; + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + bool malformed = + transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT) || + value_size > PROFILE_SERVICE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr); + for (size_t index = 0; index < value_size && !malformed; ++index) { + malformed = value[index] == '\0'; + } + if (malformed) { + 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 = profile_index == CONTROLLER_PROFILE_ALL + ? PendingCommandType::kSetAlias + : PendingCommandType::kSetProfileName; + g_command.identity = identity; + g_command.profile_index = profile_index; + g_command.value_size = static_cast(value_size); + if (value_size != 0) { + memcpy(g_command.value, value, value_size); + } + 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; +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 & kInternalTransactionIdMask) == 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_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_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_metadata_snapshot( + ProfileServiceMetadataSnapshot *output) { + if (output == nullptr) { + return; + } + *output = {}; + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + output->identity = g_selected.identity; + output->status = g_selected.status; + bool valid = + g_metadata.state == ProfileServiceState::kReady && + g_storage.get_alias( + g_selected.identity, output->alias, + sizeof(output->alias)) == ProfileStorageResult::kOk; + for (uint8_t profile = 0; + profile < CONTROLLER_PROFILE_COUNT && valid; ++profile) { + valid = g_storage.get_profile_name( + g_selected.identity, profile, + output->profile_names[profile], + sizeof(output->profile_names[profile])) == + ProfileStorageResult::kOk; + } + output->valid = valid; + if (!valid && output->status == + ConfigurationTransactionStatus::kCommitted) { + output->status = ConfigurationTransactionStatus::kStorageError; + } + 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); + 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); + 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; - } + 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_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; + } } - critical_section_exit(&g_lock); + output->profile_index = active->profile_index; + output->profile = active->profile; + output->valid = true; + } + critical_section_exit(&g_lock); } diff --git a/src/firmware/profile/profile_service.h b/src/firmware/profile/profile_service.h index 704308d..d14aba7 100644 --- a/src/firmware/profile/profile_service.h +++ b/src/firmware/profile/profile_service.h @@ -8,6 +8,7 @@ constexpr uint8_t PROFILE_SERVICE_LIST_CAPACITY = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; +constexpr size_t PROFILE_SERVICE_METADATA_MAX_BYTES = 31; enum class ProfileServiceState : uint8_t { kLoading = 0, @@ -23,6 +24,7 @@ struct ProfileServiceMetadata { struct ProfileServiceListRow { ControllerIdentity identity{}; + char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; uint8_t active_profile = 0; }; @@ -42,6 +44,17 @@ struct ProfileServiceSelectedSnapshot { ControllerProfile profile{}; }; +struct ProfileServiceMetadataSnapshot { + ProfileServiceMetadata metadata{}; + bool valid = false; + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; + ControllerIdentity identity{}; + char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; + char profile_names[CONTROLLER_PROFILE_COUNT] + [PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; +}; + struct ProfileServiceActiveProfileSnapshot { ProfileServiceMetadata metadata{}; bool valid = false; @@ -79,6 +92,9 @@ ConfigurationTransactionStatus profile_service_reset( ConfigurationTransactionStatus profile_service_activate( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t profile_index); +ConfigurationTransactionStatus profile_service_set_metadata( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index, const char* value, size_t value_size); // 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( @@ -88,6 +104,8 @@ ConfigurationTransactionStatus profile_service_activate_internal( void profile_service_list_snapshot(ProfileServiceListSnapshot* output); void profile_service_selected_snapshot( ProfileServiceSelectedSnapshot* output); +void profile_service_metadata_snapshot( + ProfileServiceMetadataSnapshot* output); void profile_service_transaction_snapshot( ProfileServiceTransactionSnapshot* output); uint32_t profile_service_database_generation(); diff --git a/src/firmware/profile/profile_storage.cpp b/src/firmware/profile/profile_storage.cpp index 068fd77..45180eb 100644 --- a/src/firmware/profile/profile_storage.cpp +++ b/src/firmware/profile/profile_storage.cpp @@ -1,251 +1,1079 @@ #include "profile/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; +constexpr uint8_t kSuperblockMagic[4] = {'S', 'P', 'C', 'A'}; +constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'C', 'R'}; +constexpr uint8_t kLegacyStorageMagic[4] = {'S', 'P', 'P', 'F'}; +constexpr uint8_t kLegacyDatabaseMagic[4] = {'S', 'P', 'D', 'B'}; +constexpr uint16_t kLegacyCatalogVersion = 1; +constexpr uint16_t kCatalogVersion = 2; +constexpr size_t kRecordPayloadOffset = 128; +constexpr size_t kLegacyRecordPayloadOffset = 256; +static_assert(kRecordPayloadOffset + CONTROLLER_PROFILE_ENCODED_SIZE == + PROFILE_STORAGE_RECORD_SIZE); +constexpr size_t kRecordHeaderCrcOffset = 34; +constexpr size_t kLegacyStart = + PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE; -uint16_t storage_read_u16(const uint8_t* input) { - return static_cast(input[0]) | - (static_cast(input[1]) << 8); +uint16_t 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); +uint32_t 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 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); +void 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))); - } +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) { + const uint32_t mask = 0u - (crc & 1u); + crc = (crc >> 1) ^ (0xedb88320u & mask); } - return crc; + } + return crc; } bool generation_is_newer(uint32_t candidate, uint32_t current) { - return static_cast(candidate - current) > 0; + 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); +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)); } -} // 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); +uint32_t pack_record(uint8_t arena, size_t offset) { + return static_cast(arena * PROFILE_STORAGE_ARENA_SIZE + offset); } -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; +uint8_t record_arena(uint32_t record) { + return static_cast(record / PROFILE_STORAGE_ARENA_SIZE); } -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; - } +size_t record_offset(uint32_t record) { + return record % PROFILE_STORAGE_ARENA_SIZE; +} - 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; +bool bytes_are(uint8_t value, const uint8_t *data, size_t size) { + for (size_t index = 0; index < size; ++index) { + if (data[index] != value) { + return false; } + } + return true; +} +bool metadata_value_valid(const uint8_t *payload, size_t size) { + return payload != nullptr && size == PROFILE_STORAGE_METADATA_PAYLOAD_SIZE && + payload[0] <= PROFILE_STORAGE_METADATA_MAX_BYTES && + bytes_are(0, &payload[payload[0] + 1], + size - payload[0] - 1); +} + +} // 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) { + io_ = io; + snapshot_ = {}; + identity_count_ = 0; + epoch_ = 0; + catalog_version_ = 0; + next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + for (ProfileStorageIdentityIndex &entry : index_) { + entry = {}; + } + initialized_ = io_.read != nullptr && io_.erase_arena != nullptr && + io_.program_page != nullptr && + io_.arena_size == PROFILE_STORAGE_ARENA_SIZE && + io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE && + io_.page_size == PROFILE_STORAGE_PAGE_SIZE; + if (!initialized_) { + return false; + } + + for (uint8_t arena = 0; arena < PROFILE_STORAGE_ARENA_COUNT; ++arena) { + ProfileStorageIdentityIndex + candidate[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t count = 0; + uint16_t version = 0; + uint32_t epoch = 0; + uint32_t generation = 0; + uint32_t payload_crc = 0; + size_t offset = 0; + const ProfileStorageResult scanned = + scan_arena(arena, &version, &epoch, &generation, &payload_crc, + &offset, candidate, &count); + if (scanned == ProfileStorageResult::kIoError) { + initialized_ = false; + return false; + } + if (scanned != ProfileStorageResult::kOk || + (snapshot_.valid && !generation_is_newer(epoch, epoch_))) { + continue; + } + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = candidate[index]; + } + identity_count_ = count; + epoch_ = epoch; + catalog_version_ = version; + next_offset_ = offset; snapshot_.valid = true; + snapshot_.active_bank = arena; 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; + snapshot_.payload_crc = payload_crc; + } + if (snapshot_.valid) { + if (catalog_version_ != kCatalogVersion && + compact() != ProfileStorageResult::kOk) { + initialized_ = false; + 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; + } + + const ProfileStorageResult migrated = migrate_legacy(); + if (migrated != ProfileStorageResult::kUnchanged) { + initialized_ = migrated == ProfileStorageResult::kOk; + return initialized_; + } + // Only pristine storage can become an empty catalog. In particular, an + // unreadable or interrupted migration must never erase an old database. + uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{}; + for (uint8_t arena = 0; arena < PROFILE_STORAGE_ARENA_COUNT; ++arena) { + for (size_t offset = 0; offset < PROFILE_STORAGE_ARENA_SIZE; + offset += sizeof(page)) { + if (!io_.read(io_.context, arena, offset, page, sizeof(page)) || + !bytes_are(0xff, page, sizeof(page))) { + initialized_ = false; + return false; + } + } + } + identity_count_ = 1; + index_[0].used = true; + index_[0].identity = controller_identity_global(); + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + index_[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + initialized_ = publish_empty_arena(0, 1); + return initialized_; } -bool ProfileStorage::validate_payload(uint8_t bank, - uint32_t expected_crc) const { +ProfileStorageResult +ProfileStorage::ensure_identity(const ControllerIdentity &identity) { + if (!initialized_ || !valid_identity(identity)) { + return ProfileStorageResult::kInvalidArgument; + } + if (find(identity) != nullptr) { + return ProfileStorageResult::kUnchanged; + } + if (identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kActivate, identity, 0, nullptr, 0); +} + +ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity, + uint8_t profile_index, + ControllerProfile *output) const { + if (!initialized_ || output == nullptr || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) { + *output = controller_profile_default(identity, profile_index); + return ProfileStorageResult::kOk; + } + return read_profile_record(entry->profile_record[profile_index], output) + ? ProfileStorageResult::kOk + : ProfileStorageResult::kIoError; +} + +ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity, + uint8_t profile_index, + const ControllerProfile &profile) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT || + !controller_profile_validate(profile)) { + return ProfileStorageResult::kInvalidArgument; + } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (!controller_profile_encode(profile, encoded, sizeof(encoded))) { + return ProfileStorageResult::kInvalidArgument; + } + ControllerProfile current{}; + uint8_t current_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (get(identity, profile_index, ¤t) == ProfileStorageResult::kOk && + controller_profile_encode(current, current_encoded, + sizeof(current_encoded)) && + memcmp(encoded, current_encoded, sizeof(encoded)) == 0) { + return ProfileStorageResult::kUnchanged; + } + if (find(identity) == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kProfile, identity, profile_index, encoded, + sizeof(encoded)); +} + +ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity, + uint8_t profile_index) { + if (!initialized_ || !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr) { + return ProfileStorageResult::kUnchanged; + } + if (profile_index == CONTROLLER_PROFILE_ALL) { + bool changed = entry->active_profile != 0; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + changed = changed || + entry->profile_record[profile] != PROFILE_STORAGE_NO_RECORD; + } + return changed ? append(RecordType::kResetAll, identity, + CONTROLLER_PROFILE_ALL, nullptr, 0) + : ProfileStorageResult::kUnchanged; + } + if (entry->profile_record[profile_index] == PROFILE_STORAGE_NO_RECORD) { + return ProfileStorageResult::kUnchanged; + } + return append(RecordType::kReset, identity, profile_index, nullptr, 0); +} + +ProfileStorageResult +ProfileStorage::activate(const ControllerIdentity &identity, + uint8_t profile_index) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry != nullptr && entry->active_profile == profile_index) { + return ProfileStorageResult::kUnchanged; + } + if (entry == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + return append(RecordType::kActivate, identity, profile_index, nullptr, 0); +} + +ProfileStorageResult ProfileStorage::get_alias( + const ControllerIdentity &identity, char *output, + size_t output_size) const { + if (!initialized_ || output == nullptr || output_size == 0 || + !valid_identity(identity)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->alias_record == PROFILE_STORAGE_NO_RECORD) { + output[0] = '\0'; + return ProfileStorageResult::kOk; + } + return read_metadata_record(entry->alias_record, output, output_size) + ? ProfileStorageResult::kOk + : ProfileStorageResult::kIoError; +} + +ProfileStorageResult ProfileStorage::set_alias( + const ControllerIdentity &identity, const char *value, + size_t value_size) { + if (!initialized_ || !valid_identity(identity) || + value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr)) { + return ProfileStorageResult::kInvalidArgument; + } + char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (find(identity) == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + if (get_alias(identity, current, sizeof(current)) == + ProfileStorageResult::kOk && + strlen(current) == value_size && + (value_size == 0 || memcmp(current, value, value_size) == 0)) { + return ProfileStorageResult::kUnchanged; + } + uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + payload[0] = static_cast(value_size); + if (value_size != 0) { + memcpy(&payload[1], value, value_size); + } + return append(RecordType::kAlias, identity, CONTROLLER_PROFILE_ALL, + payload, sizeof(payload)); +} + +ProfileStorageResult ProfileStorage::get_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + char *output, size_t output_size) const { + if (!initialized_ || output == nullptr || output_size == 0 || + !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->profile_names_record == PROFILE_STORAGE_NO_RECORD) { + output[0] = '\0'; + return ProfileStorageResult::kOk; + } + uint8_t payload[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{}; + if (!read_record_payload(entry->profile_names_record, + RecordType::kProfileNames, payload, + sizeof(payload))) { + return ProfileStorageResult::kIoError; + } + const size_t offset = + profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; + const size_t size = payload[offset]; + if (size > PROFILE_STORAGE_METADATA_MAX_BYTES || + output_size <= size) { + return ProfileStorageResult::kInvalidArgument; + } + memcpy(output, &payload[offset + 1], size); + output[size] = '\0'; + return ProfileStorageResult::kOk; +} + +ProfileStorageResult ProfileStorage::set_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + const char *value, size_t value_size) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT || + value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (get_profile_name(identity, profile_index, current, + sizeof(current)) == ProfileStorageResult::kOk && + strlen(current) == value_size && + (value_size == 0 || memcmp(current, value, value_size) == 0)) { + return ProfileStorageResult::kUnchanged; + } + uint8_t payload[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{}; + if (entry != nullptr && + entry->profile_names_record != PROFILE_STORAGE_NO_RECORD && + !read_record_payload(entry->profile_names_record, + RecordType::kProfileNames, payload, + sizeof(payload))) { + return ProfileStorageResult::kIoError; + } + const size_t offset = + profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; + memset(&payload[offset], 0, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE); + payload[offset] = static_cast(value_size); + if (value_size != 0) { + memcpy(&payload[offset + 1], value, value_size); + } + return append(RecordType::kProfileNames, identity, + CONTROLLER_PROFILE_ALL, payload, sizeof(payload)); +} + +uint8_t ProfileStorage::identity_count() const { return identity_count_; } + +const ProfileStorageIdentityIndex * +ProfileStorage::identity(uint8_t index) const { + return index < identity_count_ ? &index_[index] : nullptr; +} + +const ProfileStorageIdentityIndex * +ProfileStorage::find(const ControllerIdentity &identity) const { + for (uint8_t index = 0; index < identity_count_; ++index) { + if (index_[index].used && + controller_identity_equal(index_[index].identity, identity)) { + return &index_[index]; + } + } + return nullptr; +} + +const ProfileStorageSnapshot &ProfileStorage::snapshot() const { + return snapshot_; +} + +ProfileStorageResult ProfileStorage::scan_arena( + uint8_t arena, uint16_t *version, uint32_t *epoch, uint32_t *generation, + uint32_t *payload_crc, size_t *next_offset, + ProfileStorageIdentityIndex *index, uint8_t *identity_count) const { + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + if (!io_.read(io_.context, arena, 0, superblock, sizeof(superblock))) { + return ProfileStorageResult::kIoError; + } + *version = read_u16(&superblock[4]); + if (memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 || + (*version != kCatalogVersion && *version != kLegacyCatalogVersion) || + profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) || + !bytes_are(0, &superblock[16], sizeof(superblock) - 16)) { + return ProfileStorageResult::kUnchanged; + } + *epoch = read_u32(&superblock[8]); + *generation = 0; + *payload_crc = 0; + *next_offset = PROFILE_STORAGE_RECORDS_OFFSET; + *identity_count = 1; + index[0].used = true; + index[0].identity = controller_identity_global(); + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + index[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + + bool have_generation = false; + for (size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; + offset + PROFILE_STORAGE_RECORD_SIZE <= PROFILE_STORAGE_ARENA_SIZE; + offset += PROFILE_STORAGE_RECORD_SIZE) { + uint8_t record[PROFILE_STORAGE_RECORD_SIZE]{}; + if (!io_.read(io_.context, arena, offset, record, sizeof(record))) { + return ProfileStorageResult::kIoError; + } + // The second page is written first. Even a partial program anywhere in + // that page consumes the slot when the header page is still erased. + if (!bytes_are(0xff, record, sizeof(record))) { + *next_offset = offset + PROFILE_STORAGE_RECORD_SIZE; + } + if (!validate_record(record, *version)) { + continue; + } + ControllerIdentity identity_value{}; + controller_identity_decode(&record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity_value); + const uint32_t record_generation = read_u32(&record[8]); + apply_record(index, identity_count, static_cast(record[6]), + identity_value, record[7], record_generation, + pack_record(arena, offset)); + if (!have_generation || generation_is_newer(record_generation, *generation)) { + have_generation = true; + *generation = record_generation; + *payload_crc = read_u32(&record[16]); + } + } + return ProfileStorageResult::kOk; +} + +bool ProfileStorage::validate_record(const uint8_t *record, + uint16_t version) const { + const size_t payload_offset = version == kLegacyCatalogVersion + ? kLegacyRecordPayloadOffset + : kRecordPayloadOffset; + if (memcmp(record, kRecordMagic, sizeof(kRecordMagic)) != 0 || + read_u16(&record[4]) != version || + profile_storage_crc32(record, kRecordHeaderCrcOffset) != + read_u32(&record[kRecordHeaderCrcOffset]) || + !bytes_are(0, &record[kRecordHeaderCrcOffset + 4], + payload_offset - kRecordHeaderCrcOffset - 4)) { + return false; + } + const auto type = static_cast(record[6]); + const uint8_t profile_index = record[7]; + const size_t payload_size = read_u16(&record[12]); + const uint8_t *payload = &record[payload_offset]; + ControllerIdentity identity_value{}; + const bool known_type = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kResetAll || type == RecordType::kActivate || + type == RecordType::kAlias || type == RecordType::kProfileNames; + const bool indexed_profile = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kActivate; + const size_t expected_payload_size = + type == RecordType::kProfile + ? (version == kLegacyCatalogVersion + ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE + : CONTROLLER_PROFILE_ENCODED_SIZE) + : type == RecordType::kProfileNames + ? PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE + : type == RecordType::kAlias + ? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE + : 0; + if (!controller_identity_decode( + &record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || + !valid_identity(identity_value) || !known_type || + (indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) || + payload_size != expected_payload_size || + profile_storage_crc32(payload, payload_size) != read_u32(&record[16])) { + return false; + } + if (type == RecordType::kProfile) { + ControllerProfile decoded{}; + return read_u16(&record[14]) == read_u16(payload) && + controller_profile_decode(payload, payload_size, &decoded); + } + if (read_u16(&record[14]) != 0) { + return false; + } + if (type == RecordType::kAlias) { + return metadata_value_valid(payload, payload_size); + } + if (type == RecordType::kProfileNames) { + for (size_t name = 0; name < CONTROLLER_PROFILE_COUNT; ++name) { + if (!metadata_value_valid( + &payload[name * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE], + PROFILE_STORAGE_METADATA_PAYLOAD_SIZE)) { + return false; + } + } + } + return true; +} + +bool ProfileStorage::read_record_payload( + uint32_t record, RecordType type, uint8_t *output, size_t capacity, + size_t *size) const { + uint8_t header[kRecordHeaderCrcOffset + 4]{}; + if (record == PROFILE_STORAGE_NO_RECORD || + !io_.read(io_.context, record_arena(record), record_offset(record), + header, sizeof(header)) || + memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || + header[6] != static_cast(type) || + profile_storage_crc32(header, kRecordHeaderCrcOffset) != + read_u32(&header[kRecordHeaderCrcOffset])) { + return false; + } + const uint16_t version = read_u16(&header[4]); + const size_t payload_offset = version == kLegacyCatalogVersion + ? kLegacyRecordPayloadOffset + : kRecordPayloadOffset; + const size_t payload_size = read_u16(&header[12]); + if ((version != kCatalogVersion && version != kLegacyCatalogVersion) || + payload_size > capacity || + payload_size > PROFILE_STORAGE_RECORD_SIZE - payload_offset || + !io_.read(io_.context, record_arena(record), + record_offset(record) + payload_offset, output, payload_size) || + profile_storage_crc32(output, payload_size) != read_u32(&header[16])) { + return false; + } + if (size != nullptr) { + *size = payload_size; + } + return true; +} + +bool ProfileStorage::read_profile_record(uint32_t record, + ControllerProfile *output) const { + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + size_t size = 0; + return read_record_payload(record, RecordType::kProfile, encoded, + sizeof(encoded), &size) && + controller_profile_decode(encoded, size, output); +} + +bool ProfileStorage::read_metadata_record( + uint32_t record, char *output, size_t output_size) const { + uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (!read_record_payload(record, RecordType::kAlias, payload, + sizeof(payload)) || + payload[0] > PROFILE_STORAGE_METADATA_MAX_BYTES || + output_size <= payload[0]) { + return false; + } + memcpy(output, &payload[1], payload[0]); + output[payload[0]] = '\0'; + return true; +} + +ProfileStorageResult ProfileStorage::append(RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, + const uint8_t *payload, + size_t payload_size) { + if (next_offset_ + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE) { + const ProfileStorageResult result = compact(); + if (result != ProfileStorageResult::kOk) { + return result; + } + } + const uint32_t generation = snapshot_.generation + 1u; + const size_t offset = next_offset_; + next_offset_ += PROFILE_STORAGE_RECORD_SIZE; + if (!write_record(snapshot_.active_bank, offset, type, identity, + profile_index, generation, payload, payload_size)) { + return ProfileStorageResult::kIoError; + } + apply_record(index_, &identity_count_, type, identity, profile_index, + generation, pack_record(snapshot_.active_bank, offset)); + snapshot_.generation = generation; + snapshot_.payload_crc = profile_storage_crc32(payload, payload_size); + return ProfileStorageResult::kOk; +} + +ProfileStorageResult ProfileStorage::compact() { + const uint8_t target = snapshot_.active_bank ^ 1u; + if (!io_.erase_arena(io_.context, target)) { + return ProfileStorageResult::kIoError; + } + size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; + uint32_t generation = snapshot_.generation; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + for (uint8_t identity_index = 0; identity_index < identity_count_; + ++identity_index) { + const ProfileStorageIdentityIndex &entry = index_[identity_index]; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + if (entry.profile_record[profile] == PROFILE_STORAGE_NO_RECORD) { + continue; + } + ControllerProfile decoded{}; + if (!read_profile_record(entry.profile_record[profile], &decoded) || + !controller_profile_encode(decoded, encoded, sizeof(encoded)) || + !write_record(target, offset, RecordType::kProfile, entry.identity, + profile, ++generation, encoded, sizeof(encoded))) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + uint8_t metadata[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{}; + if (entry.alias_record != PROFILE_STORAGE_NO_RECORD) { + if (!read_record_payload(entry.alias_record, RecordType::kAlias, + metadata, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE) || + !write_record( + target, offset, RecordType::kAlias, entry.identity, + CONTROLLER_PROFILE_ALL, ++generation, metadata, + PROFILE_STORAGE_METADATA_PAYLOAD_SIZE)) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (entry.profile_names_record != PROFILE_STORAGE_NO_RECORD) { + if (!read_record_payload(entry.profile_names_record, + RecordType::kProfileNames, metadata, + sizeof(metadata)) || + !write_record( + target, offset, RecordType::kProfileNames, + entry.identity, CONTROLLER_PROFILE_ALL, ++generation, + metadata, sizeof(metadata))) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(target, offset, RecordType::kActivate, entry.identity, + entry.active_profile, ++generation, nullptr, 0)) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + + if (!publish_arena(target, epoch_ + 1u, offset)) { + return ProfileStorageResult::kIoError; + } + + ProfileStorageIdentityIndex + rebuilt[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t rebuilt_count = 0; + uint16_t rebuilt_version = 0; + uint32_t rebuilt_epoch = 0; + uint32_t rebuilt_generation = 0; + uint32_t rebuilt_payload_crc = 0; + size_t rebuilt_offset = 0; + if (scan_arena(target, &rebuilt_version, &rebuilt_epoch, + &rebuilt_generation, &rebuilt_payload_crc, &rebuilt_offset, + rebuilt, &rebuilt_count) != ProfileStorageResult::kOk || + rebuilt_count != identity_count_ || rebuilt_offset != offset || + rebuilt_generation != generation) { + return ProfileStorageResult::kIoError; + } + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = rebuilt[index]; + } + identity_count_ = rebuilt_count; + epoch_ = rebuilt_epoch; + catalog_version_ = rebuilt_version; + next_offset_ = rebuilt_offset; + snapshot_.active_bank = target; + snapshot_.generation = rebuilt_generation; + snapshot_.payload_crc = rebuilt_payload_crc; + snapshot_.valid = true; + return ProfileStorageResult::kOk; +} + +ProfileStorageResult ProfileStorage::migrate_legacy() { + struct LegacyHeader { + bool valid = false; + uint32_t generation = 0; + uint32_t crc = 0; + uint8_t bank = 0; + } headers[PROFILE_STORAGE_LEGACY_BANK_COUNT]{}; + const uint8_t arena = + static_cast(kLegacyStart / PROFILE_STORAGE_ARENA_SIZE); + const size_t arena_base = kLegacyStart % PROFILE_STORAGE_ARENA_SIZE; + for (uint8_t bank = 0; bank < PROFILE_STORAGE_LEGACY_BANK_COUNT; ++bank) { + uint8_t header[PROFILE_STORAGE_LEGACY_HEADER_SIZE]{}; + const size_t base = arena_base + bank * PROFILE_STORAGE_LEGACY_BANK_SIZE; + if (!io_.read(io_.context, arena, base, header, sizeof(header))) { + return ProfileStorageResult::kIoError; + } + if (memcmp(header, kLegacyStorageMagic, 4) != 0 || + read_u16(&header[4]) != 1 || + (read_u16(&header[6]) != 1 && read_u16(&header[6]) != 2) || + read_u32(&header[12]) != PROFILE_STORAGE_LEGACY_DATABASE_SIZE || + profile_storage_crc32(header, 20) != read_u32(&header[20]) || + !bytes_are(0, &header[24], sizeof(header) - 24)) { + continue; + } 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); + size_t remaining = PROFILE_STORAGE_LEGACY_DATABASE_SIZE; + size_t payload_offset = base + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + while (remaining != 0) { + const size_t size = remaining < sizeof(page) ? remaining : sizeof(page); + if (!io_.read(io_.context, arena, payload_offset, page, size)) { + return ProfileStorageResult::kIoError; + } + crc = crc32_update(crc, page, size); + payload_offset += size; + remaining -= size; } - return ~crc == expected_crc; + if (remaining == 0 && ~crc == read_u32(&header[16])) { + headers[bank] = {true, read_u32(&header[8]), read_u32(&header[16]), bank}; + } + } + int selected = -1; + for (uint8_t bank = 0; bank < PROFILE_STORAGE_LEGACY_BANK_COUNT; ++bank) { + if (headers[bank].valid && + (selected < 0 || generation_is_newer(headers[bank].generation, + headers[selected].generation))) { + selected = bank; + } + } + if (selected < 0) { + return ProfileStorageResult::kUnchanged; + } + + const size_t legacy_base = + arena_base + + static_cast(selected) * PROFILE_STORAGE_LEGACY_BANK_SIZE + + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + uint8_t database_header[32]{}; + if (!io_.read(io_.context, arena, legacy_base, database_header, + sizeof(database_header)) || + memcmp(database_header, kLegacyDatabaseMagic, 4) != 0 || + (read_u16(&database_header[4]) != 1 && + read_u16(&database_header[4]) != 2) || + read_u16(&database_header[6]) != PROFILE_STORAGE_LEGACY_DATABASE_SIZE || + database_header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY || + database_header[9] != PROFILE_STORAGE_LEGACY_PROFILE_COUNT || + database_header[10] >= PROFILE_STORAGE_LEGACY_PROFILE_COUNT) { + return ProfileStorageResult::kIoError; + } + if (!io_.erase_arena(io_.context, 0)) { + return ProfileStorageResult::kIoError; + } + size_t target_offset = PROFILE_STORAGE_RECORDS_OFFSET; + uint32_t generation = headers[selected].generation; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + const ControllerIdentity global = controller_identity_global(); + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + const size_t source = + legacy_base + 32 + profile * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + if (!io_.read(io_.context, arena, source, encoded, + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE) || + !controller_profile_decode(encoded, + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE, + &decoded) || + !controller_profile_encode(decoded, encoded, sizeof(encoded)) || + !write_record(0, target_offset, RecordType::kProfile, global, profile, + ++generation, encoded, sizeof(encoded))) { + return ProfileStorageResult::kIoError; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(0, target_offset, RecordType::kActivate, global, + database_header[10], ++generation, nullptr, 0)) { + return ProfileStorageResult::kIoError; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + + constexpr size_t kLegacyEntrySize = + 16 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + const size_t entries_base = + legacy_base + 32 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + for (uint8_t entry_index = 0; + entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++entry_index) { + const size_t entry_base = entries_base + entry_index * kLegacyEntrySize; + uint8_t entry_header[16]{}; + if (!io_.read(io_.context, arena, entry_base, entry_header, + sizeof(entry_header))) { + return ProfileStorageResult::kIoError; + } + if (entry_header[15] == 0) { + continue; + } + ControllerIdentity identity_value{}; + if (entry_header[15] != 1 || + entry_header[14] >= PROFILE_STORAGE_LEGACY_PROFILE_COUNT || + !controller_identity_decode( + entry_header, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || + !identity_value.stable || + controller_identity_is_global(identity_value)) { + return ProfileStorageResult::kIoError; + } + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + const size_t source = + entry_base + 16 + profile * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + if (!io_.read(io_.context, arena, source, encoded, + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE) || + !controller_profile_decode(encoded, + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE, + &decoded) || + !controller_profile_encode(decoded, encoded, sizeof(encoded)) || + !write_record(0, target_offset, RecordType::kProfile, identity_value, + profile, ++generation, encoded, sizeof(encoded))) { + return ProfileStorageResult::kIoError; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (!write_record(0, target_offset, RecordType::kActivate, identity_value, + entry_header[14], ++generation, nullptr, 0)) { + return ProfileStorageResult::kIoError; + } + target_offset += PROFILE_STORAGE_RECORD_SIZE; + } + + if (!publish_arena(0, 1, target_offset)) { + return ProfileStorageResult::kIoError; + } + + ProfileStorageIdentityIndex + rebuilt[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t rebuilt_count = 0; + uint16_t rebuilt_version = 0; + uint32_t rebuilt_epoch = 0; + uint32_t rebuilt_generation = 0; + uint32_t rebuilt_payload_crc = 0; + size_t rebuilt_offset = 0; + if (scan_arena(0, &rebuilt_version, &rebuilt_epoch, &rebuilt_generation, + &rebuilt_payload_crc, &rebuilt_offset, rebuilt, + &rebuilt_count) != ProfileStorageResult::kOk || + rebuilt_offset != target_offset || rebuilt_generation != generation) { + return ProfileStorageResult::kIoError; + } + for (size_t index = 0; + index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; ++index) { + index_[index] = rebuilt[index]; + } + identity_count_ = rebuilt_count; + epoch_ = rebuilt_epoch; + catalog_version_ = rebuilt_version; + next_offset_ = rebuilt_offset; + snapshot_.valid = true; + snapshot_.active_bank = 0; + snapshot_.generation = rebuilt_generation; + snapshot_.payload_crc = rebuilt_payload_crc; + return ProfileStorageResult::kOk; } -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::publish_arena(uint8_t arena, uint32_t epoch, + size_t records_end) const { + // Verify every copied record before making the new arena discoverable. + // The old published arena remains untouched throughout this operation. + for (size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; offset < records_end; + offset += PROFILE_STORAGE_RECORD_SIZE) { + uint8_t record[PROFILE_STORAGE_RECORD_SIZE]{}; + if (!io_.read(io_.context, arena, offset, record, sizeof(record)) || + !validate_record(record, kCatalogVersion)) { + return false; + } + } + uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)); + write_u16(&superblock[4], kCatalogVersion); + write_u32(&superblock[8], epoch); + write_u32(&superblock[12], profile_storage_crc32(superblock, 12)); + uint8_t verified[PROFILE_STORAGE_PAGE_SIZE]{}; + return io_.program_page(io_.context, arena, 0, superblock, + sizeof(superblock)) && + io_.read(io_.context, arena, 0, verified, sizeof(verified)) && + memcmp(superblock, verified, sizeof(superblock)) == 0; } -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; +bool ProfileStorage::publish_empty_arena(uint8_t arena, uint32_t epoch) { + if (!io_.erase_arena(io_.context, arena)) { + return false; + } + if (!publish_arena(arena, epoch, PROFILE_STORAGE_RECORDS_OFFSET)) { + return false; + } + epoch_ = epoch; + catalog_version_ = kCatalogVersion; + next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + snapshot_.valid = true; + snapshot_.active_bank = arena; + snapshot_.generation = 0; + snapshot_.payload_crc = 0; + return true; +} + +bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + const uint8_t *payload, + size_t payload_size) const { + const size_t expected_payload_size = + type == RecordType::kProfile + ? CONTROLLER_PROFILE_ENCODED_SIZE + : type == RecordType::kProfileNames + ? PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE + : type == RecordType::kAlias + ? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE + : 0; + if (arena >= PROFILE_STORAGE_ARENA_COUNT || + offset < PROFILE_STORAGE_RECORDS_OFFSET || + offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE || + offset % PROFILE_STORAGE_RECORD_SIZE != 0 || + payload_size != expected_payload_size || + (payload_size != 0 && payload == nullptr)) { + return false; + } + uint8_t record[PROFILE_STORAGE_RECORD_SIZE]{}; + memcpy(record, kRecordMagic, sizeof(kRecordMagic)); + write_u16(&record[4], kCatalogVersion); + record[6] = static_cast(type); + record[7] = profile_index; + write_u32(&record[8], generation); + write_u16(&record[12], static_cast(payload_size)); + write_u16(&record[14], type == RecordType::kProfile ? read_u16(payload) : 0); + write_u32(&record[16], profile_storage_crc32(payload, payload_size)); + if (!controller_identity_encode(identity, &record[20], + CONTROLLER_IDENTITY_ENCODED_SIZE)) { + return false; + } + write_u32(&record[kRecordHeaderCrcOffset], + profile_storage_crc32(record, kRecordHeaderCrcOffset)); + if (payload_size != 0) { + memcpy(&record[kRecordPayloadOffset], payload, payload_size); + } + // Publish the header-containing page last, including for short metadata and + // zero-payload records, so every interrupted slot is detectably consumed. + if (!io_.program_page(io_.context, arena, offset + PROFILE_STORAGE_PAGE_SIZE, + &record[PROFILE_STORAGE_PAGE_SIZE], + PROFILE_STORAGE_PAGE_SIZE) || + !io_.program_page(io_.context, arena, offset, record, + PROFILE_STORAGE_PAGE_SIZE)) { + return false; + } + uint8_t verified[PROFILE_STORAGE_RECORD_SIZE]{}; + return io_.read(io_.context, arena, offset, verified, sizeof(verified)) && + memcmp(record, verified, sizeof(record)) == 0; +} + +void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, + uint8_t *identity_count, RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + uint32_t record) const { + ProfileStorageIdentityIndex *entry = nullptr; + for (uint8_t current = 0; current < *identity_count; ++current) { + if (index[current].used && + controller_identity_equal(index[current].identity, identity)) { + entry = &index[current]; + break; + } + } + if (entry == nullptr) { + if (*identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return; + } + entry = &index[(*identity_count)++]; + *entry = {}; + entry->used = true; + entry->identity = identity; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + entry->profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + } + } + if (type == RecordType::kActivate) { + if (entry->active_generation == 0 || + generation_is_newer(generation, entry->active_generation)) { + entry->active_profile = profile_index; + entry->active_generation = generation; + } + return; + } + if (type == RecordType::kAlias) { + if (entry->alias_generation == 0 || + generation_is_newer(generation, entry->alias_generation)) { + entry->alias_record = record; + entry->alias_generation = generation; + } + return; + } + if (type == RecordType::kProfileNames) { + if (entry->profile_names_generation == 0 || + generation_is_newer( + generation, entry->profile_names_generation)) { + entry->profile_names_record = record; + entry->profile_names_generation = generation; + } + return; + } + if (type == RecordType::kResetAll) { + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + if (entry->profile_generation[profile] == 0 || + generation_is_newer(generation, entry->profile_generation[profile])) { + entry->profile_record[profile] = PROFILE_STORAGE_NO_RECORD; + entry->profile_generation[profile] = generation; + } + } + if (entry->active_generation == 0 || + generation_is_newer(generation, entry->active_generation)) { + entry->active_profile = 0; + entry->active_generation = generation; + } + return; + } + if (entry->profile_generation[profile_index] == 0 || + generation_is_newer(generation, + entry->profile_generation[profile_index])) { + entry->profile_record[profile_index] = + type == RecordType::kProfile ? record : PROFILE_STORAGE_NO_RECORD; + entry->profile_generation[profile_index] = generation; + } } diff --git a/src/firmware/profile/profile_storage.h b/src/firmware/profile/profile_storage.h index b793e6b..1342c9c 100644 --- a/src/firmware/profile/profile_storage.h +++ b/src/firmware/profile/profile_storage.h @@ -5,79 +5,167 @@ #include "profile/controller_profile.h" -constexpr uint8_t PROFILE_STORAGE_BANK_COUNT = 2; +constexpr uint8_t PROFILE_STORAGE_ARENA_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; +constexpr size_t PROFILE_STORAGE_ARENA_SIZE = 128 * 1024; +constexpr size_t PROFILE_STORAGE_TOTAL_SIZE = + PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE; +constexpr size_t PROFILE_STORAGE_SUPERBLOCK_SIZE = PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t PROFILE_STORAGE_RECORD_SIZE = 2 * PROFILE_STORAGE_PAGE_SIZE; +constexpr size_t PROFILE_STORAGE_RECORDS_OFFSET = PROFILE_STORAGE_SECTOR_SIZE; +constexpr uint32_t PROFILE_STORAGE_NO_RECORD = UINT32_MAX; +constexpr uint8_t PROFILE_STORAGE_METADATA_MAX_BYTES = 31; +constexpr size_t PROFILE_STORAGE_METADATA_PAYLOAD_SIZE = + PROFILE_STORAGE_METADATA_MAX_BYTES + 1; +constexpr size_t PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE = + CONTROLLER_PROFILE_COUNT * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; +constexpr size_t PROFILE_STORAGE_MAX_LIVE_RECORDS = + (CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) * + (CONTROLLER_PROFILE_COUNT + 3); +constexpr size_t PROFILE_STORAGE_RECORD_CAPACITY = + (PROFILE_STORAGE_ARENA_SIZE - PROFILE_STORAGE_RECORDS_OFFSET) / + PROFILE_STORAGE_RECORD_SIZE; +static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS <= + PROFILE_STORAGE_RECORD_CAPACITY, + "all live profile records must fit during compaction"); +static_assert(PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE == 256); +static_assert(PROFILE_STORAGE_RECORD_CAPACITY == 248); +static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS == 187); -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"); +// Layout of the retired v1/v2 whole-database store. The indexed catalog reads +// this region once during migration; new firmware never writes it. +constexpr uint8_t PROFILE_STORAGE_LEGACY_BANK_COUNT = 2; +constexpr size_t PROFILE_STORAGE_LEGACY_BANK_SIZE = 20 * 1024; +constexpr size_t PROFILE_STORAGE_LEGACY_TOTAL_SIZE = + PROFILE_STORAGE_LEGACY_BANK_COUNT * PROFILE_STORAGE_LEGACY_BANK_SIZE; +constexpr size_t PROFILE_STORAGE_LEGACY_HEADER_SIZE = PROFILE_STORAGE_PAGE_SIZE; +constexpr uint8_t PROFILE_STORAGE_LEGACY_PROFILE_COUNT = 4; +constexpr size_t PROFILE_STORAGE_LEGACY_DATABASE_SIZE = 17696; -enum class ProfileStorageResult : uint8_t { - kOk = 0, - kUnchanged = 1, - kInvalidArgument = 2, - kIoError = 3, -}; +static_assert(PROFILE_STORAGE_ARENA_SIZE % PROFILE_STORAGE_SECTOR_SIZE == 0); +static_assert(PROFILE_STORAGE_RECORDS_OFFSET % PROFILE_STORAGE_RECORD_SIZE == + 0); 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; + void *context = nullptr; + size_t arena_size = 0; + size_t sector_size = 0; + size_t page_size = 0; + bool (*read)(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) = nullptr; + bool (*erase_arena)(void *context, uint8_t arena) = nullptr; + bool (*program_page)(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) = nullptr; }; struct ProfileStorageSnapshot { - bool valid = false; - uint32_t generation = 0; - uint32_t payload_crc = 0; - uint8_t active_bank = 0; + 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); +enum class ProfileStorageResult : uint8_t { + kOk = 0, + kUnchanged = 1, + kInvalidArgument = 2, + kIoError = 3, + kFull = 4, +}; + +struct ProfileStorageIdentityIndex { + bool used = false; + ControllerIdentity identity{}; + uint8_t active_profile = 0; + uint32_t active_generation = 0; + uint32_t profile_generation[CONTROLLER_PROFILE_COUNT]{}; + uint32_t profile_record[CONTROLLER_PROFILE_COUNT]{}; + uint32_t alias_generation = 0; + uint32_t alias_record = PROFILE_STORAGE_NO_RECORD; + uint32_t profile_names_generation = 0; + uint32_t profile_names_record = PROFILE_STORAGE_NO_RECORD; +}; + +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; + bool initialize(const ProfileStorageIo &io); + ProfileStorageResult ensure_identity(const ControllerIdentity &identity); + ProfileStorageResult get(const ControllerIdentity &identity, + uint8_t profile_index, + ControllerProfile *output) const; + ProfileStorageResult set(const ControllerIdentity &identity, + uint8_t profile_index, + const ControllerProfile &profile); + ProfileStorageResult reset(const ControllerIdentity &identity, + uint8_t profile_index); + ProfileStorageResult activate(const ControllerIdentity &identity, + uint8_t profile_index); + ProfileStorageResult get_alias( + const ControllerIdentity &identity, char *output, + size_t output_size) const; + ProfileStorageResult set_alias( + const ControllerIdentity &identity, const char *value, + size_t value_size); + ProfileStorageResult get_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + char *output, size_t output_size) const; + ProfileStorageResult set_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + const char *value, size_t value_size); + + uint8_t identity_count() const; + const ProfileStorageIdentityIndex *identity(uint8_t index) const; + const ProfileStorageIdentityIndex * + find(const ControllerIdentity &identity) const; + const ProfileStorageSnapshot &snapshot() const; private: - struct BankHeader { - uint32_t generation = 0; - uint32_t payload_crc = 0; - }; + enum class RecordType : uint8_t { + kProfile = 1, + kReset = 2, + kResetAll = 3, + kActivate = 4, + kAlias = 5, + kProfileNames = 6, + }; - 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; + ProfileStorageResult scan_arena( + uint8_t arena, uint16_t *version, uint32_t *epoch, + uint32_t *generation, uint32_t *payload_crc, size_t *next_offset, + ProfileStorageIdentityIndex *index, uint8_t *identity_count) const; + bool validate_record(const uint8_t *record, uint16_t version) const; + bool read_record_payload(uint32_t record, RecordType type, uint8_t *output, + size_t capacity, size_t *size = nullptr) const; + bool read_profile_record(uint32_t record, ControllerProfile *output) const; + bool read_metadata_record(uint32_t record, char *output, + size_t output_size) const; + ProfileStorageResult append(RecordType type, + const ControllerIdentity &identity, + uint8_t profile_index, const uint8_t *payload, + size_t payload_size); + ProfileStorageResult compact(); + ProfileStorageResult migrate_legacy(); + bool publish_arena(uint8_t arena, uint32_t epoch, size_t records_end) const; + bool publish_empty_arena(uint8_t arena, uint32_t epoch); + bool write_record(uint8_t arena, size_t offset, RecordType type, + const ControllerIdentity &identity, uint8_t profile_index, + uint32_t generation, const uint8_t *payload, + size_t payload_size) const; + void apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count, + RecordType type, const ControllerIdentity &identity, + uint8_t profile_index, uint32_t generation, + uint32_t record) const; - ProfileStorageIo io_{}; - ProfileStorageSnapshot snapshot_{}; - bool initialized_ = false; + ProfileStorageIo io_{}; + ProfileStorageSnapshot snapshot_{}; + ProfileStorageIdentityIndex + index_[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{}; + uint8_t identity_count_ = 0; + uint32_t epoch_ = 0; + uint16_t catalog_version_ = 0; + size_t next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET; + bool initialized_ = false; }; diff --git a/src/firmware/usb/switch/switch_haptics.cpp b/src/firmware/usb/switch/switch_haptics.cpp index e9ac083..487a24f 100644 --- a/src/firmware/usb/switch/switch_haptics.cpp +++ b/src/firmware/usb/switch/switch_haptics.cpp @@ -1,5 +1,5 @@ #include "usb/switch/switch_haptics.h" -#include +#include "usb/switch/switch_haptics_amplitudes.h" #include namespace { @@ -149,16 +149,31 @@ void SwitchHapticsDecoder::reset() { reset_actuator(actuators_[1]); } +void SwitchHapticsDecoder::append_sample( + const ActuatorState& state, SwitchHapticsActuatorFrame& output) { + if (output.sample_count >= 3) { + return; + } + output.samples[output.sample_count++] = { + state.low_frequency, state.high_frequency, + SwitchHapticsTables::kAmplitudeQ15[state.low_amplitude], + SwitchHapticsTables::kAmplitudeQ15[state.high_amplitude], + }; +} + SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( - ActuatorState& state, uint32_t word) { + ActuatorState& state, uint32_t word, SwitchHapticsActuatorFrame& output) { + output = {}; if (word == 0 || word == kNeutralWord) { reset_actuator(state); state.last_word = word; state.have_last_word = true; + append_sample(state, output); return {0, 0}; } if (state.have_last_word && state.last_word == word) { + append_sample(state, output); return {state.low_amplitude, state.high_amplitude}; } state.last_word = word; @@ -171,10 +186,12 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( if (frame_count == 0) { state.high_amplitude = 0; + append_sample(state, output); return {state.low_amplitude, 0}; } const auto record_sample = [&]() { + append_sample(state, output); if (state.low_amplitude > peak.low) { peak.low = state.low_amplitude; } @@ -269,37 +286,33 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( } if (!decoded) { + append_sample(state, output); return {state.low_amplitude, state.high_amplitude}; } return peak; } uint8_t SwitchHapticsDecoder::amplitude_to_magnitude(uint8_t amplitude_index) { - if (amplitude_index < 2) { - return 0; - } - - const double exponent = -8.0 + static_cast(amplitude_index) / 32.0; - const double scaled = std::exp2(exponent) * 255.0; - unsigned magnitude = static_cast(scaled + 0.5); - if (magnitude > 255u) { - magnitude = 255u; - } - return static_cast(magnitude); + return SwitchHapticsTables::kMagnitude[amplitude_index]; } ControllerRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) { + ControllerRumbleOutput output{}; AmplitudePeak peaks[2] = { {actuators_[0].low_amplitude, actuators_[0].high_amplitude}, {actuators_[1].low_amplitude, actuators_[1].high_amplitude}, }; if (payload != nullptr) { - peaks[0] = decode_actuator(actuators_[0], load_little_endian_word(payload)); - peaks[1] = decode_actuator(actuators_[1], load_little_endian_word(payload + 4)); + peaks[0] = decode_actuator( + actuators_[0], load_little_endian_word(payload), output.hd.actuators[0]); + peaks[1] = decode_actuator( + actuators_[1], load_little_endian_word(payload + 4), output.hd.actuators[1]); } const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low; const uint8_t high_peak = peaks[0].high > peaks[1].high ? peaks[0].high : peaks[1].high; - return {amplitude_to_magnitude(low_peak), amplitude_to_magnitude(high_peak)}; + output.low_frequency_magnitude = amplitude_to_magnitude(low_peak); + output.high_frequency_magnitude = amplitude_to_magnitude(high_peak); + return output; } diff --git a/src/firmware/usb/switch/switch_haptics.h b/src/firmware/usb/switch/switch_haptics.h index 9d8c75a..b940c19 100644 --- a/src/firmware/usb/switch/switch_haptics.h +++ b/src/firmware/usb/switch/switch_haptics.h @@ -4,9 +4,31 @@ #include #include +// Decoded indices are logarithmic frequencies; amplitudes are linear Q0.15. +// Low/high frequency index 64 means 160/320 Hz respectively. +struct SwitchHapticsSample { + uint8_t low_frequency_index = 64; + uint8_t high_frequency_index = 64; + uint16_t low_amplitude_q15 = 0; + uint16_t high_amplitude_q15 = 0; +}; + +struct SwitchHapticsActuatorFrame { + uint8_t sample_count = 0; + SwitchHapticsSample samples[3]{}; +}; + +struct SwitchHapticsFrame { + SwitchHapticsActuatorFrame actuators[2]{}; // Left, right. +}; + struct ControllerRumbleOutput { uint8_t low_frequency_magnitude; uint8_t high_frequency_magnitude; + // Counts are zero for conventional rumble (XInput/UART/local feedback). + // Switch packets carry ordered per-side substeps in addition to the + // compatibility magnitudes consumed by existing non-native backends. + SwitchHapticsFrame hd{}; }; typedef void (*ControllerRumbleCallback)( uint8_t instance, const ControllerRumbleOutput& rumble); @@ -40,7 +62,10 @@ private: }; static void reset_actuator(ActuatorState& state); - static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word); + static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word, + SwitchHapticsActuatorFrame& output); + static void append_sample(const ActuatorState& state, + SwitchHapticsActuatorFrame& output); static uint8_t amplitude_to_magnitude(uint8_t amplitude_index); ActuatorState actuators_[2]; diff --git a/src/firmware/usb/switch/switch_haptics_amplitudes.h b/src/firmware/usb/switch/switch_haptics_amplitudes.h new file mode 100644 index 0000000..e088a55 --- /dev/null +++ b/src/firmware/usb/switch/switch_haptics_amplitudes.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +// Generated from the decoder's existing envelope: round(2^(-8+i/32)*scale), +// with indices 0 and 1 silent. Avoid transcendental math on USB report delivery. +namespace SwitchHapticsTables { +constexpr uint16_t kAmplitudeQ15[256] = { + 0, 0, 134, 137, 140, 143, 146, 149, 152, 156, 159, 162, 166, 170, 173, 177, + 181, 185, 189, 193, 197, 202, 206, 211, 215, 220, 225, 230, 235, 240, 245, 251, + 256, 262, 267, 273, 279, 285, 292, 298, 304, 311, 318, 325, 332, 339, 347, 354, + 362, 370, 378, 386, 395, 403, 412, 421, 431, 440, 450, 459, 470, 480, 490, 501, + 512, 523, 535, 546, 558, 571, 583, 596, 609, 622, 636, 650, 664, 679, 693, 709, + 724, 740, 756, 773, 790, 807, 825, 843, 861, 880, 899, 919, 939, 960, 981, 1002, + 1024, 1046, 1069, 1093, 1117, 1141, 1166, 1192, 1218, 1244, 1272, 1300, 1328, 1357, 1387, 1417, + 1448, 1480, 1512, 1545, 1579, 1614, 1649, 1685, 1722, 1760, 1798, 1838, 1878, 1919, 1961, 2004, + 2048, 2093, 2139, 2186, 2233, 2282, 2332, 2383, 2435, 2489, 2543, 2599, 2656, 2714, 2774, 2834, + 2896, 2960, 3025, 3091, 3158, 3228, 3298, 3371, 3444, 3520, 3597, 3676, 3756, 3838, 3922, 4008, + 4096, 4186, 4277, 4371, 4467, 4565, 4664, 4767, 4871, 4978, 5087, 5198, 5312, 5428, 5547, 5668, + 5793, 5919, 6049, 6182, 6317, 6455, 6597, 6741, 6889, 7039, 7194, 7351, 7512, 7677, 7845, 8016, + 8192, 8371, 8555, 8742, 8933, 9129, 9329, 9533, 9742, 9955, 10173, 10396, 10624, 10856, 11094, 11337, + 11585, 11839, 12098, 12363, 12634, 12910, 13193, 13482, 13777, 14079, 14387, 14702, 15024, 15353, 15689, 16033, + 16384, 16743, 17109, 17484, 17867, 18258, 18658, 19066, 19484, 19911, 20347, 20792, 21247, 21713, 22188, 22674, + 23170, 23678, 24196, 24726, 25268, 25821, 26386, 26964, 27554, 28158, 28774, 29405, 30048, 30706, 31379, 32066, +}; +constexpr uint8_t kMagnitude[256] = { + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, + 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 8, 8, + 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, + 11, 12, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15, 15, 16, + 16, 16, 17, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21, 22, 22, + 23, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 29, 29, 30, 31, 31, + 32, 33, 33, 34, 35, 36, 36, 37, 38, 39, 40, 40, 41, 42, 43, 44, + 45, 46, 47, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58, 60, 61, 62, + 64, 65, 67, 68, 70, 71, 73, 74, 76, 77, 79, 81, 83, 84, 86, 88, + 90, 92, 94, 96, 98, 100, 103, 105, 107, 110, 112, 114, 117, 119, 122, 125, + 128, 130, 133, 136, 139, 142, 145, 148, 152, 155, 158, 162, 165, 169, 173, 176, + 180, 184, 188, 192, 197, 201, 205, 210, 214, 219, 224, 229, 234, 239, 244, 250, +}; +} // namespace SwitchHapticsTables diff --git a/src/firmware/usb/switch/switch_pro_driver.cpp b/src/firmware/usb/switch/switch_pro_driver.cpp index c9fb250..ceb89f4 100644 --- a/src/firmware/usb/switch/switch_pro_driver.cpp +++ b/src/firmware/usb/switch/switch_pro_driver.cpp @@ -47,6 +47,8 @@ struct SwitchProContext { SwitchProReport switch_report{}; uint8_t last_report_counter = 0; uint32_t last_report_timer = 0; + uint32_t last_imu_report_timer = 0; // Logical 15 ms cadence, not ACK timing. + bool last_report_was_reply = false; bool is_ready = false; bool is_initialized = false; bool is_report_queued = false; @@ -430,6 +432,8 @@ static void reset_context_runtime(SwitchProContext& context, uint32_t now, update_switch_report_from_state(context); context.last_report_counter = 0; context.last_report_timer = now; + context.last_imu_report_timer = now; + context.last_report_was_reply = false; context.is_ready = ready_before_mount; context.is_initialized = ready_before_mount; context.is_report_queued = false; @@ -466,8 +470,9 @@ static bool send_report(uint8_t instance, SwitchProContext& context, uint16_t report_length) { bool result = tud_hid_n_report(instance, report_id, report_data, report_length); - ++context.last_report_counter; - if (!result) { + if (result) { + ++context.last_report_counter; + } else { LOG_PRINTF("[HID %u] send_report failed id=%u len=%u\n", instance, report_id, report_length); } @@ -832,59 +837,76 @@ bool switch_pro_task(uint8_t instance) { return false; } - uint32_t now = to_ms_since_boot(get_absolute_time()); - bool report_sent = false; - bool regular_report_sent = false; - + const uint32_t now = to_ms_since_boot(get_absolute_time()); update_switch_report_from_state(*context); - if (tud_suspended()) { tud_remote_wakeup(); } - - if (context->is_report_queued) { - if ((now - context->last_report_timer) > - SWITCH_PRO_KEEPALIVE_TIMER) { - if (tud_hid_n_ready(instance) && - send_report(instance, *context, 0, context->report_buffer, + // Busy USB is not a sent sample. Keep the overdue deadline and the + // unconsumed sensor state so the next ready opportunity uses fresh input. + if (!tud_hid_n_ready(instance)) { + return false; + } + const bool imu_due = context->is_ready && + (now - context->last_imu_report_timer) >= SWITCH_PRO_IMU_REPORT_TIMER; + const bool prefer_imu = imu_due && context->last_report_was_reply && + context->report_buffer[0] == REPORT_OUTPUT_21; + if (context->is_report_queued && !prefer_imu) { + if ((now - context->last_report_timer) > SWITCH_PRO_KEEPALIVE_TIMER) { + // A due IMU report may have interleaved since this ACK was built. + if (context->report_buffer[0] == REPORT_OUTPUT_21) { + context->report_buffer[1] = context->last_report_counter; + } + if (send_report(instance, *context, 0, context->report_buffer, SWITCH_PRO_ENDPOINT_SIZE)) { context->is_report_queued = false; context->last_report_timer = now; + context->last_report_was_reply = + context->report_buffer[0] == REPORT_OUTPUT_21; } } - report_sent = true; + return false; } - if (context->is_ready && !report_sent) { - if ((now - context->last_report_timer) >= - SWITCH_PRO_IMU_REPORT_TIMER) { - // One timer tick per 5ms IMU frame; three frames per report. - fill_imu_report_data(*context, context->input_state, now); - context->switch_report.timestamp += 3; - if (tud_hid_n_ready(instance) && - send_report(instance, *context, 0, &context->switch_report, - sizeof(context->switch_report))) { - context->input_state.motion_sample_count = 0; - regular_report_sent = true; - } + if (imu_due) { + const uint32_t periods = + (now - context->last_imu_report_timer) / SWITCH_PRO_IMU_REPORT_TIMER; + const MotionQuaternion previous_quaternion = context->motion_quaternion; + const uint8_t previous_timestamp = context->switch_report.timestamp; + uint8_t previous_imu[sizeof(context->switch_report.imuData)]; + memcpy(previous_imu, context->switch_report.imuData, sizeof(previous_imu)); + fill_imu_report_data(*context, context->input_state, now); + context->switch_report.timestamp += static_cast(periods * 3); + if (send_report(instance, *context, 0, &context->switch_report, + sizeof(context->switch_report))) { + context->input_state.motion_sample_count = 0; + // Stay on the 15 ms clock across 8 ms USB polling quantization. + // Long stalls skip obsolete periods, never replay a motion burst. + context->last_imu_report_timer += periods * SWITCH_PRO_IMU_REPORT_TIMER; context->last_report_timer = now; + context->last_report_was_reply = false; + return true; } - } else if (!context->is_initialized) { - send_identify(*context); - if (tud_hid_n_ready(instance)) { - bool result = tud_hid_n_report( - instance, 0, context->report_buffer, - SWITCH_PRO_ENDPOINT_SIZE); - if (result) { - context->is_initialized = true; - } else { - LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n", - instance, SWITCH_PRO_ENDPOINT_SIZE); - } - } - context->last_report_timer = now; + // Readiness can change before queuing. Failed transmission must not + // advance quaternion integration or expose an unsent timestamp. + context->motion_quaternion = previous_quaternion; + context->switch_report.timestamp = previous_timestamp; + memcpy(context->switch_report.imuData, previous_imu, sizeof(previous_imu)); + return false; } - return regular_report_sent; + + if (!context->is_initialized) { + send_identify(*context); + if (tud_hid_n_report(instance, 0, context->report_buffer, + SWITCH_PRO_ENDPOINT_SIZE)) { + context->is_initialized = true; + context->last_report_timer = now; + } else { + LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n", instance, + SWITCH_PRO_ENDPOINT_SIZE); + } + } + return false; } bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index a4e7628..ed6adcd 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -5,12 +5,19 @@ #include "adapter/adapter_host_probe.h" #include "adapter/adapter_reboot.h" #include "adapter/adapter_usb_mode.h" +#include "input/haptics_experiment.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_transport_probe.h" +#endif #include "tusb.h" #include "usb/usb_output_driver.h" namespace UsbConfigurationManagement { namespace { +uint32_t g_capture_read_run = 0; +uint16_t g_capture_read_index = 0; + uint16_t read_u16(const uint8_t* input) { return static_cast(input[0]) | static_cast(input[1] << 8); @@ -74,6 +81,10 @@ Status profile_service_status(const ProfileServiceMetadata& metadata) { bool valid_out_size(Operation operation, size_t size) { switch (operation) { + case Operation::kMacroCapture: + return size == kRequestHeaderSize + 5 || + size == kRequestHeaderSize + 7 || + size == kRequestHeaderSize + 16; case Operation::kModeSet: return size == kRequestHeaderSize + 5; case Operation::kReboot: @@ -100,9 +111,22 @@ bool valid_out_size(Operation operation, size_t size) { case Operation::kProfileReset: case Operation::kProfileActivate: return size == kRequestHeaderSize + 19; + case Operation::kProfileMetadataSet: + return size >= kRequestHeaderSize + 20 && + size <= kRequestHeaderSize + 20 + + PROFILE_SERVICE_METADATA_MAX_BYTES; + case Operation::kProfileIdentify: + return size == kRequestHeaderSize + + CONTROLLER_IDENTITY_ENCODED_SIZE; case Operation::kPairingRefresh: case Operation::kPairingClear: return size == kRequestHeaderSize; + case Operation::kHapticsExperiment: +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + return size == kRequestHeaderSize + 2; +#else + return false; +#endif default: return false; } @@ -183,6 +207,149 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { output_size); } +size_t encode_haptics_experiment(uint8_t* output, size_t output_size) { + HapticsExperimentDiagnostics diagnostics{}; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_snapshot(&diagnostics); +#else + diagnostics.state = HapticsExperimentState::kUnsupported; +#endif + uint8_t payload[kHapticsExperimentPayloadSize]{}; + write_u32(&payload[0], diagnostics.run_id); + write_u32(&payload[4], diagnostics.connection_generation); + write_u32(&payload[8], diagnostics.start_us); + write_u32(&payload[12], diagnostics.generated_packets); + write_u32(&payload[16], diagnostics.sent_packets); + write_u32(&payload[20], diagnostics.skipped_packets); + write_u32(&payload[24], diagnostics.send_failures); + write_u32(&payload[28], diagnostics.can_send_requests); + write_u32(&payload[32], diagnostics.synchronous_callbacks); + write_u32(&payload[36], diagnostics.max_generate_us); + write_u32(&payload[40], diagnostics.max_send_gap_us); + write_u32(&payload[44], diagnostics.max_lateness_us); + write_u32(&payload[48], diagnostics.max_request_wait_us); + write_u32(&payload[52], diagnostics.first_tone_due_us); + write_u32(&payload[56], diagnostics.first_tone_sent_us); + write_u32(&payload[60], diagnostics.last_sent_us); + write_u32(&payload[64], diagnostics.elapsed_us); + payload[68] = static_cast(diagnostics.state); + payload[69] = diagnostics.slot; + payload[70] = diagnostics.last_error; + payload[72] = diagnostics.mode; + write_u32(&payload[76], diagnostics.host_updates); + write_u32(&payload[80], diagnostics.dropped_updates); + payload[73] = diagnostics.packet_frames; + payload[74] = diagnostics.last_packet_nonzero ? 1 : 0; + return encode_response( + Operation::kHapticsExperiment, Status::kOk, 0, + kHapticsExperimentSchemaVersion, diagnostics.run_id, + payload, sizeof(payload), output, output_size); +} + +size_t encode_macro_capture(uint8_t* output, size_t output_size) { + Bluepad32CaptureSnapshot capture{}; + if (!bluepad32_input_backend_capture_page( + g_capture_read_run, g_capture_read_index, &capture)) { + return encode_response(Operation::kMacroCapture, Status::kMalformed, 0, + kMacroCaptureSchemaVersion, 0, + nullptr, 0, output, output_size); + } + uint8_t payload[kMacroCaptureHeaderSize + + BLUEPAD32_CAPTURE_PAGE_EVENTS * kMacroCaptureEventSize]{}; + write_u32(payload, capture.run_id); + write_u32(payload + 4, capture.connection_generation); + write_u32(payload + 8, capture.elapsed_us); + payload[12] = capture.slot; + payload[13] = static_cast(capture.state); + payload[14] = capture.options.channels; + payload[15] = capture.event_count; + write_u16(payload + 16, capture.total_events); + write_u16(payload + 18, capture.first_index); + write_u16(payload + 20, capture.options.axis_quantum); + write_u16(payload + 22, capture.options.trigger_quantum); + write_u32(payload + 24, capture.options.max_duration_ms); + payload[28] = capture.options.max_events; + for (uint8_t index = 0; index < capture.event_count; ++index) { + const CaptureEvent& event = capture.events[index]; + uint8_t* encoded = payload + kMacroCaptureHeaderSize + + index * kMacroCaptureEventSize; + write_u32(encoded, event.at_us); + write_u16(encoded + 4, event.buttons); + write_u16(encoded + 6, static_cast(event.left_x)); + write_u16(encoded + 8, static_cast(event.left_y)); + write_u16(encoded + 10, static_cast(event.right_x)); + write_u16(encoded + 12, static_cast(event.right_y)); + write_u16(encoded + 14, event.left_trigger); + write_u16(encoded + 16, event.right_trigger); + } + return encode_response( + Operation::kMacroCapture, Status::kOk, 0, kMacroCaptureSchemaVersion, + capture.run_id, payload, + kMacroCaptureHeaderSize + capture.event_count * kMacroCaptureEventSize, + output, output_size); +} + +size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + HapticsTransportProbe probe{}; + haptics_transport_probe_snapshot(&probe); + uint8_t payload[kHapticsTransportProbePayloadSize]{}; + write_u32(&payload[0], probe.run_id); + write_u32(&payload[4], probe.connection_generation); + write_u32(&payload[8], probe.connection_handle); + write_u32(&payload[12], probe.timer_wakes); + write_u32(&payload[16], probe.max_timer_lateness_us); + write_u32(&payload[20], probe.total_timer_lateness_us); + write_u32(&payload[24], probe.send_calls); + write_u32(&payload[28], probe.max_send_us); + write_u32(&payload[32], probe.total_send_us); + write_u32(&payload[36], probe.write_calls); + write_u32(&payload[40], probe.max_write_us); + write_u32(&payload[44], probe.total_write_us); + write_u32(&payload[48], probe.read_calls); + write_u32(&payload[52], probe.read_packets); + write_u32(&payload[56], probe.max_read_us); + write_u32(&payload[60], probe.total_read_us); + write_u32(&payload[64], probe.poll_calls); + write_u32(&payload[68], probe.max_poll_us); + write_u32(&payload[72], probe.total_poll_us); + write_u32(&payload[76], probe.completion_events); + write_u32(&payload[80], probe.completed_packets); + write_u32(&payload[84], probe.max_completion_gap_us); + write_u32(&payload[88], probe.max_outstanding_acl); + write_u32(&payload[92], probe.min_free_acl); + write_u32(&payload[96], probe.first_tone_send_return_us); + write_u32(&payload[100], probe.active); + write_u32(&payload[104], probe.max_permission_wait_us); + write_u32(&payload[108], probe.total_permission_wait_us); + write_u32(&payload[112], probe.permission_callbacks); + write_u32(&payload[116], probe.max_poll_gap_us); + write_u32(&payload[120], probe.controller_acl_packet_bytes); + write_u32(&payload[124], probe.controller_acl_packet_count); + write_u32(&payload[128], probe.requested_sys_khz); + write_u32(&payload[132], probe.measured_sys_khz); + write_u32(&payload[136], probe.measured_usb_khz); + write_u32(&payload[140], probe.core_voltage_mv); + write_u32(&payload[144], probe.flash_clock_divider); + write_u32(&payload[148], probe.cyw43_pio_divider256); + write_u32(&payload[152], static_cast(probe.temperature_millicelsius)); + write_u32(&payload[156], probe.host_completed_writes); + write_u32(&payload[160], probe.acl_writes); + write_u32(&payload[164], probe.other_writes); + write_u32(&payload[168], probe.write_failures); + write_u32(&payload[172], probe.packet_read_optimized); + return encode_response( + Operation::kHapticsTransportProbe, Status::kOk, 0, + kHapticsTransportProbeSchemaVersion, probe.run_id, + payload, sizeof(payload), output, output_size); +#else + return encode_response( + Operation::kHapticsTransportProbe, Status::kUnsupportedSchema, 0, + kHapticsTransportProbeSchemaVersion, 0, + nullptr, 0, output, output_size); +#endif +} + } // namespace bool decode_request(Operation setup_operation, const uint8_t* input, @@ -271,15 +438,20 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot, payload[0] = snapshot.count; size_t offset = 1; for (uint8_t index = 0; index < snapshot.count; ++index) { + const size_t alias_size = strlen(snapshot.rows[index].alias); if (!controller_identity_encode(snapshot.rows[index].identity, &payload[offset], CONTROLLER_IDENTITY_ENCODED_SIZE) || snapshot.rows[index].active_profile >= - CONTROLLER_PROFILE_COUNT) { + CONTROLLER_PROFILE_COUNT || + alias_size > PROFILE_SERVICE_METADATA_MAX_BYTES) { return 0; } payload[offset + 14] = snapshot.rows[index].active_profile; - offset += 16; + payload[offset + 16] = static_cast(alias_size); + memcpy(&payload[offset + 17], snapshot.rows[index].alias, + alias_size); + offset += kProfileListRowSize; } return encode_response( Operation::kProfileList, profile_service_status(snapshot.metadata), @@ -287,6 +459,58 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot, payload, offset, output, output_size); } +size_t encode_profile_playtest( + uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot, + uint8_t* output, size_t output_size) { + uint8_t payload[kProfilePlaytestPayloadSize]{}; + payload[1] = 0xff; + if (snapshot.active) { + if (slot >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT || + !controller_identity_encode( + snapshot.identity, &payload[12], + CONTROLLER_IDENTITY_ENCODED_SIZE) || + snapshot.state.motion_sample_count > + CONTROLLER_MOTION_SAMPLE_CAPACITY) { + return 0; + } + payload[0] = 1; + payload[1] = slot; + write_u16(&payload[2], snapshot.physical_button_mask); + write_u32(&payload[4], snapshot.connection_generation); + write_u32(&payload[8], snapshot.state_generation); + write_u16(&payload[26], + static_cast(snapshot.state.left_stick_x)); + write_u16(&payload[28], + static_cast(snapshot.state.left_stick_y)); + write_u16(&payload[30], + static_cast(snapshot.state.right_stick_x)); + write_u16(&payload[32], + static_cast(snapshot.state.right_stick_y)); + write_u16(&payload[34], snapshot.state.left_trigger); + write_u16(&payload[36], snapshot.state.right_trigger); + payload[38] = snapshot.state.motion_sample_count; + payload[39] = snapshot.battery; + payload[40] = snapshot.capabilities; + if (snapshot.state.motion_sample_count != 0) { + payload[0] |= 2; + const ControllerMotionSample& motion = + snapshot.state.motion_samples[ + snapshot.state.motion_sample_count - 1u]; + write_u16(&payload[42], static_cast(motion.accel_x)); + write_u16(&payload[44], static_cast(motion.accel_y)); + write_u16(&payload[46], static_cast(motion.accel_z)); + write_u16(&payload[48], static_cast(motion.gyro_x)); + write_u16(&payload[50], static_cast(motion.gyro_y)); + write_u16(&payload[52], static_cast(motion.gyro_z)); + } + } + return encode_response( + Operation::kProfilePlaytest, Status::kOk, payload[0], + kProfilePlaytestSchemaVersion, + snapshot.active ? snapshot.state_generation : 0, + payload, sizeof(payload), 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); @@ -308,6 +532,41 @@ size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot, payload, payload_size, output, output_size); } +size_t encode_profile_metadata( + const ProfileServiceMetadataSnapshot& 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[kProfileMetadataPayloadSize]{}; + if (snapshot.valid) { + const char* values[CONTROLLER_PROFILE_COUNT + 1] = { + snapshot.alias, + snapshot.profile_names[0], snapshot.profile_names[1], + snapshot.profile_names[2], snapshot.profile_names[3], + snapshot.profile_names[4], snapshot.profile_names[5], + snapshot.profile_names[6], snapshot.profile_names[7], + }; + for (size_t index = 0; + index < CONTROLLER_PROFILE_COUNT + 1; ++index) { + const size_t size = strlen(values[index]); + if (size > PROFILE_SERVICE_METADATA_MAX_BYTES) { + return 0; + } + const size_t offset = + index * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); + payload[offset] = static_cast(size); + memcpy(&payload[offset + 1], values[index], size); + } + } + return encode_response( + Operation::kProfileMetadataRead, status, 0, + kProfileMetadataSchemaVersion, snapshot.metadata.generation, + payload, snapshot.valid ? sizeof(payload) : 0, + output, output_size); +} + size_t encode_profile_transaction( const ProfileServiceTransactionSnapshot& snapshot, uint8_t* output, size_t output_size) { @@ -339,6 +598,7 @@ uint8_t g_request_buffer[ UsbConfigurationManagement::Operation g_pending_operation = UsbConfigurationManagement::Operation::kInfo; bool g_out_pending = false; +bool g_out_processed = false; size_t g_pending_request_size = 0; bool process_out_request() { @@ -351,6 +611,35 @@ bool process_out_request() { const uint8_t* payload = request.payload; switch (request.operation) { + case Operation::kMacroCapture: { + if (request.payload_size == 16 && payload[0] == 1) { + CaptureOptions options{}; + options.channels = payload[6]; + options.max_events = payload[7]; + options.axis_quantum = UsbConfigurationManagement::read_u16(payload + 8); + options.trigger_quantum = UsbConfigurationManagement::read_u16(payload + 10); + options.max_duration_ms = UsbConfigurationManagement::read_u32(payload + 12); + if (!bluepad32_input_backend_capture_start( + payload[1], UsbConfigurationManagement::read_u32(payload + 2), options)) return false; + g_capture_read_run = 0; + g_capture_read_index = 0; + return true; + } + if (request.payload_size == 5 && payload[0] == 0) { + return bluepad32_input_backend_capture_stop(UsbConfigurationManagement::read_u32(payload + 1)); + } + if (request.payload_size == 7 && payload[0] == 2) { + const uint32_t run = UsbConfigurationManagement::read_u32(payload + 1); + const uint16_t index = UsbConfigurationManagement::read_u16(payload + 5); + Bluepad32CaptureSnapshot snapshot{}; + if (!bluepad32_input_backend_capture_page(run, index, &snapshot)) + return false; + g_capture_read_run = run; + g_capture_read_index = index; + return true; + } + return false; + } case Operation::kModeSet: { const uint32_t transaction_id = static_cast(payload[0]) | @@ -525,12 +814,58 @@ bool process_out_request() { status == ConfigurationTransactionStatus::kUnchanged || status == ConfigurationTransactionStatus::kCommitted; } + case Operation::kProfileMetadataSet: { + 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{}; + const uint8_t profile_index = payload[18]; + const size_t value_size = payload[19]; + if (transaction_id == 0 || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT) || + value_size > PROFILE_SERVICE_METADATA_MAX_BYTES || + request.payload_size != 20 + value_size || + !controller_identity_decode( + &payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity)) { + return false; + } + const ConfigurationTransactionStatus status = + profile_service_set_metadata( + transaction_id, identity, profile_index, + reinterpret_cast(&payload[20]), + value_size); + return status == ConfigurationTransactionStatus::kPending || + status == ConfigurationTransactionStatus::kUnchanged || + status == ConfigurationTransactionStatus::kCommitted; + } + case Operation::kProfileIdentify: { + ControllerIdentity identity{}; + return controller_identity_decode( + payload, CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity) && + bluepad32_input_backend_identify(identity); + } case Operation::kPairingRefresh: bluepad32_input_backend_request_pairing_snapshot(); return true; case Operation::kPairingClear: bluepad32_input_backend_clear_pairings(); return true; + case Operation::kHapticsExperiment: + if (request.payload_size != 2 || + payload[0] > 2 || + payload[1] >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { + return false; + } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + return haptics_experiment_request(payload[0], payload[1]); +#else + return false; +#endif default: return false; } @@ -541,6 +876,11 @@ bool process_out_request() { bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) { + if (stage == CONTROL_STAGE_SETUP) { + g_out_pending = false; + g_out_processed = false; + g_pending_request_size = 0; + } if (adapter_host_probe_vendor_control(rhport, stage, request)) { return true; } @@ -555,6 +895,10 @@ bool usb_configuration_management_vendor_control( const Operation operation = static_cast(request->bRequest); + if (operation == Operation::kHapticsTransportProbe && + request->bmRequestType_bit.direction != TUSB_DIR_IN) { + return false; + } if (stage == CONTROL_STAGE_ACK) { if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { return true; @@ -563,9 +907,22 @@ bool usb_configuration_management_vendor_control( return false; } g_out_pending = false; + if (operation == Operation::kHapticsExperiment) { + return g_out_processed; + } return process_out_request(); } if (stage == CONTROL_STAGE_DATA) { + if (operation == Operation::kHapticsExperiment && + request->bmRequestType_bit.direction == TUSB_DIR_OUT) { + if (!g_out_pending || operation != g_pending_operation || + g_out_processed) { + return false; + } + // Reject before the USB status ACK, and never enqueue twice. + g_out_processed = process_out_request(); + return g_out_processed; + } return true; } if (stage != CONTROL_STAGE_SETUP) { @@ -579,6 +936,7 @@ bool usb_configuration_management_vendor_control( g_pending_operation = operation; g_pending_request_size = request->wLength; g_out_pending = true; + g_out_processed = false; return tud_control_xfer(rhport, request, g_request_buffer, request->wLength); } @@ -608,6 +966,17 @@ bool usb_configuration_management_vendor_control( response_size = encode_runtime_diagnostics(response, sizeof(response)); break; + case Operation::kHapticsExperiment: + response_size = + encode_haptics_experiment(response, sizeof(response)); + break; + case Operation::kHapticsTransportProbe: + response_size = + encode_haptics_transport_probe(response, sizeof(response)); + break; + case Operation::kMacroCapture: + response_size = encode_macro_capture(response, sizeof(response)); + break; case Operation::kProfileList: { ProfileServiceListSnapshot snapshot{}; profile_service_list_snapshot(&snapshot); @@ -622,6 +991,37 @@ bool usb_configuration_management_vendor_control( snapshot, response, sizeof(response)); break; } + case Operation::kProfilePlaytest: { + ProfileServiceSelectedSnapshot selected{}; + profile_service_selected_snapshot(&selected); + Bluepad32PlaytestSnapshot playtest{}; + uint8_t selected_slot = 0xff; + for (uint8_t slot = 0; + slot < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++slot) { + Bluepad32PlaytestSnapshot candidate{}; + bluepad32_input_backend_playtest_snapshot( + slot, &candidate); + if (!candidate.active || + (!controller_identity_is_global(selected.identity) && + !controller_identity_equal( + selected.identity, candidate.identity))) { + continue; + } + playtest = candidate; + selected_slot = slot; + break; + } + response_size = encode_profile_playtest( + selected_slot, playtest, response, sizeof(response)); + break; + } + case Operation::kProfileMetadataRead: { + ProfileServiceMetadataSnapshot snapshot{}; + profile_service_metadata_snapshot(&snapshot); + response_size = encode_profile_metadata( + snapshot, response, sizeof(response)); + break; + } case Operation::kProfileTransactionStatus: { ProfileServiceTransactionSnapshot snapshot{}; profile_service_transaction_snapshot(&snapshot); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 0477795..e479646 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -18,14 +18,29 @@ constexpr size_t kRequestHeaderSize = 16; constexpr size_t kResponseHeaderSize = 20; constexpr size_t kPairingRecordSize = 8; constexpr size_t kPairingPayloadHeaderSize = 4; -constexpr size_t kMaximumRequestSize = 64; +constexpr size_t kMaximumRequestSize = 80; +constexpr size_t kProfileListRowSize = + 16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1; constexpr size_t kProfileListPayloadSize = - 1 + PROFILE_SERVICE_LIST_CAPACITY * 16; + 1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize; +constexpr uint16_t kProfilePlaytestSchemaVersion = 2; +constexpr size_t kProfilePlaytestPayloadSize = 54; +constexpr size_t kProfileMetadataPayloadSize = + (CONTROLLER_PROFILE_COUNT + 1) * + (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); +constexpr uint16_t kProfileMetadataSchemaVersion = 1; +constexpr uint16_t kHapticsExperimentSchemaVersion = 5; +constexpr size_t kHapticsExperimentPayloadSize = 84; +constexpr uint16_t kHapticsTransportProbeSchemaVersion = 3; +constexpr size_t kHapticsTransportProbePayloadSize = 176; +constexpr uint16_t kMacroCaptureSchemaVersion = 1; +constexpr size_t kMacroCaptureHeaderSize = 32; +constexpr size_t kMacroCaptureEventSize = 20; constexpr size_t kMaximumResponseSize = kResponseHeaderSize + kProfileListPayloadSize; constexpr size_t kMaximumChunkSize = kMaximumRequestSize - kRequestHeaderSize - 8; -static_assert(kMaximumResponseSize == 293, +static_assert(kMaximumResponseSize == 837, "profile list no longer fits the EP0 response buffer"); enum class Operation : uint8_t { @@ -52,6 +67,13 @@ enum class Operation : uint8_t { kProfileReset = 0x36, kProfileActivate = 0x37, kProfileTransactionStatus = 0x38, + kProfilePlaytest = 0x39, + kProfileMetadataRead = 0x3a, + kProfileMetadataSet = 0x3b, + kProfileIdentify = 0x3c, + kHapticsExperiment = 0x40, + kHapticsTransportProbe = 0x41, + kMacroCapture = 0x42, }; enum class Status : uint8_t { @@ -84,9 +106,15 @@ 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_playtest( + uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot, + uint8_t* output, size_t output_size); size_t encode_profile_transaction( const ProfileServiceTransactionSnapshot& snapshot, uint8_t* output, size_t output_size); +size_t encode_profile_metadata( + const ProfileServiceMetadataSnapshot& snapshot, + uint8_t* output, size_t output_size); } // namespace UsbConfigurationManagement diff --git a/src/firmware/usb/usb_output_driver.cpp b/src/firmware/usb/usb_output_driver.cpp index ab77939..8083beb 100644 --- a/src/firmware/usb/usb_output_driver.cpp +++ b/src/firmware/usb/usb_output_driver.cpp @@ -56,6 +56,7 @@ bool switch_selected() { void usb_output_driver_init(AdapterUsbMode mode) { #ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); g_mode = mode; #else (void)mode; @@ -384,11 +385,21 @@ extern "C" void tud_mount_cb() { extern "C" void tud_umount_cb() { LOG_PRINTF("[USB] umount_cb\n"); +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); +#endif if (switch_selected()) { switch_pro_unmount(); } } +extern "C" void tud_suspend_cb(bool remote_wakeup_en) { + (void)remote_wakeup_en; +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); +#endif +} + extern "C" usbd_class_driver_t const* usbd_app_driver_get_cb( uint8_t* driver_count) { if (driver_count == nullptr) { diff --git a/src/firmware/usb/xinput/xinput_driver.cpp b/src/firmware/usb/xinput/xinput_driver.cpp index 6a3a5bd..d1d4957 100644 --- a/src/firmware/usb/xinput/xinput_driver.cpp +++ b/src/firmware/usb/xinput/xinput_driver.cpp @@ -20,6 +20,7 @@ struct XInputContext { uint8_t endpoint_in = 0; uint8_t endpoint_out = 0; bool configured = false; + bool host_rumble_active = false; }; XInputContext g_contexts[SWITCH_PICO_HID_INSTANCE_COUNT]{}; @@ -41,7 +42,18 @@ XInputContext *context_for_endpoint(uint8_t endpoint) { return nullptr; } +void stop_context_rumble(XInputContext& context) { + if (!context.host_rumble_active) return; + context.host_rumble_active = false; + if (context.rumble_callback != nullptr) { + context.rumble_callback(static_cast(&context - g_contexts), + ControllerRumbleOutput{}); + } +} + void reset_context(XInputContext &context) { + context.configured = false; + stop_context_rumble(context); const ControllerRumbleCallback callback = context.rumble_callback; context = {}; context.rumble_callback = callback; @@ -133,7 +145,7 @@ bool driver_control(uint8_t rhport, uint8_t stage, bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, uint32_t transferred) { XInputContext *context = context_for_endpoint(endpoint); - if (context == nullptr || result != XFER_RESULT_SUCCESS) { + if (context == nullptr || !context->configured || result != XFER_RESULT_SUCCESS) { return false; } if (endpoint == context->endpoint_out) { @@ -142,6 +154,8 @@ bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, &rumble) && context->rumble_callback != nullptr) { const uint8_t instance = static_cast(context - g_contexts); + context->host_rumble_active = rumble.low_frequency_magnitude != 0 || + rumble.high_frequency_magnitude != 0; context->rumble_callback(instance, rumble); } memset(context->output_report, 0, sizeof(context->output_report)); @@ -205,6 +219,10 @@ bool xinput_is_ready(uint8_t instance) { return context != nullptr && context->configured && tud_ready(); } +void xinput_stop_rumble() { + for (XInputContext& context : g_contexts) stop_context_rumble(context); +} + usbd_class_driver_t const* xinput_class_driver() { return &kDriver; } diff --git a/src/firmware/usb/xinput/xinput_driver.h b/src/firmware/usb/xinput/xinput_driver.h index e4fe683..b9bb0f3 100644 --- a/src/firmware/usb/xinput/xinput_driver.h +++ b/src/firmware/usb/xinput/xinput_driver.h @@ -12,5 +12,7 @@ void xinput_set_rumble_callback(uint8_t instance, void xinput_set_input(uint8_t instance, const ControllerState& state); bool xinput_task(uint8_t instance); bool xinput_is_ready(uint8_t instance); +// Stop held host output on USB reset, unmount, or suspend. +void xinput_stop_rumble(); usbd_class_driver_t const* xinput_class_driver(); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 8944e63..4a42527 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -4,14 +4,16 @@ from __future__ import annotations import argparse +import importlib import json +import math import secrets import struct import sys import time import zlib from collections.abc import Iterable, Sequence -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Protocol, cast @@ -28,8 +30,8 @@ REQUEST_INDEX = 0x0001 PROTOCOL_VERSION = 1 REQUEST_HEADER_SIZE = 16 RESPONSE_HEADER_SIZE = 20 -MAXIMUM_REQUEST_SIZE = 64 -MAXIMUM_RESPONSE_SIZE = 293 +MAXIMUM_REQUEST_SIZE = 80 +MAXIMUM_RESPONSE_SIZE = 837 MAXIMUM_CHUNK_SIZE = 40 USB_TIMEOUT_MS = 1000 DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0 @@ -58,9 +60,26 @@ OP_PROFILE_COMMIT = 0x35 OP_PROFILE_RESET = 0x36 OP_PROFILE_ACTIVATE = 0x37 OP_PROFILE_TRANSACTION_STATUS = 0x38 +OP_PROFILE_PLAYTEST = 0x39 +OP_PROFILE_METADATA_READ = 0x3A +OP_PROFILE_METADATA_SET = 0x3B +OP_PROFILE_IDENTIFY = 0x3C +OP_HAPTICS_EXPERIMENT = 0x40 +OP_HAPTICS_TRANSPORT_PROBE = 0x41 +OP_MACRO_CAPTURE = 0x42 +MACRO_CAPTURE_SCHEMA_VERSION = 1 +MACRO_CAPTURE_STATES = ( + "idle", + "recording", + "stopped", + "full", + "timed_out", + "disconnected", +) STATUS_OK = 0 STATUS_PENDING = 1 +STATUS_UNSUPPORTED_SCHEMA = 3 STATUS_NAMES = { 2: "malformed request", 3: "unsupported schema", @@ -99,19 +118,130 @@ 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_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 +PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 +PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 +PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 +PROFILE_SCHEMA_VERSION = 6 +PROFILE_LEGACY_SIZE = 256 +PROFILE_SIZE = 384 +PROFILE_CAPACITY = 8 PROFILE_IDENTITY_CAPACITY = 16 PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1 CONTROLLER_IDENTITY_SIZE = 14 -PROFILE_LIST_ROW_SIZE = 16 +PROFILE_LIST_ROW_SIZE = 48 PROFILE_NONE_BUTTON = 0xFF -PROFILE_MACRO_STEP_CAPACITY = 8 +PROFILE_MACRO_COUNT = 4 +PROFILE_MACRO_STEP_CAPACITY = 16 +PROFILE_MACRO_STEPS_PER_MACRO = 8 +PROFILE_LEGACY_MACRO_STEP_CAPACITY = 8 +PROFILE_MACRO_STREAM_SIZE = 136 PROFILE_MACRO_STEP_SIZE = 19 PROFILE_MAXIMUM_WAIT_MS = 10000 PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934 +PROFILE_TURBO_RATE_MIN = 1 +PROFILE_TURBO_RATE_MAX = 30 +PROFILE_TURBO_DUTY_MIN = 1 +PROFILE_TURBO_DUTY_MAX = 99 +PROFILE_TURBO_BURST_MIN = 1 +PROFILE_TURBO_BURST_MAX = 255 +PROFILE_MACRO_REPEAT_MIN = 1 +PROFILE_MACRO_REPEAT_MAX = 255 +PROFILE_PLAYTEST_SCHEMA_VERSION = 2 +PROFILE_PLAYTEST_SIZE = 54 +PROFILE_PLAYTEST_SLOT_COUNT = 4 +PROFILE_METADATA_SCHEMA_VERSION = 1 +PROFILE_METADATA_MAX_BYTES = 31 +PROFILE_METADATA_VALUE_SIZE = 32 +PROFILE_METADATA_SIZE = 288 +HAPTICS_EXPERIMENT_SCHEMA_VERSION = 5 +HAPTICS_EXPERIMENT_SIZE = 84 +HAPTICS_EXPERIMENT_SLOT_COUNT = 4 +HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION = 3 +HAPTICS_TRANSPORT_PROBE_SIZE = 176 +HAPTICS_EXPERIMENT_STATES = ( + "idle", + "pending", + "running", + "completed", + "stopped", + "disconnected", + "unsupported", + "error", +) +HAPTICS_EXPERIMENT_MODES = ("fixture", "gameplay") +HAPTICS_EXPERIMENT_ERRORS = { + 0: "none", + 1: "unsupported controller or Bluetooth protocol", + 2: "insufficient Bluetooth MTU", + 3: "controller connection missing or lost", + 4: "Bluetooth can-send or stop timed out", + 5: "Bluetooth request or send failed", + 6: "queued controller output; wait for prior output to drain, then retry", +} +HAPTICS_EXPERIMENT_ENABLE_HINT = ( + "Install firmware built with SWITCH_PICO_HAPTICS_EXPERIMENT=ON " + "and connect a DualSense or DualSense Edge over Bluetooth Classic." +) +HAPTICS_EXPERIMENT_EVIDENCE_NOTE = ( + "Send timestamps measure firmware/HCI submission, not physical actuator " + "onset or playback; USB ACK only accepts a request. " + "The initial 1.024 s of priming silence is intentional, not transport delay." +) +HAPTICS_GAMEPLAY_EVIDENCE_NOTE = ( + "Send timestamps measure firmware/HCI submission, not physical actuator " + "onset or playback; USB ACK only accepts a request. Gameplay streams " + "continuously, including silence without host commands; first-tone " + "timestamps remain zero until the first nonzero PCM." +) +HAPTICS_GAMEPLAY_ARMING_NOTE = ( + "PC-controlled gameplay arming does not persist across power cycles. " + "Firmware built with SWITCH_PICO_HD_RUMBLE=ON automatically arms the first " + "eligible DualSense that becomes ready, in any slot. One native stream is " + "selected at a time; use gameplay --slot to select another controller." +) +HAPTICS_GAMEPLAY_TIMING = { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "lookback_us": 64000000 / 3000, + "switch_command_window_us": 8000, + "switch_watchdog_us": 50000, + "xinput_command_policy": "held_until_changed_or_stopped", + "xinput_carrier_hz": {"left_low": 160, "right_high": 320}, + "band_gains": {"low": 2.0, "high": 2.0}, + "response_exponent": 0.8, +} +HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT = ( + "Firmware does not support haptics transport profile operation 0x41. " + "Install updated firmware built with SWITCH_PICO_HAPTICS_EXPERIMENT=ON " + "and transport-probe support; haptics-experiment status still uses 0x40." +) +HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE = ( + "Durations are inclusive and may overlap or nest (poll/read/send/write); " + "do not sum their totals. first_tone_send_return_us is the low 32-bit " + "Pico uptime timestamp after l2cap_send returns successfully, not physical " + "actuator onset or playback. ACL extrema are observed samples, not an " + "exact occupancy timeline; completion counters select this connection " + "handle. Running snapshots are correlated to one run, not one instant." +) +HAPTICS_EXPERIMENT_PATTERN = { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "peak_amplitude": 32, + "priming_silence_packets": 48, + "cycles": 4, + "phases": [ + {"channel": "left", "frequency_hz": 100, "packets": 12}, + {"channel": "silence", "packets": 12}, + {"channel": "right", "frequency_hz": 200, "packets": 12}, + {"channel": "silence", "packets": 12}, + ], + "trailing_silence_packets": 48, + "total_packets": 288, + "initial_mode_packet_stereo_frames": 32, + "duration_us": 6144000, +} LOGICAL_BUTTONS = ( "south", @@ -131,8 +261,13 @@ LOGICAL_BUTTONS = ( "dpad_left", "dpad_right", ) +LOGICAL_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") -TURBO_MODES = ("off", "turbo", "auto_burst") +TURBO_MODES = ("off", "turbo", "auto_burst", "burst") +SHIFT_MODES = ("off", "hold", "toggle") +MACRO_PLAYBACK_MODES = ("once", "while_held", "toggle", "repeat") +SHORTCUT_SELECTOR_BUTTONS = LOGICAL_BUTTONS[:4] + LOGICAL_BUTTONS[12:] MACRO_STEP_TYPES = ("state", "end") MACRO_OVERRIDE_NAMES = ( "buttons", @@ -161,8 +296,7 @@ class UsbDevice(Protocol): index: int = 0, data_or_w_length: Any = None, timeout: int | None = None, - ) -> Any: - ... + ) -> Any: ... @dataclass(frozen=True) @@ -221,6 +355,174 @@ class RuntimeDiagnostics: feedback_pending_slots: int rumble_pending_slots: int + +@dataclass(frozen=True) +class HapticsExperimentDiagnostics: + run_id: int + connection_generation: int + start_us: int + generated_packets: int + sent_packets: int + skipped_packets: int + send_failures: int + can_send_requests: int + synchronous_callbacks: int + max_generate_us: int + max_send_gap_us: int + max_lateness_us: int + max_request_wait_us: int + first_tone_due_us: int + first_tone_sent_us: int + last_sent_us: int + elapsed_us: int + state: int + slot: int | None + last_error: int + mode: int + host_updates: int + dropped_updates: int + packet_frames: int = 64 + last_packet_nonzero: bool = False + + @property + def state_name(self) -> str: + return HAPTICS_EXPERIMENT_STATES[self.state] + + @property + def mode_name(self) -> str: + return HAPTICS_EXPERIMENT_MODES[self.mode] + + @property + def error_name(self) -> str: + return HAPTICS_EXPERIMENT_ERRORS.get( + self.last_error, f"unknown error {self.last_error}" + ) + + @property + def firmware_supported(self) -> bool: + return not (self.state_name == "unsupported" and self.slot is None) + + @property + def first_tone_submission_delay_us(self) -> int | None: + if self.first_tone_sent_us == 0: + return None + return (self.first_tone_sent_us - self.first_tone_due_us) & 0xFFFFFFFF + + def to_json_object(self) -> dict[str, Any]: + values = { + **asdict(self), + "schema_version": HAPTICS_EXPERIMENT_SCHEMA_VERSION, + "state_name": self.state_name, + "mode_name": self.mode_name, + "error_name": self.error_name, + "firmware_supported": self.firmware_supported, + "first_tone_submission_delay_us": self.first_tone_submission_delay_us, + } + if self.mode == 1: + values["gameplay"] = { + **HAPTICS_GAMEPLAY_TIMING, + "stereo_frames_per_packet": self.packet_frames, + "lookback_us": self.packet_frames * 1000000 / 3000, + } + values["evidence_note"] = HAPTICS_GAMEPLAY_EVIDENCE_NOTE + values["arming_note"] = HAPTICS_GAMEPLAY_ARMING_NOTE + else: + values["pattern"] = HAPTICS_EXPERIMENT_PATTERN + values["evidence_note"] = HAPTICS_EXPERIMENT_EVIDENCE_NOTE + return values + + +@dataclass(frozen=True) +class MacroCaptureEvent: + at_us: int + buttons: int + left_x: int + left_y: int + right_x: int + right_y: int + left_trigger: int + right_trigger: int + + +@dataclass(frozen=True) +class MacroCapturePage: + run_id: int + connection_generation: int + elapsed_us: int + slot: int + state: int + channels: int + total_events: int + first_index: int + axis_quantum: int + trigger_quantum: int + max_duration_ms: int + max_events: int + events: tuple[MacroCaptureEvent, ...] + + @property + def state_name(self) -> str: + return MACRO_CAPTURE_STATES[self.state] + + def to_json_object(self) -> dict[str, Any]: + return {**asdict(self), "state_name": self.state_name} + + +@dataclass(frozen=True) +class HapticsTransportProbe: + run_id: int + connection_generation: int + connection_handle: int + timer_wakes: int + max_timer_lateness_us: int + total_timer_lateness_us: int + send_calls: int + max_send_us: int + total_send_us: int + write_calls: int + max_write_us: int + total_write_us: int + read_calls: int + read_packets: int + max_read_us: int + total_read_us: int + poll_calls: int + max_poll_us: int + total_poll_us: int + completion_events: int + completed_packets: int + max_completion_gap_us: int + max_outstanding_acl: int + min_free_acl: int + first_tone_send_return_us: int + active: bool + max_permission_wait_us: int + total_permission_wait_us: int + permission_callbacks: int + max_poll_gap_us: int + controller_acl_packet_bytes: int + controller_acl_packet_count: int + requested_sys_khz: int + measured_sys_khz: int + measured_usb_khz: int + core_voltage_mv: int + flash_clock_divider: int + cyw43_pio_divider256: int + temperature_millicelsius: int + host_completed_writes: int + acl_writes: int + other_writes: int + write_failures: int + packet_read_optimized: int + + def to_json_object(self) -> dict[str, Any]: + return { + **asdict(self), + "schema_version": HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION, + "evidence_note": HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE, + } + + @dataclass(frozen=True) class AdapterConfiguration: pairing_window_seconds: int @@ -261,9 +563,7 @@ class PairingRecord: 2: "public identity", 3: "random identity", } - suffix = address_types.get( - self.address_type, f"type {self.address_type}" - ) + suffix = address_types.get(self.address_type, f"type {self.address_type}") return f"BLE ({suffix})" return f"unknown transport {self.transport}" @@ -290,9 +590,7 @@ def _require_bool(value: Any, name: str) -> bool: return value -def _require_object( - value: Any, fields: Sequence[str], name: str -) -> dict[str, Any]: +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) @@ -311,9 +609,7 @@ def _require_object( 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)}" - ) + raise ConfigManagerError(f"{name} must be one of {', '.join(choices)}") return choices.index(value) @@ -321,9 +617,7 @@ 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" - ) + raise ConfigManagerError(f"{name} must be a logical button name or null") return LOGICAL_BUTTONS.index(value) @@ -333,6 +627,21 @@ def _button_name(value: int) -> str | None: return LOGICAL_BUTTONS[value] +def _control_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_CONTROLS: + choices = ", ".join(LOGICAL_CONTROLS) + raise ConfigManagerError(f"{name} must be null or one of: {choices}") + return LOGICAL_CONTROLS.index(value) + + +def _control_name(value: int) -> str | None: + if value == PROFILE_NONE_BUTTON: + return None + return LOGICAL_CONTROLS[value] + + def _button_mask_from_json(value: Any, name: str) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") @@ -348,12 +657,27 @@ def _button_mask_from_json(value: Any, name: str) -> int: return mask +def _control_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 = _control_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 control") + mask |= bit + return mask + + +def _control_mask_to_json(mask: int) -> list[str]: + return [name for index, name in enumerate(LOGICAL_CONTROLS) if mask & (1 << index)] + + def _button_mask_to_json(mask: int) -> list[str]: - return [ - name - for index, name in enumerate(LOGICAL_BUTTONS) - if mask & (1 << index) - ] + return [name for index, name in enumerate(LOGICAL_BUTTONS) if mask & (1 << index)] @dataclass(frozen=True) @@ -446,6 +770,7 @@ class ControllerIdentity: class ProfileListEntry: identity: ControllerIdentity active_profile_index: int + alias: str = "" def __post_init__(self) -> None: if not isinstance(self.identity, ControllerIdentity): @@ -458,6 +783,102 @@ class ProfileListEntry: 0, PROFILE_CAPACITY - 1, ) + if ( + type(self.alias) is not str + or "\x00" in self.alias + or len(self.alias.encode("utf-8")) > PROFILE_METADATA_MAX_BYTES + ): + raise ConfigManagerError( + "controller alias must contain at most 31 UTF-8 bytes" + ) + + +@dataclass(frozen=True) +class ProfileMetadata: + alias: str + profile_names: tuple[str, ...] + + def __post_init__(self) -> None: + if len(self.profile_names) != PROFILE_CAPACITY: + raise ConfigManagerError("profile metadata must contain eight names") + for label, value in ( + ("controller alias", self.alias), + *( + (f"profile {index + 1} name", name) + for index, name in enumerate(self.profile_names) + ), + ): + if ( + type(value) is not str + or "\x00" in value + or len(value.encode("utf-8")) > PROFILE_METADATA_MAX_BYTES + ): + raise ConfigManagerError(f"{label} must contain at most 31 UTF-8 bytes") + + +@dataclass(frozen=True) +class ProfilePlaytest: + connected: bool + slot_index: int | None + connection_generation: int + state_generation: int + identity: ControllerIdentity | None + button_mask: int + left_stick: tuple[int, int] + right_stick: tuple[int, int] + triggers: tuple[int, int] + battery: int + capabilities: int + motion: tuple[int, int, int, int, int, int] | None + + def to_json_object(self) -> dict[str, Any]: + return { + "connected": self.connected, + "slot": self.slot_index, + "connection_generation": self.connection_generation, + "state_generation": self.state_generation, + "identity": ( + { + "address": self.identity.address_text, + "transport": self.identity.transport_text, + "vendor_id": self.identity.vendor_id, + "product_id": self.identity.product_id, + } + if self.identity is not None + else None + ), + "buttons": _button_mask_to_json(self.button_mask), + "left_stick": { + "x": self.left_stick[0], + "y": self.left_stick[1], + }, + "right_stick": { + "x": self.right_stick[0], + "y": self.right_stick[1], + }, + "triggers": { + "left": self.triggers[0], + "right": self.triggers[1], + }, + "battery": ( + round((self.battery - 1) / 250 * 100) if self.battery != 0 else None + ), + "capabilities": [ + name + for bit, name in enumerate( + ("rumble", "lightbar", "player_leds", "motion") + ) + if self.capabilities & (1 << bit) + ], + "motion": ( + { + "accel": list(self.motion[:3]), + "gyro": list(self.motion[3:]), + } + if self.motion is not None + else None + ), + } @dataclass(frozen=True) @@ -473,12 +894,8 @@ class StickConfig: 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 - ) + _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" @@ -556,9 +973,7 @@ class StickConfig: 1, 0x7FFF, ), - _require_int( - obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF - ), + _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"), ) @@ -570,55 +985,87 @@ class TriggerConfig: upper_saturation: int curve_q8_8: int digital_threshold: int + output: 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 - ) + _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 - ) + _require_int(self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF) + if ( + type(self.output) is not int + or self.output != PROFILE_NONE_BUTTON + and not 0 <= self.output < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid trigger output mapping") @classmethod - def from_bytes(cls, payload: bytes) -> TriggerConfig: - if len(payload) != 10 or payload[8:] != b"\x00\x00": + def from_bytes( + cls, + payload: bytes, + *, + schema_version: int, + source_index: int, + ) -> TriggerConfig: + if len(payload) != 10 or payload[9] != 0: raise ConfigManagerError("invalid trigger configuration encoding") - return cls(*struct.unpack("= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else len(LOGICAL_BUTTONS) + source_index + ) + lower, upper, curve_q8_8, threshold = struct.unpack(" bytes: return struct.pack( - " dict[str, int]: + def to_json_object(self) -> dict[str, int | str | None]: return { "lower_deadzone": self.lower_deadzone, "upper_saturation": self.upper_saturation, "curve_q8_8": self.curve_q8_8, "digital_threshold": self.digital_threshold, + "output": _control_name(self.output), } @classmethod - def from_json_object(cls, value: Any, name: str) -> TriggerConfig: + def from_json_object( + cls, + value: Any, + name: str, + *, + schema_version: int, + source_index: int, + ) -> TriggerConfig: fields = ( "lower_deadzone", "upper_saturation", "curve_q8_8", "digital_threshold", ) + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + fields += ("output",) obj = _require_object(value, fields, name) + output = ( + _control_index(obj["output"], f"{name}.output") + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else len(LOGICAL_BUTTONS) + source_index + ) return cls( _require_int( obj["lower_deadzone"], @@ -632,28 +1079,26 @@ class TriggerConfig: 1, 0xFFFF, ), - _require_int( - obj["curve_q8_8"], f"{name}.curve_q8_8", 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, ), + output, ) + def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: - if ( - trigger.digital_threshold - != PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD - ): + 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, + trigger.output, ) @@ -681,18 +1126,14 @@ class MacroStep: 0, PROFILE_MAXIMUM_WAIT_MS, ) - _require_int( - self.output_button_mask, "macro output button mask", 0, 0xFFFF - ) + _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(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( @@ -710,23 +1151,16 @@ class MacroStep: ): 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) + 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) + 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" @@ -766,6 +1200,61 @@ class MacroStep: 0, ) + def to_sparse_bytes(self) -> bytes: + if self.step_type != 0: + raise ConfigManagerError("only state steps use sparse encoding") + payload = bytearray(struct.pack(" tuple[MacroStep, int]: + if len(payload) < 3: + raise ConfigManagerError(f"{name} is truncated") + flags, duration = struct.unpack_from(" tuple[int, ...]: + nonlocal offset + size = struct.calcsize(fmt) + if offset + size > len(payload): + raise ConfigManagerError(f"{name} is truncated") + values = struct.unpack_from(fmt, payload, offset) + offset += size + return values + + buttons = take(" dict[str, Any]: return { "type": MACRO_STEP_TYPES[self.step_type], @@ -775,9 +1264,7 @@ class MacroStep: if self.override_flags & (1 << index) ], "duration_ms": self.duration_ms, - "output_buttons": _button_mask_to_json( - self.output_button_mask - ), + "output_buttons": _button_mask_to_json(self.output_button_mask), "left_stick": { "x": self.left_stick_x, "y": self.left_stick_y, @@ -809,21 +1296,13 @@ class MacroStep: 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" - ) + 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" - ) + 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" - ) + 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" ) @@ -836,21 +1315,11 @@ class MacroStep: 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 - ), + _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", @@ -866,6 +1335,230 @@ class MacroStep: ) +@dataclass(frozen=True) +class ProfileShortcuts: + modifier: int = PROFILE_NONE_BUTTON + profiles: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * PROFILE_CAPACITY + + def __post_init__(self) -> None: + if type(self.modifier) is not int or ( + self.modifier != PROFILE_NONE_BUTTON + and not 0 <= self.modifier < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid shortcut modifier") + if type(self.profiles) is not tuple or len(self.profiles) != PROFILE_CAPACITY: + raise ConfigManagerError("shortcuts must contain eight profile selectors") + selected: set[int] = set() + for selector in self.profiles: + if type(selector) is not int: + raise ConfigManagerError("invalid shortcut selector") + if selector == PROFILE_NONE_BUTTON: + continue + if not (0 <= selector < 4 or 12 <= selector < 16): + raise ConfigManagerError( + "shortcut selector must be a face or D-pad button" + ) + if selector in selected or selector == self.modifier: + raise ConfigManagerError( + "shortcut selectors must be unique and differ from modifier" + ) + if self.modifier == PROFILE_NONE_BUTTON: + raise ConfigManagerError("enabled shortcuts require a modifier") + selected.add(selector) + + def to_json_object(self) -> dict[str, Any]: + return { + "modifier": _control_name(self.modifier), + "profiles": [_button_name(value) for value in self.profiles], + } + + @classmethod + def from_json_object(cls, value: Any) -> ProfileShortcuts: + obj = _require_object(value, ("modifier", "profiles"), "profile.shortcuts") + if type(obj["profiles"]) is not list: + raise ConfigManagerError("profile.shortcuts.profiles must be an array") + return cls( + _control_index(obj["modifier"], "profile.shortcuts.modifier"), + tuple( + _button_index(selector, f"profile.shortcuts.profiles[{index}]") + for index, selector in enumerate(obj["profiles"]) + ), + ) + + +@dataclass(frozen=True) +class ProfileShift: + mode: int = 0 + modifier: int = PROFILE_NONE_BUTTON + button_map: tuple[int, ...] = tuple(range(len(LOGICAL_BUTTONS))) + + def __post_init__(self) -> None: + _require_int(self.mode, "Shift mode", 0, len(SHIFT_MODES) - 1) + if type(self.modifier) is not int or ( + self.modifier != PROFILE_NONE_BUTTON + and not 0 <= self.modifier < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid Shift modifier") + if self.mode != 0 and self.modifier == PROFILE_NONE_BUTTON: + raise ConfigManagerError("enabled Shift requires a modifier") + if type(self.button_map) is not tuple or len(self.button_map) != len( + LOGICAL_BUTTONS + ): + raise ConfigManagerError("Shift button map must contain 16 mappings") + for output in self.button_map: + if type(output) is not int or ( + output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) + ): + raise ConfigManagerError("Shift outputs must be buttons or null") + + def to_json_object(self) -> dict[str, Any]: + return { + "mode": SHIFT_MODES[self.mode], + "modifier": _control_name(self.modifier), + "button_map": { + name: _button_name(self.button_map[index]) + for index, name in enumerate(LOGICAL_BUTTONS) + }, + } + + @classmethod + def from_json_object(cls, value: Any) -> ProfileShift: + obj = _require_object( + value, ("mode", "modifier", "button_map"), "profile.shift" + ) + mappings = _require_object( + obj["button_map"], LOGICAL_BUTTONS, "profile.shift.button_map" + ) + return cls( + _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), + _control_index(obj["modifier"], "profile.shift.modifier"), + tuple( + _button_index(mappings[name], f"profile.shift.button_map.{name}") + for name in LOGICAL_BUTTONS + ), + ) + + +@dataclass(frozen=True) +class TurboSettings: + rate_hz: int = 15 + duty_percent: int = 50 + burst_count: int = 3 + + def __post_init__(self) -> None: + _require_int( + self.rate_hz, "Turbo rate", PROFILE_TURBO_RATE_MIN, PROFILE_TURBO_RATE_MAX + ) + _require_int( + self.duty_percent, + "Turbo duty", + PROFILE_TURBO_DUTY_MIN, + PROFILE_TURBO_DUTY_MAX, + ) + _require_int( + self.burst_count, + "Turbo burst count", + PROFILE_TURBO_BURST_MIN, + PROFILE_TURBO_BURST_MAX, + ) + + def to_json_object(self) -> dict[str, Any]: + return { + "rate_hz": self.rate_hz, + "duty_percent": self.duty_percent, + "burst_count": self.burst_count, + } + + @classmethod + def from_json_object(cls, value: Any, name: str) -> TurboSettings: + obj = _require_object(value, ("rate_hz", "duty_percent", "burst_count"), name) + return cls(obj["rate_hz"], obj["duty_percent"], obj["burst_count"]) + + +@dataclass(frozen=True) +class ControllerMacro: + trigger_mask: int + cancel_control: int + steps: tuple[MacroStep, ...] + playback: int = 0 + repeat_count: int = 1 + + def __post_init__(self) -> None: + _require_int( + self.trigger_mask, + "macro trigger chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) + if type(self.cancel_control) is not int or ( + self.cancel_control != PROFILE_NONE_BUTTON + and not 0 <= self.cancel_control < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid macro cancel control") + if ( + type(self.steps) is not tuple + or len(self.steps) > PROFILE_MACRO_STEPS_PER_MACRO + or not all( + isinstance(step, MacroStep) and step.step_type == 0 + for step in self.steps + ) + ): + raise ConfigManagerError("macro must contain zero to eight state steps") + _require_int(self.playback, "macro playback", 0, len(MACRO_PLAYBACK_MODES) - 1) + _require_int( + self.repeat_count, + "macro repeat count", + PROFILE_MACRO_REPEAT_MIN, + PROFILE_MACRO_REPEAT_MAX, + ) + if ( + self.trigger_mask + and self.steps + and self.playback != 0 + and not any(step.duration_ms for step in self.steps) + ): + raise ConfigManagerError("looping macros require a nonzero cycle duration") + + @classmethod + def empty(cls) -> ControllerMacro: + return cls(0, PROFILE_NONE_BUTTON, ()) + + def to_json_object(self) -> dict[str, Any]: + return { + "trigger": _control_mask_to_json(self.trigger_mask), + "cancel": _control_name(self.cancel_control), + "steps": [step.to_json_object() for step in self.steps], + "playback": MACRO_PLAYBACK_MODES[self.playback], + "repeat_count": self.repeat_count, + } + + @classmethod + def from_json_object( + cls, value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ControllerMacro: + fields = ["trigger", "cancel", "steps"] + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.extend(("playback", "repeat_count")) + obj = _require_object(value, fields, name) + steps = obj["steps"] + if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO: + raise ConfigManagerError(f"{name}.steps must contain zero to eight steps") + return cls( + _control_mask_from_json(obj["trigger"], f"{name}.trigger"), + _control_index(obj["cancel"], f"{name}.cancel"), + tuple( + MacroStep.from_json_object(step, f"{name}.steps[{index}]") + for index, step in enumerate(steps) + ), + ( + _require_enum(obj["playback"], MACRO_PLAYBACK_MODES, f"{name}.playback") + if schema_version >= PROFILE_SCHEMA_VERSION + else 0 + ), + obj["repeat_count"] if schema_version >= PROFILE_SCHEMA_VERSION else 1, + ) + + @dataclass(frozen=True) class ControllerProfile: button_map: tuple[int, ...] @@ -877,24 +1570,25 @@ class ControllerProfile: strong_rumble_scale: int confirmation_policy: int switching_chord: int - macro_trigger: int - macro_cancel: int - macro_steps: tuple[MacroStep, ...] + motion_toggle_chord: int + macros: tuple[ControllerMacro, ...] turbo_modes: tuple[int, ...] + shortcuts: ProfileShortcuts = ProfileShortcuts() + shift: ProfileShift = ProfileShift() + turbo_defaults: TurboSettings = TurboSettings() + turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS) 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" - ) + 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) + and not 0 <= mapping < len(LOGICAL_CONTROLS) ): - raise ConfigManagerError("invalid logical button mapping") + raise ConfigManagerError("invalid logical control mapping") if not isinstance(self.left_stick, StickConfig) or not isinstance( self.right_stick, StickConfig ): @@ -902,108 +1596,290 @@ class ControllerProfile: if not isinstance(self.left_trigger, TriggerConfig) or not isinstance( self.right_trigger, TriggerConfig ): + raise ConfigManagerError("profile triggers must be TriggerConfig values") + routed_triggers = [ + trigger.output + for trigger in (self.left_trigger, self.right_trigger) + if len(LOGICAL_BUTTONS) <= trigger.output < len(LOGICAL_CONTROLS) + ] + if len(routed_triggers) != len(set(routed_triggers)): raise ConfigManagerError( - "profile triggers must be TriggerConfig values" + "left and right trigger cannot target the same analog trigger" ) - _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.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}") + _require_int( + self.switching_chord, + "switching chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) + _require_int( + self.motion_toggle_chord, + "motion toggle chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) if ( - type(self.macro_steps) is not tuple - or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY - or not all(isinstance(step, MacroStep) for step in self.macro_steps) + type(self.macros) is not tuple + or len(self.macros) != PROFILE_MACRO_COUNT + or not all(isinstance(macro, ControllerMacro) for macro in self.macros) + ): + raise ConfigManagerError("profile must contain four macros") + total_steps = sum(len(macro.steps) for macro in self.macros) + if total_steps > PROFILE_MACRO_STEP_CAPACITY: + raise ConfigManagerError( + "profile macros exceed the sixteen-step shared pool" + ) + encoded_size = sum( + len(step.to_sparse_bytes()) for macro in self.macros for step in macro.steps + ) + if encoded_size > PROFILE_MACRO_STREAM_SIZE: + raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") + triggers = [ + macro.trigger_mask for macro in self.macros if macro.trigger_mask != 0 + ] + if len(triggers) != len(set(triggers)): + raise ConfigManagerError("macro trigger chords must be unique") + if type(self.turbo_modes) is not tuple or len(self.turbo_modes) != len( + LOGICAL_BUTTONS ): - 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) + if not isinstance(self.shortcuts, ProfileShortcuts): + raise ConfigManagerError("profile shortcuts must be ProfileShortcuts") + if not isinstance(self.shift, ProfileShift): + raise ConfigManagerError("profile Shift must be ProfileShift") + if not isinstance(self.turbo_defaults, TurboSettings): + raise ConfigManagerError("Turbo defaults must be TurboSettings") + if ( + type(self.turbo_overrides) is not tuple + or len(self.turbo_overrides) != len(LOGICAL_BUTTONS) + or not all( + settings is None or isinstance(settings, TurboSettings) + for settings in self.turbo_overrides + ) + ): + raise ConfigManagerError( + "Turbo overrides must contain 16 settings or null entries" + ) @classmethod def default(cls) -> ControllerProfile: stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False) - trigger = TriggerConfig( - 0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD + left_trigger = TriggerConfig( + 0, + 0xFFFF, + 256, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + LOGICAL_CONTROLS.index("left_trigger"), + ) + right_trigger = TriggerConfig( + 0, + 0xFFFF, + 256, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + LOGICAL_CONTROLS.index("right_trigger"), ) return cls( button_map=tuple(range(len(LOGICAL_BUTTONS))), left_stick=stick, right_stick=stick, - left_trigger=trigger, - right_trigger=trigger, + left_trigger=left_trigger, + right_trigger=right_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(),), + motion_toggle_chord=0, + macros=tuple(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT)), turbo_modes=(0,) * len(LOGICAL_BUTTONS), ) @classmethod def from_bytes(cls, payload: bytes) -> ControllerProfile: payload = bytes(payload) - if len(payload) != PROFILE_SIZE: + if len(payload) not in (PROFILE_LEGACY_SIZE, PROFILE_SIZE): raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from("= PROFILE_SCHEMA_VERSION else PROFILE_LEGACY_SIZE + ) if ( - version - not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION) - or size != PROFILE_SIZE + version < PROFILE_LEGACY_SCHEMA_VERSION + or version > PROFILE_SCHEMA_VERSION + or size != expected_size + or len(payload) != expected_size ): raise ConfigManagerError("unsupported profile schema") - if payload[75] != 0 or payload[81] != 0: - raise ConfigManagerError("profile reserved fields must be zero") - if payload[98:100] != b"\x00\x00" or payload[252:] != bytes(4): - raise ConfigManagerError("profile reserved fields must be zero") - macro_count = payload[80] - if not 1 <= macro_count <= PROFILE_MACRO_STEP_CAPACITY: - raise ConfigManagerError("invalid macro step count") - all_steps = tuple( - MacroStep.from_bytes( - payload[ - 100 - + index * PROFILE_MACRO_STEP_SIZE : 100 - + (index + 1) * PROFILE_MACRO_STEP_SIZE - ] - ) - for index in range(PROFILE_MACRO_STEP_CAPACITY) - ) - if any( - step != MacroStep.end() for step in all_steps[macro_count:] + has_control_mapping = version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION + sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION + if sparse_macros: + if payload[75] & 0xCC: + raise ConfigManagerError("profile action flags are invalid") + elif has_action_controls: + if payload[75] & 0xC0: + raise ConfigManagerError("profile action flags are invalid") + if payload[252:] != bytes(4): + raise ConfigManagerError("profile reserved fields must be zero") + elif payload[75] != 0 or payload[252:] != bytes(4): + raise ConfigManagerError("legacy profile reserved fields must be zero") + if not has_control_mapping and ( + payload[81] != 0 or payload[98:100] != b"\x00\x00" ): - raise ConfigManagerError("unused macro steps must be canonical end") - left_trigger = TriggerConfig.from_bytes(payload[52:62]) - right_trigger = TriggerConfig.from_bytes(payload[62:72]) + raise ConfigManagerError("legacy profile reserved fields must be zero") + + left_trigger = TriggerConfig.from_bytes( + payload[52:62], schema_version=version, source_index=0 + ) + right_trigger = TriggerConfig.from_bytes( + payload[62:72], schema_version=version, source_index=1 + ) if version == PROFILE_LEGACY_SCHEMA_VERSION: left_trigger = _migrate_legacy_trigger_threshold(left_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger) + + if sparse_macros: + switching_chord = struct.unpack_from("> 4) & 0x03) << 16 + ) + turbo_modes = tuple(payload[80:96]) + macros: list[ControllerMacro] = [] + stream_offset = 0 + total_steps = 0 + for macro_index in range(PROFILE_MACRO_COUNT): + offset = 96 + macro_index * 6 + descriptor = payload[offset : offset + 6] + if descriptor[2] & 0x80 or descriptor[3] != stream_offset: + raise ConfigManagerError("invalid sparse macro descriptor") + trigger_mask = struct.unpack_from("> 2) & 0x1F + if cancel > len(LOGICAL_CONTROLS) - 1 and cancel != 0x1F: + raise ConfigManagerError("invalid sparse macro cancel control") + step_count = descriptor[4] + encoded_size = descriptor[5] + if ( + step_count > PROFILE_MACRO_STEPS_PER_MACRO + or total_steps + step_count > PROFILE_MACRO_STEP_CAPACITY + or stream_offset + encoded_size > PROFILE_MACRO_STREAM_SIZE + ): + raise ConfigManagerError("invalid sparse macro bounds") + consumed = 0 + steps: list[MacroStep] = [] + for step_index in range(step_count): + step, step_size = MacroStep.from_sparse_bytes( + payload[ + 120 + stream_offset + consumed : 120 + + stream_offset + + encoded_size + ], + f"profile.macros[{macro_index}].steps[{step_index}]", + ) + steps.append(step) + consumed += step_size + if consumed != encoded_size: + raise ConfigManagerError("invalid sparse macro size") + macros.append( + ControllerMacro( + trigger_mask, + PROFILE_NONE_BUTTON if cancel == 0x1F else cancel, + tuple(steps), + payload[336 + macro_index * 2] + if version >= PROFILE_SCHEMA_VERSION + else 0, + payload[337 + macro_index * 2] + if version >= PROFILE_SCHEMA_VERSION + else 1, + ) + ) + stream_offset += consumed + total_steps += step_count + if payload[120 + stream_offset : PROFILE_LEGACY_SIZE] != bytes( + PROFILE_MACRO_STREAM_SIZE - stream_offset + ): + raise ConfigManagerError("nonzero sparse macro padding") + else: + switching_chord = struct.unpack_from("> 2) & 0x03) << 16 + motion_toggle_chord |= ((payload[75] >> 4) & 0x03) << 16 + legacy_count = payload[80] + if not 1 <= legacy_count <= PROFILE_LEGACY_MACRO_STEP_CAPACITY: + raise ConfigManagerError("invalid legacy macro step count") + legacy_steps = tuple( + MacroStep.from_bytes( + payload[ + 100 + index * PROFILE_MACRO_STEP_SIZE : 100 + + (index + 1) * PROFILE_MACRO_STEP_SIZE + ] + ) + for index in range(PROFILE_LEGACY_MACRO_STEP_CAPACITY) + ) + if any( + step != MacroStep.end() for step in legacy_steps[legacy_count - 1 :] + ): + raise ConfigManagerError("invalid legacy macro end padding") + state_steps = legacy_steps[: legacy_count - 1] + if any(step.step_type != 0 for step in state_steps): + raise ConfigManagerError("invalid legacy macro state step") + macros = [ + ControllerMacro(trigger_mask, cancel_control, state_steps), + *(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT - 1)), + ] + turbo_modes = tuple(payload[82:98]) + shortcuts = ProfileShortcuts() + shift = ProfileShift() + turbo_defaults = TurboSettings() + turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) + if version >= PROFILE_SCHEMA_VERSION: + shortcuts = ProfileShortcuts(payload[256], tuple(payload[257:265])) + shift = ProfileShift(payload[265], payload[266], tuple(payload[267:283])) + turbo_defaults = TurboSettings(*payload[283:286]) + override_mask = struct.unpack_from(" 2 for mode in turbo_modes): + raise ConfigManagerError("invalid legacy Turbo mode") + return cls( button_map=tuple(payload[4:20]), left_stick=StickConfig.from_bytes(payload[20:36]), @@ -1013,49 +1889,88 @@ class ControllerProfile: weak_rumble_scale=payload[72], strong_rumble_scale=payload[73], confirmation_policy=payload[74], - switching_chord=struct.unpack_from(" bytes: payload = bytearray(PROFILE_SIZE) - struct.pack_into( - "> 16) & 0x03) | ( + ((self.motion_toggle_chord >> 16) & 0x03) << 4 + ) + struct.pack_into("> 16) & 0x03) | ( + cancel << 2 + ) + payload[descriptor_offset + 3] = len(stream) + payload[descriptor_offset + 4] = len(macro.steps) + payload[descriptor_offset + 5] = len(encoded_steps) + stream.extend(encoded_steps) + payload[336 + macro_index * 2] = macro.playback + payload[337 + macro_index * 2] = macro.repeat_count + if len(stream) > PROFILE_MACRO_STREAM_SIZE: + raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") + payload[120 : 120 + len(stream)] = stream + payload[256] = self.shortcuts.modifier + payload[257:265] = bytes(self.shortcuts.profiles) + payload[265] = self.shift.mode + payload[266] = self.shift.modifier + payload[267:283] = bytes(self.shift.button_map) + payload[283:286] = bytes( ( - self.macro_trigger, - self.macro_cancel, - len(self.macro_steps), - 0, + self.turbo_defaults.rate_hz, + self.turbo_defaults.duty_percent, + self.turbo_defaults.burst_count, ) ) - payload[82:98] = bytes(self.turbo_modes) - for index in range(PROFILE_MACRO_STEP_CAPACITY): - step = ( - self.macro_steps[index] - if index < len(self.macro_steps) - else MacroStep.end() - ) - offset = 100 + index * PROFILE_MACRO_STEP_SIZE - payload[offset : offset + PROFILE_MACRO_STEP_SIZE] = step.to_bytes() + override_mask = 0 + settings_offset = 288 + for button, settings in enumerate(self.turbo_overrides): + if settings is not None: + override_mask |= 1 << button + payload[settings_offset : settings_offset + 3] = bytes( + ( + settings.rate_hz, + settings.duty_percent, + settings.burst_count, + ) + ) + settings_offset += 3 + struct.pack_into(" dict[str, Any]: @@ -1063,7 +1978,7 @@ class ControllerProfile: "schema_version": PROFILE_SCHEMA_VERSION, "size": PROFILE_SIZE, "button_map": { - name: _button_name(self.button_map[index]) + name: _control_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, "sticks": { @@ -1077,22 +1992,25 @@ class ControllerProfile: "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 - ], + "confirmation_policy": RUMBLE_POLICIES[self.confirmation_policy], }, + "switching_chord": _control_mask_to_json(self.switching_chord), + "motion_toggle_chord": _control_mask_to_json(self.motion_toggle_chord), + "macros": [macro.to_json_object() for macro in self.macros], "turbo": { name: TURBO_MODES[self.turbo_modes[index]] for index, name in enumerate(LOGICAL_BUTTONS) }, + "shortcuts": self.shortcuts.to_json_object(), + "shift": self.shift.to_json_object(), + "turbo_settings": { + "defaults": self.turbo_defaults.to_json_object(), + "overrides": { + LOGICAL_BUTTONS[index]: settings.to_json_object() + for index, settings in enumerate(self.turbo_overrides) + if settings is not None + }, + }, } def to_json(self) -> str: @@ -1100,7 +2018,20 @@ class ControllerProfile: @classmethod def from_json_object(cls, value: Any) -> ControllerProfile: - fields = ( + if type(value) is not dict: + raise ConfigManagerError("profile must be a JSON object") + schema_version = _require_int( + value.get("schema_version"), + "profile.schema_version", + 0, + 0xFFFF, + ) + if ( + schema_version < PROFILE_LEGACY_SCHEMA_VERSION + or schema_version > PROFILE_SCHEMA_VERSION + ): + raise ConfigManagerError("unsupported profile schema") + fields = [ "schema_version", "size", "button_map", @@ -1108,26 +2039,29 @@ class ControllerProfile: "triggers", "rumble", "switching_chord", - "macro", "turbo", + ] + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + fields.append("motion_toggle_chord") + fields.append( + "macros" + if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION + else "macro" ) + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.extend(("shortcuts", "shift", "turbo_settings")) obj = _require_object(value, fields, "profile") - schema_version = _require_int( - obj["schema_version"], "profile.schema_version", 0, 0xFFFF + expected_size = ( + PROFILE_SIZE + if schema_version >= PROFILE_SCHEMA_VERSION + else PROFILE_LEGACY_SIZE ) - if ( - schema_version - not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION) - or _require_int(obj["size"], "profile.size", 0, 0xFFFF) - != PROFILE_SIZE - ): + if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != expected_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" - ) + sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( obj["triggers"], ("left", "right"), "profile.triggers" ) @@ -1136,33 +2070,123 @@ class ControllerProfile: ("weak_scale", "strong_scale", "confirmation_policy"), "profile.rumble", ) - macro = _require_object( - obj["macro"], ("trigger", "cancel", "steps"), "profile.macro" - ) - turbo = _require_object( - obj["turbo"], LOGICAL_BUTTONS, "profile.turbo" - ) - steps = macro["steps"] - if type(steps) is not list or not ( - 1 <= len(steps) <= PROFILE_MACRO_STEP_CAPACITY - ): - raise ConfigManagerError( - "profile.macro.steps must contain one to eight steps" - ) + turbo = _require_object(obj["turbo"], LOGICAL_BUTTONS, "profile.turbo") left_trigger = TriggerConfig.from_json_object( - triggers["left"], "profile.triggers.left" + triggers["left"], + "profile.triggers.left", + schema_version=schema_version, + source_index=0, ) right_trigger = TriggerConfig.from_json_object( - triggers["right"], "profile.triggers.right" + triggers["right"], + "profile.triggers.right", + schema_version=schema_version, + source_index=1, ) if schema_version == PROFILE_LEGACY_SCHEMA_VERSION: left_trigger = _migrate_legacy_trigger_threshold(left_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger) + + mask_parser = ( + _control_mask_from_json + if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION + else _button_mask_from_json + ) + switching_chord = mask_parser(obj["switching_chord"], "profile.switching_chord") + motion_toggle_chord = ( + mask_parser( + obj["motion_toggle_chord"], + "profile.motion_toggle_chord", + ) + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else 0 + ) + if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION: + macro_values = obj["macros"] + if ( + type(macro_values) is not list + or len(macro_values) != PROFILE_MACRO_COUNT + ): + raise ConfigManagerError("profile.macros must contain four macros") + macros = tuple( + ControllerMacro.from_json_object( + macro, f"profile.macros[{index}]", schema_version=schema_version + ) + for index, macro in enumerate(macro_values) + ) + else: + macro = _require_object( + obj["macro"], ("trigger", "cancel", "steps"), "profile.macro" + ) + steps = macro["steps"] + if type(steps) is not list or not ( + 1 <= len(steps) <= PROFILE_LEGACY_MACRO_STEP_CAPACITY + ): + raise ConfigManagerError( + "profile.macro.steps must contain one to eight steps" + ) + decoded_steps = tuple( + MacroStep.from_json_object(step, f"profile.macro.steps[{index}]") + for index, step in enumerate(steps) + ) + if decoded_steps[-1] != MacroStep.end() or any( + step.step_type != 0 for step in decoded_steps[:-1] + ): + raise ConfigManagerError( + "legacy macro must end with one canonical end step" + ) + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + trigger_mask = mask_parser(macro["trigger"], "profile.macro.trigger") + cancel_control = ( + _control_index(macro["cancel"], "profile.macro.cancel") + if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION + else _button_index(macro["cancel"], "profile.macro.cancel") + ) + else: + trigger = _button_index(macro["trigger"], "profile.macro.trigger") + trigger_mask = 0 if trigger == PROFILE_NONE_BUTTON else 1 << trigger + cancel_control = _button_index(macro["cancel"], "profile.macro.cancel") + macros = ( + ControllerMacro(trigger_mask, cancel_control, decoded_steps[:-1]), + *(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT - 1)), + ) + + shortcuts = ProfileShortcuts() + shift = ProfileShift() + turbo_defaults = TurboSettings() + turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) + if schema_version >= PROFILE_SCHEMA_VERSION: + shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"]) + shift = ProfileShift.from_json_object(obj["shift"]) + settings = _require_object( + obj["turbo_settings"], + ("defaults", "overrides"), + "profile.turbo_settings", + ) + turbo_defaults = TurboSettings.from_json_object( + settings["defaults"], "profile.turbo_settings.defaults" + ) + overrides = settings["overrides"] + if type(overrides) is not dict or any( + name not in LOGICAL_BUTTONS for name in overrides + ): + raise ConfigManagerError( + "Turbo overrides must map button names to settings" + ) + for name, override in overrides.items(): + turbo_overrides[LOGICAL_BUTTONS.index(name)] = ( + TurboSettings.from_json_object( + override, f"profile.turbo_settings.overrides.{name}" + ) + ) + return cls( button_map=tuple( - _button_index( - button_map[name], f"profile.button_map.{name}" - ) + ( + _control_index + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else _button_index + )(button_map[name], f"profile.button_map.{name}") for name in LOGICAL_BUTTONS ), left_stick=StickConfig.from_json_object( @@ -1174,43 +2198,33 @@ class ControllerProfile: left_trigger=left_trigger, right_trigger=right_trigger, weak_rumble_scale=_require_int( - rumble["weak_scale"], - "profile.rumble.weak_scale", - 0, - 0xFF, + rumble["weak_scale"], "profile.rumble.weak_scale", 0, 0xFF ), strong_rumble_scale=_require_int( - rumble["strong_scale"], - "profile.rumble.strong_scale", - 0, - 0xFF, + rumble["strong_scale"], "profile.rumble.strong_scale", 0, 0xFF ), confirmation_policy=_require_enum( rumble["confirmation_policy"], 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) - ), + switching_chord=switching_chord, + motion_toggle_chord=motion_toggle_chord, + macros=macros, turbo_modes=tuple( _require_enum( - turbo[name], TURBO_MODES, f"profile.turbo.{name}" + turbo[name], + TURBO_MODES + if schema_version >= PROFILE_SCHEMA_VERSION + else TURBO_MODES[:3], + f"profile.turbo.{name}", ) for name in LOGICAL_BUTTONS ), + shortcuts=shortcuts, + shift=shift, + turbo_defaults=turbo_defaults, + turbo_overrides=tuple(turbo_overrides), ) @classmethod @@ -1242,17 +2256,20 @@ def _host_transaction_id() -> int: def encode_request(operation: int, payload: bytes = b"") -> bytes: if len(payload) + REQUEST_HEADER_SIZE > MAXIMUM_REQUEST_SIZE: raise ConfigManagerError("management request exceeds EP0 limit") - return struct.pack( - "<4sBBBBHHI", - b"SPMG", - PROTOCOL_VERSION, - operation, - 0, - 0, - len(payload), - 0, - _crc32(payload), - ) + payload + return ( + struct.pack( + "<4sBBBBHHI", + b"SPMG", + PROTOCOL_VERSION, + operation, + 0, + 0, + len(payload), + 0, + _crc32(payload), + ) + + payload + ) def parse_response(payload: bytes, expected_operation: int) -> Envelope: @@ -1272,13 +2289,9 @@ def parse_response(payload: bytes, expected_operation: int) -> Envelope: if magic != b"SPMG": raise ConfigManagerError("device does not implement switch-pico management") if version != PROTOCOL_VERSION: - raise ConfigManagerError( - f"unsupported management protocol version {version}" - ) + raise ConfigManagerError(f"unsupported management protocol version {version}") if operation != expected_operation: - raise ConfigManagerError( - f"unexpected management operation 0x{operation:02x}" - ) + raise ConfigManagerError(f"unexpected management operation 0x{operation:02x}") if len(payload) != RESPONSE_HEADER_SIZE + payload_size: raise ConfigManagerError("invalid management payload size") body = bytes(payload[RESPONSE_HEADER_SIZE:]) @@ -1303,9 +2316,7 @@ def _raise_status(envelope: Envelope, *, pending_ok: bool = False) -> None: if envelope.status == STATUS_PENDING: raise ConfigManagerError("device operation is still pending") raise ConfigManagerError( - STATUS_NAMES.get( - envelope.status, f"unknown device status {envelope.status}" - ) + STATUS_NAMES.get(envelope.status, f"unknown device status {envelope.status}") ) @@ -1321,9 +2332,7 @@ def _control_in(device: UsbDevice, operation: int) -> Envelope: return parse_response(bytes(payload), operation) -def _control_out( - device: UsbDevice, operation: int, payload: bytes = b"" -) -> None: +def _control_out(device: UsbDevice, operation: int, payload: bytes = b"") -> None: request = encode_request(operation, payload) device.ctrl_transfer( 0x40, @@ -1349,9 +2358,7 @@ def read_info(device: UsbDevice) -> DeviceInfo: f"unknown device capability flags 0x{capabilities:02x}" ) if capabilities != 0 and not capabilities & CAPABILITY_INPUT: - raise ConfigManagerError( - "device capability flags omit required input support" - ) + raise ConfigManagerError("device capability flags omit required input support") return DeviceInfo( firmware_version=( envelope.payload[0], @@ -1361,11 +2368,204 @@ def read_info(device: UsbDevice) -> DeviceInfo: board=envelope.payload[3], active_mode=active_mode, capabilities=capabilities, - maximum_configuration_size=struct.unpack_from( - " MacroCapturePage: + _require_int(run_id, "capture run ID", 0, 0xFFFFFFFF) + _require_int(first_index, "capture first index", 0, 128) + _control_out(device, OP_MACRO_CAPTURE, struct.pack("= len(MACRO_CAPTURE_STATES) + or slot >= 4 + or channels == 0 + or channels > 31 + or count > 32 + or total > limit + or not 1 <= limit <= 128 + or first != first_index + or first + count > total + or len(payload) != 32 + count * 20 + or not 1 <= axis <= 32767 + or not 1 <= trigger <= 65535 + or not 1 <= duration <= 80000 + or elapsed > duration * 1000 + or any(payload[29:32]) + ): + raise ConfigManagerError("invalid macro capture bounds or run identity") + events = [] + previous = -1 + for index in range(count): + offset = 32 + index * 20 + event = MacroCaptureEvent(*struct.unpack_from(" elapsed + or any(payload[offset + 18 : offset + 20]) + ): + raise ConfigManagerError("invalid macro capture event timeline") + previous = event.at_us + events.append(event) + return MacroCapturePage( + actual_run, + generation, + elapsed, + slot, + state, + channels, + total, + first, + axis, + trigger, + duration, + limit, + tuple(events), + ) + + +def start_macro_capture( + device: UsbDevice, + slot: int, + connection_generation: int, + *, + channels: int = 1, + max_events: int = 8, + axis_quantum: int = 512, + trigger_quantum: int = 1024, + max_duration_ms: int = 10000, +) -> MacroCapturePage: + for value, name, low, high in ( + (slot, "capture slot", 0, 3), + (connection_generation, "capture generation", 0, 0xFFFFFFFF), + (channels, "capture channels", 1, 31), + (max_events, "capture capacity", 1, 128), + (axis_quantum, "capture axis quantum", 1, 32767), + (trigger_quantum, "capture trigger quantum", 1, 65535), + (max_duration_ms, "capture duration", 1, 80000), + ): + _require_int(value, name, low, high) + _control_out( + device, + OP_MACRO_CAPTURE, + struct.pack( + " MacroCapturePage: + _require_int(run_id, "capture run ID", 1, 0xFFFFFFFF) + _control_out(device, OP_MACRO_CAPTURE, struct.pack(" MacroCapturePage: + page = read_macro_capture(device, run_id) + if page.state_name in ("idle", "recording"): + raise ConfigManagerError("stop recording before collecting macro steps") + events = list(page.events) + while len(events) < page.total_events: + following = read_macro_capture(device, run_id, len(events)) + if ( + following.run_id != page.run_id + or following.connection_generation != page.connection_generation + or following.elapsed_us != page.elapsed_us + or following.total_events != page.total_events + or following.state != page.state + or following.channels != page.channels + or following.slot != page.slot + or following.axis_quantum != page.axis_quantum + or following.trigger_quantum != page.trigger_quantum + or following.max_duration_ms != page.max_duration_ms + or following.max_events != page.max_events + or not following.events + ): + raise ConfigManagerError("macro capture changed while reading pages") + if events and following.events[0].at_us <= events[-1].at_us: + raise ConfigManagerError("macro capture pages overlap") + events.extend(following.events) + return MacroCapturePage( + page.run_id, + page.connection_generation, + page.elapsed_us, + page.slot, + page.state, + page.channels, + page.total_events, + 0, + page.axis_quantum, + page.trigger_quantum, + page.max_duration_ms, + page.max_events, + tuple(events), + ) + + +def capture_macro_steps(page: MacroCapturePage) -> tuple[MacroStep, ...]: + if ( + page.state_name in ("idle", "recording") + or page.first_index != 0 + or len(page.events) != page.total_events + or not page.events + or page.events[0].at_us != 0 + ): + raise ConfigManagerError("a complete stopped capture is required") + steps = [] + for index, event in enumerate(page.events): + end_us = ( + page.events[index + 1].at_us + if index + 1 < len(page.events) + else page.elapsed_us + ) + duration = (end_us + 500) // 1000 - (event.at_us + 500) // 1000 + steps.append( + MacroStep( + 0, + page.channels, + duration, + event.buttons, + event.left_x, + event.left_y, + event.right_x, + event.right_y, + event.left_trigger, + event.right_trigger, + ) + ) + return tuple(steps) + + def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS) _raise_status(envelope) @@ -1381,6 +2581,411 @@ def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: ) +def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics: + _raise_status(envelope) + if envelope.schema_version != HAPTICS_EXPERIMENT_SCHEMA_VERSION: + raise ConfigManagerError( + f"unsupported haptics experiment schema {envelope.schema_version}; " + "update the host tool and experiment firmware together" + ) + if len(envelope.payload) != HAPTICS_EXPERIMENT_SIZE: + raise ConfigManagerError("invalid haptics experiment payload size") + counters = struct.unpack_from("<17I", envelope.payload) + state, slot, last_error, reserved = struct.unpack_from("<4B", envelope.payload, 68) + mode = envelope.payload[72] + packet_frames = envelope.payload[73] + if packet_frames not in (32, 64) or (mode == 0 and packet_frames != 64): + raise ConfigManagerError("invalid haptics packet size") + host_updates, dropped_updates = struct.unpack_from("<2I", envelope.payload, 76) + if envelope.payload[74] not in (0, 1): + raise ConfigManagerError("invalid haptics nonzero flag") + if envelope.flags != 0 or reserved != 0 or envelope.payload[75] != 0: + raise ConfigManagerError("invalid haptics experiment reserved flags") + if state >= len(HAPTICS_EXPERIMENT_STATES): + raise ConfigManagerError(f"invalid haptics experiment state {state}") + if mode >= len(HAPTICS_EXPERIMENT_MODES): + raise ConfigManagerError(f"invalid haptics experiment mode {mode}") + if slot >= HAPTICS_EXPERIMENT_SLOT_COUNT and not ( + slot == 0xFF and HAPTICS_EXPERIMENT_STATES[state] in ("idle", "unsupported") + ): + raise ConfigManagerError(f"invalid haptics experiment slot {slot}") + return HapticsExperimentDiagnostics( + *counters, + state=state, + slot=None if slot == 0xFF else slot, + last_error=last_error, + mode=mode, + host_updates=host_updates, + dropped_updates=dropped_updates, + packet_frames=packet_frames, + last_packet_nonzero=bool(envelope.payload[74]), + ) + + +def read_haptics_experiment(device: UsbDevice) -> HapticsExperimentDiagnostics: + try: + envelope = _control_in(device, OP_HAPTICS_EXPERIMENT) + except usb.core.USBError as exc: + if exc.errno == 32 or exc.backend_error_code == -9: + raise ConfigManagerError( + "firmware does not support haptics experiment operation 0x40. " + + HAPTICS_EXPERIMENT_ENABLE_HINT + ) from exc + raise + return parse_haptics_experiment(envelope) + + +def parse_haptics_transport_probe(envelope: Envelope) -> HapticsTransportProbe: + if envelope.status == STATUS_UNSUPPORTED_SCHEMA: + raise ConfigManagerError(HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT) + _raise_status(envelope) + if envelope.schema_version != HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION: + raise ConfigManagerError( + f"unsupported haptics transport probe schema {envelope.schema_version}; " + "update the host tool and experiment firmware together" + ) + if len(envelope.payload) != HAPTICS_TRANSPORT_PROBE_SIZE: + raise ConfigManagerError("invalid haptics transport probe payload size") + if envelope.flags != 0: + raise ConfigManagerError("invalid haptics transport probe reserved flags") + fields = struct.unpack("<38Ii5I", envelope.payload) + if fields[2] > 0xFFFF: + raise ConfigManagerError("invalid haptics transport probe connection handle") + if fields[25] not in (0, 1): + raise ConfigManagerError("invalid haptics transport probe active boolean") + if envelope.generation != fields[0]: + raise ConfigManagerError("haptics transport probe envelope run ID mismatch") + return HapticsTransportProbe(*fields[:25], bool(fields[25]), *fields[26:]) + + +def read_haptics_transport_probe(device: UsbDevice) -> HapticsTransportProbe: + try: + envelope = _control_in(device, OP_HAPTICS_TRANSPORT_PROBE) + except usb.core.USBError as exc: + if exc.errno == 32 or exc.backend_error_code == -9: + raise ConfigManagerError(HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT) from exc + raise + return parse_haptics_transport_probe(envelope) + + +def read_haptics_experiment_profile( + device: UsbDevice, +) -> tuple[HapticsExperimentDiagnostics, HapticsTransportProbe]: + before = read_haptics_experiment(device) + transport = read_haptics_transport_probe(device) + after = read_haptics_experiment(device) + correlation = (before.run_id, before.connection_generation) + if ( + (after.run_id, after.connection_generation) != correlation + or (transport.run_id, transport.connection_generation) != correlation + or (after.slot, after.mode) != (before.slot, before.mode) + ): + raise ConfigManagerError( + "haptics experiment run, slot, mode, or connection generation changed or does not " + "match the transport profile; cannot attribute measurements. " + "Read profile again after the accepted run has started or finished." + ) + return after, transport + + +def _print_haptics_experiment_profile( + snapshot: HapticsExperimentDiagnostics, + transport: HapticsTransportProbe, + *, + as_json: bool, +) -> None: + if as_json: + values = snapshot.to_json_object() + values["transport"] = transport.to_json_object() + values["host_monotonic_s"] = time.monotonic() + print(json.dumps(values, sort_keys=True), flush=True) + return + _print_haptics_experiment(snapshot, as_json=False) + print( + f"Transport profile: run={transport.run_id}; " + f"connection_generation={transport.connection_generation}; " + f"handle=0x{transport.connection_handle:04x}; active={transport.active}" + ) + for label, calls, maximum, total in ( + ( + "timer lateness", + transport.timer_wakes, + transport.max_timer_lateness_us, + transport.total_timer_lateness_us, + ), + ( + "permission wait", + transport.permission_callbacks, + transport.max_permission_wait_us, + transport.total_permission_wait_us, + ), + ( + "l2cap_send", + transport.send_calls, + transport.max_send_us, + transport.total_send_us, + ), + ( + "HCI write", + transport.write_calls, + transport.max_write_us, + transport.total_write_us, + ), + ( + "HCI read", + transport.read_calls, + transport.max_read_us, + transport.total_read_us, + ), + ( + "data-source poll", + transport.poll_calls, + transport.max_poll_us, + transport.total_poll_us, + ), + ): + print(f" {label}: calls={calls}, max_us={maximum}, total_us={total}") + print(f" read_packets: {transport.read_packets}") + print( + f" completions: events={transport.completion_events}, " + f"packets={transport.completed_packets}, " + f"max_gap_us={transport.max_completion_gap_us}" + ) + print(f" max_poll_gap_us: {transport.max_poll_gap_us}") + print( + f" observed ACL slots: max_outstanding={transport.max_outstanding_acl}, " + f"min_free={transport.min_free_acl}" + ) + print(f" first_tone_send_return_us: {transport.first_tone_send_return_us}") + print( + f" controller advertised ACL: {transport.controller_acl_packet_count} " + f"packets of {transport.controller_acl_packet_bytes} bytes" + ) + print(HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE, flush=True) + + +def _raise_haptics_experiment_failure( + snapshot: HapticsExperimentDiagnostics, +) -> None: + if not snapshot.firmware_supported: + raise ConfigManagerError( + "haptics experiment is unsupported in this firmware. " + + HAPTICS_EXPERIMENT_ENABLE_HINT + ) + if snapshot.state_name in ("disconnected", "unsupported", "error"): + raise ConfigManagerError( + f"haptics experiment run {snapshot.run_id} " + f"{snapshot.state_name}: {snapshot.error_name} " + f"(last_error={snapshot.last_error}); " + "check the selected controller connection and experiment status" + ) + + +def _print_haptics_experiment( + snapshot: HapticsExperimentDiagnostics, + *, + as_json: bool, +) -> None: + values = snapshot.to_json_object() + if as_json: + values["host_monotonic_s"] = time.monotonic() + print(json.dumps(values, sort_keys=True), flush=True) + return + print( + f"Haptics experiment: {snapshot.state_name}; run={snapshot.run_id}; " + f"slot={snapshot.slot if snapshot.slot is not None else 'none'}; " + f"mode={snapshot.mode_name}" + ) + for name, value in asdict(snapshot).items(): + if name not in ("state", "slot", "last_error", "mode"): + print(f" {name}: {value}") + print(f" last_error: {snapshot.last_error} ({snapshot.error_name})") + delay = snapshot.first_tone_submission_delay_us + print( + " first_tone_submission_delay_us: " + f"{delay if delay is not None else 'not recorded'}" + ) + if snapshot.mode == 1: + print( + "Gameplay: continuous 3 kHz, 64 stereo frames/packet; " + "21333.333 us lookback, 8000 us command window, " + "50000 us host-effect watchdog; balanced 2x gameplay gain with a " + "0.8-power response curve, jointly headroom-limited. " + "Silence continues without commands." + ) + print(HAPTICS_GAMEPLAY_ARMING_NOTE) + else: + print( + "Pattern: 3 kHz, 64 stereo frames/packet, peak 32/127; " + "48 packets priming silence (1.024 s), 4 cycles of " + "left 100 Hz / silence / right 200 Hz / silence " + "(12 packets = 256 ms each), 48 packets trailing silence (1.024 s); " + "288 packets / 6.144 s total. Initial mode handoff carries 32 silent frames." + ) + print( + "Timestamp fields are low 32-bit Pico uptime microseconds; " + "differences use unsigned wraparound." + ) + print(values["evidence_note"], flush=True) + + +def _watch_haptics_experiment( + device: UsbDevice, + snapshot: HapticsExperimentDiagnostics, + deadline: float, + *, + as_json: bool, +) -> None: + run_id = snapshot.run_id + slot = snapshot.slot + mode = snapshot.mode + while True: + _print_haptics_experiment(snapshot, as_json=as_json) + _raise_haptics_experiment_failure(snapshot) + if snapshot.state_name not in ("pending", "running"): + return + remaining = deadline - time.monotonic() + if remaining <= 0: + if mode == 1: + raise ConfigManagerError( + f"haptics gameplay watch reached --timeout; run {run_id} " + "is still armed. Use haptics-experiment stop --slot " + f"{slot} to disarm; watching does not stop the stream." + ) + raise ConfigManagerError( + f"haptics experiment run {run_id} did not reach a terminal " + "state before --timeout; it may still be active, use status " + "or stop --slot " + str(slot) + ) + time.sleep(min(0.1, remaining)) + snapshot = read_haptics_experiment(device) + if (snapshot.run_id, snapshot.slot, snapshot.mode) != (run_id, slot, mode): + raise ConfigManagerError( + "haptics experiment run changed while watching; " + "cannot attribute measurements to the requested run" + ) + + +def _run_haptics_experiment_command( + device: UsbDevice, + args: argparse.Namespace, +) -> None: + if args.haptics_command == "profile": + snapshot, transport = read_haptics_experiment_profile(device) + _print_haptics_experiment_profile(snapshot, transport, as_json=args.json) + return + before = read_haptics_experiment(device) + deadline = time.monotonic() + args.timeout + action = args.haptics_command + if action == "status": + if not before.firmware_supported: + _print_haptics_experiment(before, as_json=args.json) + print(HAPTICS_EXPERIMENT_ENABLE_HINT, file=sys.stderr) + elif args.watch: + _watch_haptics_experiment(device, before, deadline, as_json=args.json) + else: + _print_haptics_experiment(before, as_json=args.json) + _raise_haptics_experiment_failure(before) + return + if not before.firmware_supported: + _raise_haptics_experiment_failure(before) + active = before.state_name in ("pending", "running") + starting = action in ("start", "gameplay") + if starting and active: + raise ConfigManagerError( + f"haptics experiment is already {before.state_name} " + f"on slot {before.slot}; stop that run before starting another" + ) + if action == "stop": + if active and before.slot != args.slot: + raise ConfigManagerError( + f"haptics experiment is active on slot {before.slot}, " + f"not requested slot {args.slot}" + ) + if not active: + _print_haptics_experiment(before, as_json=args.json) + print( + "No active haptics experiment to stop.", + file=sys.stderr if args.json else sys.stdout, + ) + return + _control_out( + device, + OP_HAPTICS_EXPERIMENT, + bytes(({"start": 1, "gameplay": 2, "stop": 0}[action], args.slot)), + ) + print( + f"{action.capitalize()} request accepted; pending firmware confirmation. " + "USB ACK is not evidence of stream start, completion, or playback.", + file=sys.stderr if args.json else sys.stdout, + flush=True, + ) + expected_run_id = (before.run_id + 1) & 0xFFFFFFFF if starting else before.run_id + expected_mode = (1 if action == "gameplay" else 0) if starting else before.mode + observed_run = False + while True: + snapshot = read_haptics_experiment(device) + if snapshot.run_id == expected_run_id: + observed_run = True + if snapshot.slot != args.slot: + raise ConfigManagerError( + "haptics experiment response belongs to another slot" + ) + if snapshot.mode != expected_mode: + raise ConfigManagerError( + "haptics experiment response belongs to another mode" + ) + if snapshot.state_name in ("disconnected", "unsupported", "error"): + _print_haptics_experiment(snapshot, as_json=args.json) + _raise_haptics_experiment_failure(snapshot) + if starting and args.watch: + _watch_haptics_experiment(device, snapshot, deadline, as_json=args.json) + return + if starting and ( + snapshot.state_name == "running" + or (action == "start" and snapshot.state_name == "completed") + ): + _print_haptics_experiment(snapshot, as_json=args.json) + if snapshot.state_name == "running": + print( + "Firmware reports running; use status --watch " + "to capture later states and failures.", + file=sys.stderr if args.json else sys.stdout, + ) + return + if snapshot.state_name in ("stopped", "completed") and action == "stop": + _print_haptics_experiment(snapshot, as_json=args.json) + return + if snapshot.state_name not in ("pending", "running"): + raise ConfigManagerError( + f"haptics experiment {action} ended in unexpected " + f"state {snapshot.state_name}" + ) + elif ( + action == "stop" + or observed_run + or (snapshot.run_id, snapshot.slot, snapshot.mode) + != (before.run_id, before.slot, before.mode) + ): + raise ConfigManagerError( + f"haptics experiment run changed before {action} was confirmed" + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + if action == "gameplay": + raise ConfigManagerError( + "haptics gameplay was not confirmed before --timeout; " + "the stream may still be armed. Use status to inspect it " + f"or haptics-experiment stop --slot {args.slot} to disarm " + "(USB ACK alone does not confirm it)" + ) + raise ConfigManagerError( + f"haptics experiment {action} was not confirmed before " + "--timeout; check status (USB ACK alone does not confirm it)" + ) + time.sleep(min(0.1, remaining)) + + def read_configuration(device: UsbDevice) -> AdapterConfiguration: envelope = _control_in(device, OP_CONFIGURATION_READ) _raise_status(envelope) @@ -1399,9 +3004,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: ): raise ConfigManagerError("invalid stored pairing-window duration") if requested_mode >= len(REQUESTED_MODE_NAMES): - raise ConfigManagerError( - f"invalid stored requested USB mode {requested_mode}" - ) + raise ConfigManagerError(f"invalid stored requested USB mode {requested_mode}") return AdapterConfiguration( pairing_window_seconds=pairing_window_seconds, generation=envelope.generation, @@ -1441,13 +3044,10 @@ def write_configuration( <= configuration.pairing_window_seconds <= PAIRING_WINDOW_SECONDS_MAX ): - raise ConfigManagerError( - "pairing window must be between 10 and 300 seconds" - ) - if ( - type(configuration.requested_mode) is not int - or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES) - ): + raise ConfigManagerError("pairing window must be between 10 and 300 seconds") + if type( + configuration.requested_mode + ) is not int or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES): raise ConfigManagerError("invalid requested USB mode") payload = struct.pack( " TransactionStatus: transaction_id = _host_transaction_id() - _control_out( - device, OP_CONFIGURATION_RESET, struct.pack(" None: - _require_int( - transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK - ) + _require_int(transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK) _control_out(device, OP_REBOOT, struct.pack(" None: _control_out(device, OP_BOOTSEL_REBOOT) @@ -1535,6 +3130,10 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: _raise_status(envelope) if envelope.schema_version not in ( PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, + PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, + PROFILE_ACTION_CONTROL_SCHEMA_VERSION, + PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -1554,18 +3153,26 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: 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") + alias_size = envelope.payload[offset + 16] + alias_payload = envelope.payload[offset + 17 : offset + 48] + if ( + envelope.payload[offset + 15] != 0 + or alias_size > PROFILE_METADATA_MAX_BYTES + or any(alias_payload[alias_size:]) + ): + raise ConfigManagerError("invalid profile-list metadata") + try: + alias = alias_payload[:alias_size].decode("utf-8") + except UnicodeDecodeError as exc: + raise ConfigManagerError("invalid profile-list alias") from exc if index == 0 and not identity.is_global_fallback: - raise ConfigManagerError( - "profile list does not begin with 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)) + entries.append(ProfileListEntry(identity, active_profile_index, alias)) return tuple(entries) @@ -1600,9 +3207,18 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: _raise_status(envelope) if envelope.schema_version not in ( PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, + PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, + PROFILE_ACTION_CONTROL_SCHEMA_VERSION, + PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") + if ( + len(envelope.payload) < 4 + or struct.unpack_from(" str: + size = payload[offset] + encoded = payload[offset + 1 : offset + PROFILE_METADATA_VALUE_SIZE] + if size > PROFILE_METADATA_MAX_BYTES or any(encoded[size:]): + raise ConfigManagerError(f"invalid {label} metadata") + try: + return encoded[:size].decode("utf-8") + except UnicodeDecodeError as exc: + raise ConfigManagerError(f"invalid {label} UTF-8") from exc + + +def parse_profile_metadata(envelope: Envelope) -> ProfileMetadata: + _raise_status(envelope) + if ( + envelope.schema_version != PROFILE_METADATA_SCHEMA_VERSION + or len(envelope.payload) != PROFILE_METADATA_SIZE + ): + raise ConfigManagerError("invalid profile metadata payload") + alias = _decode_profile_metadata_value(envelope.payload, 0, "controller alias") + names = tuple( + _decode_profile_metadata_value( + envelope.payload, + (index + 1) * PROFILE_METADATA_VALUE_SIZE, + f"profile {index + 1} name", + ) + for index in range(PROFILE_CAPACITY) + ) + return ProfileMetadata(alias, names) + + +def read_selected_profile_metadata(device: UsbDevice) -> ProfileMetadata: + return parse_profile_metadata(_control_in(device, OP_PROFILE_METADATA_READ)) + + +def read_profile_metadata( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int = 0, +) -> ProfileMetadata: + select_profile(device, identity, profile_index) + return read_selected_profile_metadata(device) + + +def set_profile_metadata( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int, + value: str, + timeout: float, +) -> TransactionStatus: + if profile_index != PROFILE_NONE_BUTTON: + _validate_profile_index(profile_index) + if type(value) is not str or "\x00" in value: + raise ConfigManagerError("profile metadata must be text") + encoded = value.encode("utf-8") + if len(encoded) > PROFILE_METADATA_MAX_BYTES: + raise ConfigManagerError("profile metadata must contain at most 31 UTF-8 bytes") + transaction_id = _host_transaction_id() + _control_out( + device, + OP_PROFILE_METADATA_SET, + struct.pack(" None: + if identity.is_global_fallback: + raise ConfigManagerError("default profile has no controller to identify") + _control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes()) + + +def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: + _raise_status(envelope) + if ( + envelope.schema_version != PROFILE_PLAYTEST_SCHEMA_VERSION + or len(envelope.payload) != PROFILE_PLAYTEST_SIZE + ): + raise ConfigManagerError("invalid profile playtest payload") + payload = envelope.payload + flags = payload[0] + if flags & ~0x03 or flags != envelope.flags or payload[41] != 0: + raise ConfigManagerError("invalid profile playtest flags") + connected = bool(flags & 0x01) + has_motion = bool(flags & 0x02) + motion_count = payload[38] + if not connected: + if flags != 0 or payload[1] != 0xFF or any(payload[2:]): + raise ConfigManagerError("invalid disconnected playtest payload") + return ProfilePlaytest( + False, + None, + 0, + 0, + None, + 0, + (0, 0), + (0, 0), + (0, 0), + 0, + 0, + None, + ) + if ( + payload[1] >= PROFILE_PLAYTEST_SLOT_COUNT + or not 0 <= motion_count <= 3 + or has_motion != (motion_count != 0) + ): + raise ConfigManagerError("invalid connected playtest payload") + identity = ControllerIdentity.from_bytes(payload[12:26]) + left_x, left_y, right_x, right_y, left_trigger, right_trigger = struct.unpack_from( + " ProfilePlaytest: + return parse_profile_playtest(_control_in(device, OP_PROFILE_PLAYTEST)) + + 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 + not PROFILE_LEGACY_SCHEMA_VERSION + <= envelope.schema_version + <= PROFILE_SCHEMA_VERSION or len(envelope.payload) != 20 ): raise ConfigManagerError("invalid profile transaction-status payload") @@ -1634,18 +3391,16 @@ def _wait_for_profile_transaction( while time.monotonic() < deadline: envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS) if ( - envelope.schema_version != PROFILE_SCHEMA_VERSION + not PROFILE_LEGACY_SCHEMA_VERSION + <= envelope.schema_version + <= PROFILE_SCHEMA_VERSION or len(envelope.payload) != 20 ): - raise ConfigManagerError( - "invalid profile transaction-status payload" - ) + raise ConfigManagerError("invalid profile transaction-status payload") values = struct.unpack(" PairingSnapshot: raise ConfigManagerError("short pairing snapshot") record_count = envelope.payload[0] required = 4 + record_count * PAIRING_RECORD_SIZE - if ( - record_count > PAIRING_RECORD_CAPACITY - or len(envelope.payload) != required - ): + if record_count > PAIRING_RECORD_CAPACITY or len(envelope.payload) != required: raise ConfigManagerError("invalid pairing record count") records: list[PairingRecord] = [] offset = 4 @@ -1793,17 +3543,13 @@ def _candidate_devices() -> Iterable[UsbDevice]: for vendor_id, product_id in USB_IDENTITIES: devices = cast( Iterable[UsbDevice] | None, - usb.core.find( - find_all=True, idVendor=vendor_id, idProduct=product_id - ), + usb.core.find(find_all=True, idVendor=vendor_id, idProduct=product_id), ) if devices is not None: yield from devices -def find_pico( - bus: int | None, address: int | None, timeout: float = 3.0 -) -> UsbDevice: +def find_pico(bus: int | None, address: int | None, timeout: float = 3.0) -> UsbDevice: deadline = time.monotonic() + timeout failures: list[Exception] = [] while True: @@ -1894,13 +3640,9 @@ def _capture_reenumeration_snapshot( if ( device is previous_device or ( - previous_enumeration is not None - and enumeration == previous_enumeration - ) - or ( - selected_location is not None - and location == selected_location + previous_enumeration is not None and enumeration == previous_enumeration ) + or (selected_location is not None and location == selected_location) ): continue other_count += 1 @@ -1966,8 +3708,7 @@ def _wait_for_reenumeration( while True: candidates = list(_candidate_devices()) if not disappeared and not any( - _is_previous_enumeration(device, snapshot) - for device in candidates + _is_previous_enumeration(device, snapshot) for device in candidates ): disappeared = True if disappeared: @@ -2011,8 +3752,7 @@ def _wait_for_reenumeration( ) from failures[-1] if snapshot.selected_location is not None: raise ConfigManagerError( - "Pico did not re-enumerate on its original physical USB port " - "after reboot" + "Pico did not re-enumerate on its original physical USB port after reboot" ) raise ConfigManagerError("Pico did not re-enumerate after reboot") @@ -2030,24 +3770,19 @@ def configure_mode( raise ConfigManagerError("requested USB mode is not available") before_info = read_info(device) before_configuration = read_configuration(device) - if ( - before_configuration.requested_mode == requested_mode - and _mode_is_active(requested_mode, before_info.active_mode) + if before_configuration.requested_mode == requested_mode and _mode_is_active( + requested_mode, before_info.active_mode ): return device, False reenumeration_snapshot = _capture_reenumeration_snapshot(device) transaction = set_mode(device, requested_mode, timeout) request_reboot(device, transaction.transaction_id) - reenumerated = _wait_for_reenumeration( - reenumeration_snapshot, timeout - ) + reenumerated = _wait_for_reenumeration(reenumeration_snapshot, timeout) after_info = read_info(reenumerated) after_configuration = read_configuration(reenumerated) if after_configuration.requested_mode != requested_mode: - raise ConfigManagerError( - "requested USB mode was not stored after reboot" - ) + raise ConfigManagerError("requested USB mode was not stored after reboot") if not _mode_is_active(requested_mode, after_info.active_mode): raise ConfigManagerError( f"device activated {after_info.mode_name()} instead of " @@ -2078,8 +3813,7 @@ def _print_profiles(entries: Sequence[ProfileListEntry]) -> None: f"VID:PID {identity.vendor_id:04X}:{identity.product_id:04X}" ) print( - f"{index}: {description} " - f"(active profile {entry.active_profile_index + 1})" + f"{index}: {description} (active profile {entry.active_profile_index + 1})" ) @@ -2098,9 +3832,7 @@ 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 + raise ConfigManagerError(f"could not read profile JSON {path}: {exc}") from exc return ControllerProfile.from_json(payload) @@ -2108,9 +3840,7 @@ 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 + raise ConfigManagerError(f"could not write profile JSON {path}: {exc}") from exc def _profile_number(value: str) -> int: @@ -2118,10 +3848,12 @@ def _profile_number(value: str) -> int: number = int(value) except ValueError as exc: raise argparse.ArgumentTypeError( - "profile must be a number from 1 to 4" + f"profile must be a number from 1 to {PROFILE_CAPACITY}" ) from exc if not 1 <= number <= PROFILE_CAPACITY: - raise argparse.ArgumentTypeError("profile must be a number from 1 to 4") + raise argparse.ArgumentTypeError( + f"profile must be a number from 1 to {PROFILE_CAPACITY}" + ) return number - 1 @@ -2133,12 +3865,22 @@ def _identity_index(value: str) -> int: "identity must be a non-negative list index" ) from exc if index < 0: - raise argparse.ArgumentTypeError( - "identity must be a non-negative list index" - ) + raise argparse.ArgumentTypeError("identity must be a non-negative list index") return index +def _tcp_port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "port must be a number from 0 to 65535" + ) from exc + if not 0 <= port <= 0xFFFF: + raise argparse.ArgumentTypeError("port must be a number from 0 to 65535") + return port + + def _add_identity_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--identity", @@ -2173,9 +3915,46 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser( "diagnostics", help="show live Bluetooth and rumble pipeline counters" ) - reboot = commands.add_parser( - "reboot", help="reboot into a firmware or ROM target" + haptics = commands.add_parser( + "haptics-experiment", help="control the opt-in DualSense PCM experiment" ) + haptics_commands = haptics.add_subparsers(dest="haptics_command", required=True) + for action, help_text in ( + ("start", "run the finite PCM fixture"), + ("gameplay", "arm continuous Nintendo HD-rumble PCM until stopped"), + ("status", "read the current fixture or gameplay stream"), + ("stop", "stop and disarm the selected stream"), + ): + experiment = haptics_commands.add_parser(action, help=help_text) + if action != "status": + experiment.add_argument( + "--slot", + type=int, + choices=range(HAPTICS_EXPERIMENT_SLOT_COUNT), + default=0, + help="connected controller slot (default: 0)", + ) + if action != "stop": + experiment.add_argument( + "--watch", + action="store_true", + help="capture 100 ms status samples until terminal or --timeout; " + "does not stop an armed gameplay stream", + ) + experiment.add_argument( + "--json", + action="store_true", + help="emit JSON diagnostics (one object per sample with --watch)", + ) + profile = haptics_commands.add_parser( + "profile", help="read a run-correlated transport timing snapshot" + ) + profile.add_argument( + "--json", + action="store_true", + help="emit diagnostics with a nested transport profile object", + ) + reboot = commands.add_parser("reboot", help="reboot into a firmware or ROM target") reboot.add_argument("target", choices=("bootsel",)) mode = commands.add_parser("mode", help="select the persistent USB mode") mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES) @@ -2194,14 +3973,28 @@ def build_parser() -> argparse.ArgumentParser: 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 + "profiles", + help="open the editor or manage controller profiles as JSON", ) + profile_commands = profiles.add_subparsers(dest="profile_command", required=True) profile_commands.add_parser( "list", help="list profile identities and active profiles" ) + profile_edit = profile_commands.add_parser( + "edit", help="open the local graphical profile editor" + ) + profile_edit.add_argument( + "--port", + type=_tcp_port, + default=8765, + metavar="PORT", + help="localhost port (default: 8765; use 0 to choose automatically)", + ) + profile_edit.add_argument( + "--no-browser", + action="store_true", + help="print the editor URL without opening a browser", + ) profile_export = profile_commands.add_parser( "export", help="export a profile as JSON" ) @@ -2237,9 +4030,7 @@ def build_parser() -> argparse.ArgumentParser: _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 - ) + pairing_commands = pairings.add_subparsers(dest="pairing_command", required=True) pairing_commands.add_parser("list", help="list stored pairings") pairing_clear = pairing_commands.add_parser("clear", help="clear pairings") pairing_clear.add_argument("--yes", action="store_true") @@ -2251,30 +4042,31 @@ 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 == "haptics-experiment" and not math.isfinite(args.timeout): + print("error: --timeout must be finite", file=sys.stderr) + return 2 + 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 - ): + 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 == "edit": + profile_web = importlib.import_module(".profile_web", __package__) + profile_web.run_profile_editor( + bus=args.bus, + address=args.address, + timeout=args.timeout, + port=args.port, + open_browser=not args.no_browser, + ) + return 0 if args.command == "profiles" and args.profile_command == "import": imported_profile = _load_profile(args.path) device = find_pico(args.bus, args.address, args.timeout) @@ -2292,41 +4084,22 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Mode capabilities: {info.capability_summary()}") print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") - print( - "Pairing window: " - f"{configuration.pairing_window_seconds} seconds" - ) + print(f"Pairing window: {configuration.pairing_window_seconds} seconds") elif args.command == "diagnostics": diagnostics = read_runtime_diagnostics(device) print(f"Initialization stage: {diagnostics.initialization_stage}") print(f"Rumble timer ticks: {diagnostics.rumble_timer_ticks}") - print( - "Configuration timer ticks: " - f"{diagnostics.configuration_timer_ticks}" - ) + print(f"Configuration timer ticks: {diagnostics.configuration_timer_ticks}") print(f"Controller reports: {diagnostics.controller_reports}") - print( - "Host rumble requests: " - f"{diagnostics.host_rumble_requests}" - ) - print( - "Local feedback requests: " - f"{diagnostics.local_feedback_requests}" - ) + print(f"Host rumble requests: {diagnostics.host_rumble_requests}") + print(f"Local feedback requests: {diagnostics.local_feedback_requests}") print(f"Rumble dispatches: {diagnostics.rumble_dispatches}") print(f"Active slots: {diagnostics.active_slots}") - print( - "Rumble-capable slots: " - f"{diagnostics.rumble_capable_slots}" - ) - print( - "Feedback-pending slots: " - f"{diagnostics.feedback_pending_slots}" - ) - print( - "Rumble-pending slots: " - f"{diagnostics.rumble_pending_slots}" - ) + print(f"Rumble-capable slots: {diagnostics.rumble_capable_slots}") + print(f"Feedback-pending slots: {diagnostics.feedback_pending_slots}") + print(f"Rumble-pending slots: {diagnostics.rumble_pending_slots}") + elif args.command == "haptics-experiment": + _run_haptics_experiment_command(device, args) elif args.command == "reboot": request_bootsel_reboot(device) print("Rebooting into USB BOOTSEL mode.") @@ -2340,9 +4113,7 @@ def main(argv: Sequence[str] | None = None) -> int: elif args.command == "config": if args.config_command == "show": configuration = read_configuration(device) - print( - f"pairing_window_seconds={configuration.pairing_window_seconds}" - ) + print(f"pairing_window_seconds={configuration.pairing_window_seconds}") print( "requested_mode=" f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" @@ -2368,10 +4139,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) else: status = reset_configuration(device, args.timeout) - print( - "Reset configuration at generation " - f"{status.stored_generation}." - ) + print(f"Reset configuration at generation {status.stored_generation}.") elif args.command == "profiles": entries = list_profiles(device) if args.profile_command == "list": @@ -2379,9 +4147,7 @@ def main(argv: Sequence[str] | None = None) -> int: else: identity = _resolve_profile_identity(entries, args.identity) if args.profile_command == "export": - profile = read_profile( - device, identity, args.profile_index - ) + profile = read_profile(device, identity, args.profile_index) _save_profile(args.path, profile) print( f"Exported profile {args.profile_index + 1} " @@ -2402,9 +4168,7 @@ def main(argv: Sequence[str] | None = None) -> int: f"(CRC {status.stored_crc:08x})." ) elif args.profile_command == "reset": - reset_profile( - device, identity, args.profile_index, args.timeout - ) + reset_profile(device, identity, args.profile_index, args.timeout) target = ( "all profiles" if args.profile_index is None @@ -2412,9 +4176,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) print(f"Reset {target} for identity {args.identity}.") else: - activate_profile( - device, identity, args.profile_index, args.timeout - ) + activate_profile(device, identity, args.profile_index, args.timeout) print( f"Activated profile {args.profile_index + 1} " f"for identity {args.identity}." diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py new file mode 100644 index 0000000..57e3ffa --- /dev/null +++ b/src/switch_pico_bridge/profile_web.py @@ -0,0 +1,985 @@ +"""Local-only HTTP backend for the graphical controller profile editor.""" + +from __future__ import annotations + +import json +import secrets +import webbrowser +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +from importlib import resources +from typing import Any, Callable, cast +from urllib.parse import parse_qs, urlsplit + +import usb.core + +from . import config_manager + +_MAXIMUM_REQUEST_BYTES = 64 * 1024 +_ASSET_TYPES = { + "/": ("profile_editor.html", "text/html; charset=utf-8"), + "/app.js": ("profile_editor.js", "text/javascript; charset=utf-8"), + "/playtest.js": ( + "profile_playtest.js", + "text/javascript; charset=utf-8", + ), + "/style.css": ("profile_editor.css", "text/css; charset=utf-8"), + "/assets/controller-switch-pro.svg": ( + "assets/controller-switch-pro.svg", + "image/svg+xml", + ), + "/assets/controller-dualsense.svg": ( + "assets/controller-dualsense.svg", + "image/svg+xml", + ), + "/assets/controller-xbox.svg": ( + "assets/controller-xbox.svg", + "image/svg+xml", + ), +} + +_CONTROL_LABELS = { + "generic": { + "north": "Y", + "east": "B", + "south": "A", + "west": "X", + "left_shoulder": "LB", + "right_shoulder": "RB", + "left_trigger": "LT", + "right_trigger": "RT", + "select": "View", + "start": "Menu", + "capture": "Share", + "system": "Xbox", + "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "xbox": { + "north": "Y", + "east": "B", + "south": "A", + "west": "X", + "left_shoulder": "LB", + "right_shoulder": "RB", + "left_trigger": "LT", + "right_trigger": "RT", + "select": "View", + "start": "Menu", + "capture": "Share", + "system": "Xbox", + "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "switch": { + "north": "X", + "east": "A", + "south": "B", + "west": "Y", + "left_shoulder": "L", + "right_shoulder": "R", + "left_trigger": "ZL", + "right_trigger": "ZR", + "select": "Minus", + "start": "Plus", + "capture": "Capture", + "system": "Home", + "left_stick": "Left Stick", + "right_stick": "Right Stick", + }, + "playstation": { + "north": "Triangle", + "east": "Circle", + "south": "Cross", + "west": "Square", + "left_shoulder": "L1", + "right_shoulder": "R1", + "left_trigger": "L2", + "right_trigger": "R2", + "select": "Create", + "start": "Options", + "capture": "Touchpad", + "system": "PS", + "left_stick": "L3", + "right_stick": "R3", + }, +} + + +def _controller_presentation( + identity: config_manager.ControllerIdentity, +) -> dict[str, str]: + if identity.is_global_fallback: + return {"model": "Generic controller", "style": "generic"} + + known = { + (0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch"), + (0x054C, 0x0CE6): ("Sony DualSense", "playstation"), + (0x054C, 0x09CC): ("Sony DualShock 4", "playstation"), + } + exact = known.get((identity.vendor_id, identity.product_id)) + if exact is not None: + model, style = exact + return {"model": model, "style": style} + if identity.vendor_id == 0x054C: + return {"model": "Sony controller", "style": "playstation"} + if identity.vendor_id == 0x045E: + return {"model": "Xbox controller", "style": "xbox"} + if identity.vendor_id == 0x2DC8: + return {"model": "8BitDo controller", "style": "switch"} + return {"model": "Connected controller", "style": "generic"} + + +def _controller_label( + identity: config_manager.ControllerIdentity, alias: str = "" +) -> str: + if alias: + return alias + if identity.is_global_fallback: + return "Default profile" + presentation = _controller_presentation(identity) + friendly_models = { + "Nintendo Switch Pro Controller": "Switch Pro", + "Sony DualSense": "DualSense", + "Sony DualShock 4": "DualShock 4", + "Sony controller": "PlayStation Controller", + "Xbox controller": "Xbox", + "8BitDo controller": "8BitDo", + "Connected controller": "Controller", + } + model = friendly_models.get(presentation["model"], presentation["model"]) + address_suffix = ":".join(f"{octet:02X}" for octet in identity.address[-2:]) + return f"{model} · {address_suffix}" + + +class ProfileEditorServer(HTTPServer): + """Single-threaded server that serializes USB profile transactions.""" + + def __init__( + self, + address: tuple[str, int], + *, + bus: int | None, + device_address: int | None, + timeout: float, + ) -> None: + super().__init__(address, ProfileEditorHandler) + self.bus = bus + self.device_address = device_address + self.operation_timeout = timeout + self.mutation_token = secrets.token_urlsafe(32) + self._device: config_manager.UsbDevice | None = None + self.capture: dict[str, Any] | None = None + + def find_device(self) -> config_manager.UsbDevice: + if self._device is None: + self._device = config_manager.find_pico( + self.bus, self.device_address, self.operation_timeout + ) + return self._device + + def invalidate_device(self) -> None: + self._device = None + + +class ProfileEditorHandler(BaseHTTPRequestHandler): + @property + def profile_server(self) -> ProfileEditorServer: + return cast(ProfileEditorServer, self.server) + + def do_GET(self) -> None: + if not self._local_host_header(): + self.send_error(HTTPStatus.FORBIDDEN) + return + + path = urlsplit(self.path).path + if path in _ASSET_TYPES: + self._serve_asset(*_ASSET_TYPES[path]) + return + if path == "/api/schema": + self._send_json( + { + "buttons": list(config_manager.LOGICAL_BUTTONS), + "controls": list(config_manager.LOGICAL_CONTROLS), + "default_switching_chord": [ + "left_shoulder", + "right_shoulder", + "select", + "start", + ], + "default_motion_toggle_chord": [ + "dpad_up", + "right_shoulder", + "start", + ], + "rumble_policies": list(config_manager.RUMBLE_POLICIES), + "turbo_modes": list(config_manager.TURBO_MODES), + "shift_modes": list(config_manager.SHIFT_MODES), + "shortcut_selectors": list( + config_manager.SHORTCUT_SELECTOR_BUTTONS + ), + "turbo_settings_bounds": { + "rate_hz": { + "min": config_manager.PROFILE_TURBO_RATE_MIN, + "max": config_manager.PROFILE_TURBO_RATE_MAX, + }, + "duty_percent": { + "min": config_manager.PROFILE_TURBO_DUTY_MIN, + "max": config_manager.PROFILE_TURBO_DUTY_MAX, + }, + "burst_count": { + "min": config_manager.PROFILE_TURBO_BURST_MIN, + "max": config_manager.PROFILE_TURBO_BURST_MAX, + }, + }, + "macro_playback_modes": list(config_manager.MACRO_PLAYBACK_MODES), + "macro_repeat_bounds": { + "min": config_manager.PROFILE_MACRO_REPEAT_MIN, + "max": config_manager.PROFILE_MACRO_REPEAT_MAX, + }, + "macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES), + "macro_count": config_manager.PROFILE_MACRO_COUNT, + "profile_capacity": config_manager.PROFILE_CAPACITY, + "control_labels": _CONTROL_LABELS, + "maximum_macro_state_steps": ( + config_manager.PROFILE_MACRO_STEPS_PER_MACRO + ), + "maximum_shared_macro_steps": ( + config_manager.PROFILE_MACRO_STEP_CAPACITY + ), + "maximum_macro_stream_bytes": ( + config_manager.PROFILE_MACRO_STREAM_SIZE + ), + "default_profile": ( + config_manager.ControllerProfile.default().to_json_object() + ), + "mutation_token": self.profile_server.mutation_token, + } + ) + return + if path == "/api/profiles": + self._api_call(self._list_profiles) + return + capture = self._parse_capture_path(path) + if capture is not None: + identity_index, profile_index, action = capture + if action.isdecimal() or action == "current": + self._api_call( + lambda: self._read_capture(identity_index, profile_index, action) + ) + return + + selection = self._parse_profile_path(path) + if selection is not None: + identity_index, profile_index, action = selection + if action is None: + self._api_call( + lambda: self._read_profile(identity_index, profile_index) + ) + return + if action == "playtest": + self._api_call( + lambda: self._read_playtest(identity_index, profile_index) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) + + def do_PUT(self) -> None: + if not self._allow_mutation(): + return + path = urlsplit(self.path).path + identity_selection = self._parse_identity_path(path) + if identity_selection is not None: + identity_index, action = identity_selection + if action == "alias": + self._api_call(lambda: self._set_alias(identity_index)) + return + selection = self._parse_profile_path(path) + if selection is None: + self.send_error(HTTPStatus.NOT_FOUND) + return + identity_index, profile_index, action = selection + if action is None: + self._api_call(lambda: self._write_profile(identity_index, profile_index)) + return + if action == "name": + self._api_call( + lambda: self._set_profile_name(identity_index, profile_index) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + if not self._allow_mutation(): + return + path = urlsplit(self.path).path + if path == "/api/profiles/validate": + self._api_call( + lambda: { + "profile": config_manager.ControllerProfile.from_json( + self._read_json_body() + ).to_json_object() + } + ) + return + capture = self._parse_capture_path(path) + if capture is not None: + identity_index, profile_index, action = capture + if action == "start": + self._api_call( + lambda: self._start_capture(identity_index, profile_index) + ) + return + if action == "stop": + self._api_call( + lambda: self._stop_capture(identity_index, profile_index) + ) + return + identity_selection = self._parse_identity_path(path) + if identity_selection is not None: + identity_index, action = identity_selection + if action == "identify": + self._api_call(lambda: self._identify(identity_index)) + return + selection = self._parse_profile_path(path) + if selection is not None: + identity_index, profile_index, action = selection + if action == "activate": + self._api_call( + lambda: self._activate_profile(identity_index, profile_index) + ) + return + if action == "copy": + self._api_call( + lambda: self._copy_profile(identity_index, profile_index) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) + + def _local_host_header(self) -> bool: + host = self.headers.get("Host", "") + return host in { + f"127.0.0.1:{self.profile_server.server_port}", + f"localhost:{self.profile_server.server_port}", + } + + def _allow_mutation(self) -> bool: + if not self._local_host_header(): + self.send_error(HTTPStatus.FORBIDDEN) + return False + if ( + self.headers.get("X-Switch-Pico-Token") + != self.profile_server.mutation_token + ): + self._send_json( + {"error": "missing or invalid mutation token"}, + status=HTTPStatus.FORBIDDEN, + ) + return False + return True + + @staticmethod + def _parse_profile_path(path: str) -> tuple[int, int, str | None] | None: + parts = path.strip("/").split("/") + if len(parts) not in (4, 5) or parts[:2] != ["api", "profiles"]: + return None + try: + identity_index = int(parts[2]) + profile_number = int(parts[3]) + except ValueError: + return None + if ( + identity_index < 0 + or not 1 <= profile_number <= config_manager.PROFILE_CAPACITY + ): + return None + action = parts[4] if len(parts) == 5 else None + return identity_index, profile_number - 1, action + + @classmethod + def _parse_capture_path(cls, path: str) -> tuple[int, int, str] | None: + parts = path.strip("/").split("/") + if len(parts) != 6 or parts[4] != "capture": + return None + selection = cls._parse_profile_path("/".join(parts[:4])) + if selection is None: + return None + return selection[0], selection[1], parts[5] + + @staticmethod + def _parse_identity_path(path: str) -> tuple[int, str] | None: + parts = path.strip("/").split("/") + if len(parts) != 4 or parts[:2] != ["api", "identities"]: + return None + try: + identity_index = int(parts[2]) + except ValueError: + return None + return (identity_index, parts[3]) if identity_index >= 0 else None + + def _entries_and_identity( + self, device: config_manager.UsbDevice, identity_index: int + ) -> tuple[ + tuple[config_manager.ProfileListEntry, ...], + config_manager.ControllerIdentity, + ]: + entries = config_manager.list_profiles(device) + if not 0 <= identity_index < len(entries): + raise config_manager.ConfigManagerError( + f"identity index {identity_index} is out of range; " + f"refresh the profile library" + ) + return entries, entries[identity_index].identity + + def _list_profiles(self) -> dict[str, Any]: + device = self.profile_server.find_device() + entries = config_manager.list_profiles(device) + return { + "identities": [ + { + "index": index, + "label": _controller_label(entry.identity, entry.alias), + "key": entry.identity.to_bytes().hex(), + "active_profile": entry.active_profile_index + 1, + "controller": _controller_presentation(entry.identity), + "alias": entry.alias, + "modifier_controls": list( + config_manager.LOGICAL_CONTROLS + if _controller_presentation(entry.identity)["style"] + in {"xbox", "playstation"} + else config_manager.LOGICAL_BUTTONS + ), + } + for index, entry in enumerate(entries) + ] + } + + def _read_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: + device = self.profile_server.find_device() + entries, identity = self._entries_and_identity(device, identity_index) + profile = config_manager.read_profile(device, identity, profile_index) + metadata = config_manager.read_selected_profile_metadata(device) + return { + "profile": profile.to_json_object(), + "name": metadata.profile_names[profile_index], + "profile_names": list(metadata.profile_names), + "alias": metadata.alias, + "active": entries[identity_index].active_profile_index == profile_index, + } + + def _read_playtest(self, identity_index: int, profile_index: int) -> dict[str, Any]: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + config_manager.select_profile(device, identity, profile_index) + playtest = config_manager.read_profile_playtest(device) + result = playtest.to_json_object() + result["owner_key"] = identity.to_bytes().hex() + result["identity_key"] = ( + playtest.identity.to_bytes().hex() + if playtest.identity is not None + else None + ) + result["controller"] = ( + _controller_presentation(playtest.identity) + if playtest.identity is not None + else None + ) + result["label"] = ( + _controller_label(playtest.identity) + if playtest.identity is not None + else None + ) + return result + + @staticmethod + def _capture_integer( + body: dict[str, Any], name: str, minimum: int, maximum: int + ) -> int: + value = body.get(name) + if type(value) is not int or not minimum <= value <= maximum: + raise config_manager.ConfigManagerError( + f"{name} must be an integer from {minimum} to {maximum}" + ) + return value + + def _start_capture(self, identity_index: int, profile_index: int) -> dict[str, Any]: + body = self._read_json_object() + channels = self._capture_integer(body, "channels", 1, 31) + maximum = self._capture_integer(body, "max_events", 1, 8) + axis_quantum = self._capture_integer(body, "axis_quantum", 1, 32767) + trigger_quantum = self._capture_integer(body, "trigger_quantum", 1, 65535) + duration = self._capture_integer(body, "max_duration_ms", 1, 80000) + slot = self._capture_integer( + body, "slot", 0, config_manager.PROFILE_PLAYTEST_SLOT_COUNT - 1 + ) + generation = self._capture_integer(body, "connection_generation", 0, 0xFFFFFFFF) + macro_index = self._capture_integer( + body, "macro_index", 0, config_manager.PROFILE_MACRO_COUNT - 1 + ) + owner_key = body.get("owner_key") + if not isinstance(owner_key, str): + raise config_manager.ConfigManagerError("capture owner key is required") + capture_id = body.get("capture_id") + if not isinstance(capture_id, str) or not 1 <= len(capture_id) <= 128: + raise config_manager.ConfigManagerError("capture request ID is required") + profile = config_manager.ControllerProfile.from_json_object(body.get("profile")) + others = [ + step + for index, macro in enumerate(profile.macros) + if index != macro_index + for step in macro.steps + ] + step_bytes = 3 + sum( + size for bit, size in enumerate((2, 4, 4, 2, 2)) if channels & (1 << bit) + ) + available = min( + config_manager.PROFILE_MACRO_STEPS_PER_MACRO, + config_manager.PROFILE_MACRO_STEP_CAPACITY - len(others), + ( + config_manager.PROFILE_MACRO_STREAM_SIZE + - sum(len(step.to_sparse_bytes()) for step in others) + ) + // step_bytes, + ) + if maximum > available: + raise config_manager.ConfigManagerError( + "recording would exceed the draft's 8/16/136 macro budget" + ) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + if identity.to_bytes().hex() != owner_key: + raise config_manager.ConfigManagerError( + "profile owner changed; refresh before recording" + ) + # The routed playtest endpoint selected this owner. Recheck its live + # identity and generation without changing USB selection on a stale start. + sample = config_manager.read_profile_playtest(device) + if ( + not sample.connected + or sample.identity is None + or (not identity.is_global_fallback and sample.identity != identity) + or sample.slot_index != slot + or sample.connection_generation != generation + ): + raise config_manager.ConfigManagerError( + "controller connection changed; wait for live input before recording" + ) + previous = config_manager.read_macro_capture(device) + if previous.state_name == "recording": + raise config_manager.ConfigManagerError( + "a recording is already running; stop it before starting another" + ) + self.profile_server.capture = { + "identity_index": identity_index, + "profile_index": profile_index, + "owner_key": owner_key, + "macro_index": macro_index, + "run_id": None, + "previous_run_id": previous.run_id, + "slot": slot, + "connection_generation": generation, + "result": None, + "capture_id": capture_id, + "channels": channels, + "max_events": maximum, + "axis_quantum": axis_quantum, + "trigger_quantum": trigger_quantum, + "max_duration_ms": duration, + } + page = config_manager.start_macro_capture( + device, + slot, + generation, + channels=channels, + max_events=maximum, + axis_quantum=axis_quantum, + trigger_quantum=trigger_quantum, + max_duration_ms=duration, + ) + return self._capture_result(device, page) + + def _bound_capture( + self, + identity_index: int, + profile_index: int, + run_id: int | None, + owner_key: str | None, + generation: int, + capture_id: str | None, + ) -> dict[str, Any]: + capture = self.profile_server.capture + if ( + capture is None + or capture["identity_index"] != identity_index + or capture["profile_index"] != profile_index + or capture["owner_key"] != owner_key + or capture["capture_id"] != capture_id + or capture["connection_generation"] != generation + or (run_id is not None and capture["run_id"] != run_id) + ): + raise config_manager.ConfigManagerError( + "stale capture run, connection or profile owner" + ) + return capture + + def _capture_device(self, capture: dict[str, Any]) -> config_manager.UsbDevice: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, capture["identity_index"]) + if identity.to_bytes().hex() != capture["owner_key"]: + raise config_manager.ConfigManagerError( + "capture profile owner moved; refusing a stale library index" + ) + return device + + def _capture_result( + self, + device: config_manager.UsbDevice, + page: config_manager.MacroCapturePage, + ) -> dict[str, Any]: + capture = self.profile_server.capture + assert capture is not None + run_id = capture["run_id"] + if ( + not page.run_id + or page.run_id == capture["previous_run_id"] + or (run_id is not None and page.run_id != run_id) + or any( + getattr(page, field) != capture[field] + for field in ( + "slot", + "connection_generation", + "channels", + "max_events", + "axis_quantum", + "trigger_quantum", + "max_duration_ms", + ) + ) + ): + raise config_manager.ConfigManagerError( + "capture run, connection or options changed" + ) + capture["run_id"] = page.run_id + terminal = page.state_name not in {"idle", "recording"} + if terminal: + page = config_manager.collect_macro_capture(device, page.run_id) + if any( + getattr(page, field) != capture[field] + for field in ( + "run_id", + "slot", + "connection_generation", + "channels", + "max_events", + "axis_quantum", + "trigger_quantum", + "max_duration_ms", + ) + ): + raise config_manager.ConfigManagerError( + "capture changed during collection" + ) + result = page.to_json_object() + result.update( + { + "owner_key": capture["owner_key"], + "capture_id": capture["capture_id"], + "profile_number": capture["profile_index"] + 1, + "macro_index": capture["macro_index"], + } + ) + if terminal: + try: + result["steps"] = [ + step.to_json_object() + for step in config_manager.capture_macro_steps(page) + ] + except config_manager.ConfigManagerError as exc: + # A conversion failure must not hide the retained raw events. + result["conversion_error"] = str(exc) + capture["result"] = result + return result + + def _read_capture( + self, identity_index: int, profile_index: int, action: str + ) -> dict[str, Any]: + query = parse_qs(urlsplit(self.path).query) + try: + generation = int(query.get("connection_generation", [""])[0]) + except ValueError as exc: + raise config_manager.ConfigManagerError( + "capture connection generation is required" + ) from exc + capture = self._bound_capture( + identity_index, + profile_index, + None if action == "current" else int(action), + query.get("owner_key", [None])[0], + generation, + query.get("capture_id", [None])[0], + ) + device = self._capture_device(capture) + if capture["result"] is not None: + return cast(dict[str, Any], capture["result"]) + page = config_manager.read_macro_capture(device, capture["run_id"] or 0) + return self._capture_result(device, page) + + def _stop_capture(self, identity_index: int, profile_index: int) -> dict[str, Any]: + body = self._read_json_object() + run_id = self._capture_integer(body, "run_id", 1, 0xFFFFFFFF) + generation = self._capture_integer(body, "connection_generation", 0, 0xFFFFFFFF) + capture = self._bound_capture( + identity_index, + profile_index, + run_id, + body.get("owner_key"), + generation, + body.get("capture_id"), + ) + device = self._capture_device(capture) + if capture["result"] is not None: + return cast(dict[str, Any], capture["result"]) + page = config_manager.read_macro_capture(device, run_id) + if ( + page.slot != capture["slot"] + or page.connection_generation != generation + or page.run_id != run_id + ): + raise config_manager.ConfigManagerError("capture connection or run changed") + if page.state_name == "recording": + page = config_manager.stop_macro_capture(device, run_id) + return self._capture_result(device, page) + + def _read_json_body(self) -> str: + try: + length = int(self.headers.get("Content-Length", "")) + except ValueError as exc: + raise config_manager.ConfigManagerError( + "invalid request content length" + ) from exc + if not 1 <= length <= _MAXIMUM_REQUEST_BYTES: + raise config_manager.ConfigManagerError( + "profile request must contain 1 to 65536 bytes" + ) + return self.rfile.read(length).decode("utf-8") + + def _read_json_object(self) -> dict[str, Any]: + value = json.loads(self._read_json_body()) + if not isinstance(value, dict): + raise config_manager.ConfigManagerError( + "request body must be a JSON object" + ) + return value + + def _write_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: + profile = config_manager.ControllerProfile.from_json(self._read_json_body()) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.write_profile( + device, + identity, + profile_index, + profile, + self.profile_server.operation_timeout, + ) + return { + "stored_generation": status.stored_generation, + "stored_crc": f"{status.stored_crc:08x}", + } + + def _set_profile_name( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + value = self._read_json_object().get("value") + if not isinstance(value, str): + raise config_manager.ConfigManagerError("profile name must be text") + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.set_profile_metadata( + device, + identity, + profile_index, + value, + self.profile_server.operation_timeout, + ) + return {"stored_generation": status.stored_generation} + + def _set_alias(self, identity_index: int) -> dict[str, Any]: + value = self._read_json_object().get("value") + if not isinstance(value, str): + raise config_manager.ConfigManagerError("controller alias must be text") + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.set_profile_metadata( + device, + identity, + config_manager.PROFILE_NONE_BUTTON, + value, + self.profile_server.operation_timeout, + ) + return { + "stored_generation": status.stored_generation, + "label": _controller_label(identity, value), + } + + def _identify(self, identity_index: int) -> dict[str, Any]: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + config_manager.identify_controller(device, identity) + return {"identified": True} + + def _copy_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: + destination = self._read_json_object() + destination_identity_index = destination.get("identity_index") + destination_profile_number = destination.get("profile_number") + if ( + type(destination_identity_index) is not int + or type(destination_profile_number) is not int + or not 1 <= destination_profile_number <= config_manager.PROFILE_CAPACITY + ): + raise config_manager.ConfigManagerError("copy destination is invalid") + profile = config_manager.ControllerProfile.from_json_object( + destination.get("profile") + ) + name = destination.get("name") + if ( + type(name) is not str + or "\x00" in name + or len(name.encode("utf-8")) > config_manager.PROFILE_METADATA_MAX_BYTES + ): + raise config_manager.ConfigManagerError( + "profile name must contain at most 31 UTF-8 bytes" + ) + device = self.profile_server.find_device() + self._entries_and_identity(device, identity_index) + _, destination_identity = self._entries_and_identity( + device, destination_identity_index + ) + target_profile = destination_profile_number - 1 + profile_status = config_manager.write_profile( + device, + destination_identity, + target_profile, + profile, + self.profile_server.operation_timeout, + ) + name_status = config_manager.set_profile_metadata( + device, + destination_identity, + target_profile, + name, + self.profile_server.operation_timeout, + ) + return { + "stored_generation": name_status.stored_generation, + "profile_generation": profile_status.stored_generation, + } + + def _activate_profile( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.activate_profile( + device, + identity, + profile_index, + self.profile_server.operation_timeout, + ) + return {"stored_generation": status.stored_generation} + + def _api_call(self, operation: Callable[[], dict[str, Any]]) -> None: + try: + result = operation() + except config_manager.ConfigManagerError as exc: + self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + except usb.core.USBError as exc: + self.profile_server.invalidate_device() + self._send_json( + {"error": f"USB access failed: {exc}"}, + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + self._send_json( + {"error": f"invalid JSON request: {exc}"}, + status=HTTPStatus.BAD_REQUEST, + ) + else: + self._send_json(result) + + def _serve_asset(self, filename: str, content_type: str) -> None: + try: + payload = ( + resources.files("switch_pico_bridge") + .joinpath("web", filename) + .read_bytes() + ) + except (FileNotFoundError, OSError): + self.send_error(HTTPStatus.NOT_FOUND) + return + self._send_bytes(payload, content_type) + + def _send_json(self, value: Any, *, status: HTTPStatus = HTTPStatus.OK) -> None: + self._send_bytes( + json.dumps(value, separators=(",", ":")).encode("utf-8"), + "application/json; charset=utf-8", + status=status, + ) + + def _send_bytes( + self, + payload: bytes, + content_type: str, + *, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; " + "connect-src 'self'; img-src 'self'; frame-ancestors 'none'", + ) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: Any) -> None: + return + + +def run_profile_editor( + *, + bus: int | None, + address: int | None, + timeout: float, + port: int, + open_browser: bool, +) -> None: + try: + server = ProfileEditorServer( + ("127.0.0.1", port), + bus=bus, + device_address=address, + timeout=timeout, + ) + except OSError as exc: + raise config_manager.ConfigManagerError( + f"could not start profile editor on port {port}: {exc}" + ) from exc + + url = f"http://127.0.0.1:{server.server_port}/" + print(f"Profile editor: {url}") + print("Press Ctrl+C to stop.") + if open_browser and not webbrowser.open(url): + print(f"Could not open a browser automatically; open {url} manually.") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt b/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt new file mode 100644 index 0000000..64f58ef --- /dev/null +++ b/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt @@ -0,0 +1,35 @@ +Controller artwork source +========================= + +The following files are redistributed from AL2009man/Gamepad-Asset-Pack +revision a6d1113b0b3b2438bbbcb08a33f9a25eb2391d7c: + +- controller-dualsense.svg +- controller-switch-pro.svg +- controller-xbox.svg + +Source: https://github.com/AL2009man/Gamepad-Asset-Pack +Copyright (c) 2024 Al. Lopez +License: MIT + +MIT License + +Copyright (c) 2024 Al. Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/switch_pico_bridge/web/assets/controller-dualsense.svg b/src/switch_pico_bridge/web/assets/controller-dualsense.svg new file mode 100644 index 0000000..92a893e --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-dualsense.svg @@ -0,0 +1,2013 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/controller-switch-pro.svg b/src/switch_pico_bridge/web/assets/controller-switch-pro.svg new file mode 100644 index 0000000..5b68d3e --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-switch-pro.svg @@ -0,0 +1,463 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/controller-xbox.svg b/src/switch_pico_bridge/web/assets/controller-xbox.svg new file mode 100644 index 0000000..a8c9e97 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-xbox.svg @@ -0,0 +1,1383 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css new file mode 100644 index 0000000..b1c9705 --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -0,0 +1,695 @@ +:root { + --canvas: #070913; + --canvas-soft: #0a1020; + --surface: rgba(14, 20, 37, 0.94); + --surface-solid: #0e1425; + --surface-2: #141d32; + --surface-3: #1b2742; + --input: #090f1d; + --ink: #f6f7ff; + --muted: #abb5cc; + --subtle: #7e8aa5; + --line: #303d5b; + --control-line: #60708f; + --line-soft: rgba(129, 147, 190, 0.18); + --blue: #6aa7ff; + --blue-strong: #3d7eea; + --sky: #59c7ff; + --mint: #4adea5; + --amber: #f6c76d; + --rose: #ff7c9f; + --cyan: var(--sky); + --cyan-strong: #34b5f5; + --coral: var(--rose); + --lime: var(--mint); + --warning: var(--amber); + --shadow: 0 28px 80px rgba(1, 3, 12, 0.48); + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +[hidden] { display: none !important; } +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: var(--ink); + background: var(--canvas); +} + +button, input, select { font: inherit; } +button, select { cursor: pointer; } +button, .profile-button, .macro-tab, .checkbox-pill span, .select, .number-input { + transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease, color 150ms ease, transform 150ms ease; +} +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { + outline: 3px solid rgba(89, 199, 255, 0.95); + outline-offset: 3px; +} + +.ambient { + position: fixed; + z-index: -1; + width: 30rem; + height: 30rem; + border-radius: 50%; + filter: blur(120px); + opacity: 0.12; + pointer-events: none; +} +.ambient-one { top: 10rem; left: -16rem; background: var(--blue); } +.ambient-two { right: -14rem; bottom: 3rem; background: var(--sky); } + +.topbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 84px; + padding: 14px clamp(18px, 4vw, 64px); + border-bottom: 1px solid var(--line-soft); + background: rgba(7, 9, 19, 0.82); + box-shadow: 0 10px 36px rgba(1, 3, 12, 0.24); + backdrop-filter: blur(24px) saturate(140%); +} +.brand { display: flex; align-items: center; gap: 15px; } +.brand-mark { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + width: 44px; + height: 44px; + padding: 8px; + border: 1px solid rgba(106, 167, 255, 0.55); + border-radius: 15px; + background: rgba(106, 167, 255, 0.12); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.08), 0 10px 24px rgba(33, 92, 174, 0.26); + transform: rotate(-3deg); +} +.brand-mark span { border-radius: 8px 5px 5px 8px; background: var(--blue); } +.brand-mark span:last-child { border-radius: 5px 8px 8px 5px; background: var(--sky); } +.topbar-meta { display: flex; align-items: center; gap: 20px; } +.topbar-meta > p { color: var(--subtle); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } +h1, h2, h3, h4, p { margin: 0; } +h1 { font-size: 1.18rem; letter-spacing: -0.025em; } +h2 { font-size: clamp(1.35rem, 2vw, 1.65rem); letter-spacing: -0.04em; } +h3 { font-size: clamp(1.2rem, 1.7vw, 1.42rem); letter-spacing: -0.035em; } +h4 { font-size: 1rem; } +.eyebrow { + margin-bottom: 5px; + color: var(--blue); + font-size: 0.66rem; + font-weight: 850; + letter-spacing: 0.17em; + text-transform: uppercase; +} +.connection { + display: flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 8px 13px; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(16, 23, 41, 0.76); + font-size: 0.78rem; + font-weight: 650; +} +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 0 4px rgba(246, 199, 109, 0.1), 0 0 14px currentColor; +} +.connection[data-state="ready"] .connection-dot { background: var(--mint); box-shadow: 0 0 0 4px rgba(74, 222, 165, 0.1), 0 0 14px var(--mint); } +.connection[data-state="error"] .connection-dot { background: var(--rose); box-shadow: 0 0 0 4px rgba(255, 124, 159, 0.1), 0 0 14px var(--rose); } + +.workspace { + display: grid; + grid-template-columns: 292px minmax(0, 1fr); + gap: 26px; + width: min(1580px, calc(100% - 40px)); + margin: 28px auto 88px; +} +.sidebar { + position: sticky; + top: 112px; + align-self: start; + max-height: calc(100dvh - 132px); + overflow-y: auto; + scrollbar-gutter: stable; + padding: 22px; + border: 1px solid var(--line); + border-radius: 22px; + background: var(--surface); + box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.04); +} +.sidebar-heading, .editor-header, .panel-heading, .subpanel-heading, .title-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.icon-button { + display: grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + color: var(--sky); + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-2); + font-size: 1.2rem; +} +.icon-button:hover { border-color: var(--sky); background: rgba(89, 199, 255, 0.1); transform: rotate(20deg); } +.field-label { display: block; margin: 24px 0 8px; color: var(--muted); font-size: 0.69rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.11em; } +.field-help { margin-top: 8px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; } +.select, .number-input { + width: 100%; + min-height: 44px; + padding: 9px 12px; + color: var(--ink); + border: 1px solid var(--control-line); + border-radius: 11px; + background: var(--input); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.28); +} +.select:hover, .number-input:hover { border-color: #7d90b6; } +.select:focus, .number-input:focus { border-color: var(--sky); box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.1); } +.select:disabled, .number-input:disabled { cursor: not-allowed; opacity: 0.45; border-color: var(--line-soft); box-shadow: none; } +.select option:disabled { color: var(--subtle); } +.profile-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 22px; } +.profile-button { + --slot-color: var(--blue); + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 66px; + padding: 12px; + overflow: hidden; + color: var(--muted); + border: 1px solid var(--line-soft); + border-radius: 14px; + background: rgba(20, 29, 50, 0.72); + text-align: left; +} +.profile-button::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--slot-color); content: ""; opacity: 0.5; } +.profile-button:nth-child(1) { --slot-color: #59c7ff; } +.profile-button:nth-child(2) { --slot-color: #4adea5; } +.profile-button:nth-child(3) { --slot-color: #f6c76d; } +.profile-button:nth-child(4) { --slot-color: #a58bfa; } +.profile-button:nth-child(5) { --slot-color: #ff7c7c; } +.profile-button:nth-child(6) { --slot-color: #45dfe2; } +.profile-button:nth-child(7) { --slot-color: #ff7cbd; } +.profile-button:nth-child(8) { --slot-color: #c7ed64; } +.profile-button:hover { color: var(--ink); border-color: var(--slot-color); background: var(--surface-2); transform: translateY(-1px); } +.profile-button.selected { color: var(--ink); border-color: var(--slot-color); background: color-mix(in srgb, var(--slot-color) 12%, var(--surface-2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--slot-color) 12%, transparent); } +.profile-button.selected::before { width: 4px; opacity: 1; } +.profile-number { display: flex; align-items: center; gap: 8px; font-size: 0.74rem; font-weight: 780; } +.profile-number span { display: grid; place-items: center; width: 25px; height: 25px; color: #07101b; border-radius: 8px; background: var(--slot-color); font-size: 0.73rem; font-weight: 900; } +.mini-active { margin: 6px 0 0 33px; color: var(--mint); font-size: 0.59rem; font-weight: 850; letter-spacing: 0.1em; text-transform: uppercase; } +.sidebar-note { display: flex; gap: 11px; margin-top: 22px; padding: 14px 2px 0; color: var(--muted); border-top: 1px solid var(--line-soft); font-size: 0.73rem; line-height: 1.55; } +.note-icon { display: grid; flex: 0 0 auto; place-items: center; width: 22px; height: 22px; color: var(--blue); border: 1px solid rgba(106, 167, 255, 0.55); border-radius: 8px; background: rgba(106, 167, 255, 0.08); font-size: 0.7rem; font-weight: 900; } + +.editor-shell { min-width: 0; } +.editor-header { + min-height: 96px; + margin-bottom: 12px; + padding: 20px 22px; + border: 1px solid var(--line); + border-radius: 20px; + background: var(--surface); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.04); +} +.header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; } +.button { + min-height: 42px; + padding: 9px 16px; + color: var(--ink); + border: 1px solid var(--control-line); + border-radius: 11px; + background: var(--surface-3); + font-size: 0.8rem; + font-weight: 780; +} +.button:hover { border-color: #596b91; transform: translateY(-1px); } +.button:active { transform: translateY(0); } +.button:disabled { cursor: not-allowed; opacity: 0.42; transform: none; } +.button-primary { color: #080a16; border-color: transparent; background: var(--blue); box-shadow: 0 8px 24px rgba(33, 92, 174, 0.24); } +.button-secondary { border-color: rgba(106, 167, 255, 0.48); background: rgba(106, 167, 255, 0.1); } +.button-ghost { color: var(--muted); background: transparent; } +.active-badge, .dirty-badge { padding: 5px 9px; border: 1px solid currentColor; border-radius: 999px; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.09em; text-transform: uppercase; } +.active-badge { color: var(--mint); background: rgba(74, 222, 165, 0.08); } +.dirty-badge { color: var(--amber); background: rgba(246, 199, 109, 0.08); } +.section-nav { + position: sticky; + top: 96px; + z-index: 18; + display: flex; + gap: 5px; + overflow-x: auto; + margin-bottom: 14px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(10, 15, 29, 0.9); + box-shadow: 0 12px 30px rgba(1, 3, 12, 0.24); + backdrop-filter: blur(18px); +} +.section-nav a { flex: 1 0 auto; padding: 8px 13px; color: var(--muted); border-radius: 9px; font-size: 0.73rem; font-weight: 780; text-align: center; text-decoration: none; } +.section-nav a:hover, .section-nav a:focus-visible { color: var(--ink); background: var(--surface-3); } + +.panel { + --section-accent: var(--blue); + position: relative; + scroll-margin-top: 152px; + margin-bottom: 20px; + padding: clamp(22px, 3vw, 32px); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 18px 52px rgba(1, 3, 12, 0.24), inset 0 1px rgba(255, 255, 255, 0.035); +} +.panel::before { position: absolute; inset: 0 auto auto 0; width: 64px; height: 3px; background: var(--section-accent); content: ""; } +#analog { --section-accent: var(--sky); } +#feedback { --section-accent: var(--amber); } +#turbo { --section-accent: var(--mint); } +#macro { --section-accent: var(--rose); } +#shortcuts { --section-accent: var(--sky); } +#shift { --section-accent: var(--amber); } +.panel .eyebrow { color: var(--section-accent); } +.panel-heading { align-items: flex-end; margin-bottom: 26px; } +.panel-heading > p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; } +.controller-mapper { display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 18px; align-items: stretch; } +.controller-canvas { + padding: 18px; + overflow: hidden; + border: 1px solid rgba(89, 199, 255, 0.24); + border-radius: 18px; + background: #090f1d; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.04); +} +.controller-caption { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 27px; } +.controller-caption strong { color: #dfe8ff; font-size: 0.8rem; } +.controller-live { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.65rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; } +.controller-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 4px rgba(74, 222, 165, 0.09), 0 0 12px var(--mint); } +.controller-photo-wrap { position: relative; width: 100%; margin: 10px auto 4px; aspect-ratio: 1.62; transition: aspect-ratio 180ms ease; } +.controller-canvas[data-style="switch"] .controller-photo-wrap { aspect-ratio: 1.38; } +.controller-canvas[data-style="playstation"] .controller-photo-wrap { aspect-ratio: 1.8; } +.controller-photo { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; filter: drop-shadow(0 22px 28px rgba(0, 0, 0, 0.46)); } +.controller-hotspots { position: absolute; inset: 0; } +.controller-hotspots button { + --x: 50%; + --y: 50%; + position: absolute; + left: var(--x); + top: var(--y); + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + color: #eff6ff; + border: 2px solid var(--sky); + border-radius: 50%; + background: rgba(7, 12, 25, 0.9); + box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.12), 0 5px 16px rgba(0, 0, 0, 0.42); + transform: translate(-50%, -50%); + font-size: 0.7rem; + font-weight: 900; + line-height: 1; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; +} +.controller-hotspots button:hover, .controller-hotspots button:focus-visible { z-index: 2; border-color: var(--blue); transform: translate(-50%, -50%) scale(1.13); } +.controller-hotspots button.selected { z-index: 3; color: #090a16; border-color: var(--rose); background: var(--rose); box-shadow: 0 0 0 5px rgba(255, 124, 159, 0.18), 0 8px 20px rgba(0, 0, 0, 0.48); transform: translate(-50%, -50%) scale(1.16); } +.controller-hotspots button[data-controller-button$="_trigger"] { width: 60px; height: 30px; border-radius: 11px; } +.controller-hotspots button.disabled-map { color: var(--subtle); border-color: #56617a; background: rgba(8, 11, 20, 0.9); box-shadow: none; } +.controller-hotspots button[data-controller-button$="_shoulder"] { width: 60px; height: 30px; border-radius: 11px; } +.controller-hotspots button[data-controller-button^="dpad_"] { width: 27px; height: 27px; font-size: 0.58rem; } +.controller-hotspots button[data-controller-button="select"], +.controller-hotspots button[data-controller-button="start"], +.controller-hotspots button[data-controller-button="capture"], +.controller-hotspots button[data-controller-button="system"] { width: 29px; height: 29px; font-size: 0.63rem; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_shoulder"] { --x: 22%; --y: 12%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_trigger"] { --x: 22%; --y: 4%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_trigger"] { --x: 78%; --y: 4%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_shoulder"] { --x: 78%; --y: 12%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_stick"] { --x: 23%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_stick"] { --x: 63%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_up"] { --x: 36%; --y: 62%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_right"] { --x: 40%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_down"] { --x: 36%; --y: 74%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_left"] { --x: 32%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="north"] { --x: 77%; --y: 33%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="east"] { --x: 84%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="south"] { --x: 77%; --y: 54%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="west"] { --x: 70%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="select"] { --x: 42%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="start"] { --x: 57%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="capture"] { --x: 50%; --y: 52%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="system"] { --x: 50%; --y: 26%; } + +.controller-canvas[data-style="switch"] [data-controller-button="left_shoulder"] { --x: 27%; --y: 8%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_shoulder"] { --x: 73%; --y: 8%; } +.controller-canvas[data-style="switch"] [data-controller-button="left_trigger"] { --x: 27%; --y: 2%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_trigger"] { --x: 73%; --y: 2%; } +.controller-canvas[data-style="switch"] [data-controller-button="left_stick"] { --x: 24%; --y: 30%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_stick"] { --x: 63%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_up"] { --x: 35%; --y: 46%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_right"] { --x: 39%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_down"] { --x: 35%; --y: 56%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_left"] { --x: 31%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="north"] { --x: 77%; --y: 22%; } +.controller-canvas[data-style="switch"] [data-controller-button="east"] { --x: 85%; --y: 32%; } +.controller-canvas[data-style="switch"] [data-controller-button="south"] { --x: 77%; --y: 43%; } +.controller-canvas[data-style="switch"] [data-controller-button="west"] { --x: 69%; --y: 32%; } +.controller-canvas[data-style="switch"] [data-controller-button="select"] { --x: 39%; --y: 21%; } +.controller-canvas[data-style="switch"] [data-controller-button="start"] { --x: 61%; --y: 21%; } +.controller-canvas[data-style="switch"] [data-controller-button="capture"] { --x: 44%; --y: 31%; } +.controller-canvas[data-style="switch"] [data-controller-button="system"] { --x: 57%; --y: 32%; } + +.controller-canvas[data-style="playstation"] [data-controller-button="left_shoulder"] { --x: 21%; --y: 12%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_shoulder"] { --x: 79%; --y: 12%; } +.controller-canvas[data-style="playstation"] [data-controller-button="left_stick"] { --x: 34%; --y: 69%; } +.controller-canvas[data-style="playstation"] [data-controller-button="left_trigger"] { --x: 21%; --y: 3%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_trigger"] { --x: 79%; --y: 3%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_stick"] { --x: 66%; --y: 69%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_up"] { --x: 18%; --y: 42%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_right"] { --x: 24%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_down"] { --x: 18%; --y: 58%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_left"] { --x: 12%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="north"] { --x: 83%; --y: 39%; } +.controller-canvas[data-style="playstation"] [data-controller-button="east"] { --x: 91%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="south"] { --x: 83%; --y: 61%; } +.controller-canvas[data-style="playstation"] [data-controller-button="west"] { --x: 75%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="select"] { --x: 25%; --y: 30%; } +.controller-canvas[data-style="playstation"] [data-controller-button="start"] { --x: 75%; --y: 30%; } +.controller-canvas[data-style="playstation"] [data-controller-button="capture"] { --x: 50%; --y: 36%; } +.controller-canvas[data-style="playstation"] [data-controller-button="system"] { --x: 50%; --y: 72%; } + +.controller-hint { margin-top: 4px; color: var(--muted); font-size: 0.72rem; text-align: center; } +.controller-credit { margin-top: 8px; color: var(--subtle); font-size: 0.64rem; text-align: center; } +.controller-credit a { color: var(--muted); text-decoration-color: rgba(171, 181, 204, 0.45); text-underline-offset: 3px; } +.mapping-inspector { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 21px; + border: 1px solid rgba(106, 167, 255, 0.32); + border-radius: 18px; + background: var(--surface-2); +} +.selected-control-mark { display: grid; place-items: center; width: 58px; height: 58px; margin: 15px 0; color: #090a16; border-radius: 18px; background: var(--rose); box-shadow: 0 10px 28px rgba(255, 124, 159, 0.22); font-size: 1.18rem; font-weight: 950; } +.mapping-inspector h4 { font-size: 1.16rem; } +.mapping-inspector > p:not(.eyebrow) { min-height: 50px; margin-top: 8px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; } +.mapping-inspector .field-label { margin-top: 20px; } +.mapping-key { display: flex; flex-wrap: wrap; gap: 13px; margin-top: auto; padding-top: 24px; color: var(--muted); font-size: 0.67rem; } +.mapping-key span { display: inline-flex; align-items: center; gap: 7px; } +.key-dot { width: 9px; height: 9px; border: 2px solid var(--sky); border-radius: 50%; } +.key-dot.disabled { border-color: #69758e; } +.mapping-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; } +.mapping-grid.compact { grid-template-columns: repeat(4, minmax(140px, 1fr)); } +.control-card { padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); } +.control-card:hover { border-color: #415273; } +.control-card label, .number-field label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 0.7rem; font-weight: 760; letter-spacing: 0.035em; } +.control-card .select { min-height: 40px; } +.modifier-controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 14px; margin-bottom: 18px; } +.turbo-defaults { margin-bottom: 14px; } +.turbo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 14px; } +.turbo-setting-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.turbo-setting-grid .number-input { min-width: 0; padding-inline: 9px; } +.turbo-setting-grid .number-field label { min-height: 2.6em; line-height: 1.3; } +.turbo-override { display: block; margin-block: 14px; } +.turbo-card.narrow-pulse { border-color: var(--amber); } +.timing-notice { margin-bottom: 18px; padding: 13px 15px; color: var(--muted); border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); font-size: 0.76rem; line-height: 1.6; } +.timing-notice.warning { color: var(--amber); border-color: var(--amber); } +.macro-playback-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 20px; } +.playtest-panel { margin-bottom: 18px; padding: 19px; border: 1px solid rgba(89, 199, 255, 0.28); border-radius: 17px; background: #0b1324; } +.playtest-panel[data-state="waiting"] .playtest-grid { opacity: 0.48; } +.playtest-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.playtest-heading h4 { margin-top: 3px; } +.playtest-status { padding: 5px 9px; color: var(--amber); border: 1px solid currentColor; border-radius: 999px; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; } +.playtest-panel[data-state="live"] .playtest-status { color: var(--mint); } +.playtest-panel[data-state="error"] .playtest-status { color: var(--rose); } +.playtest-help { margin: 7px 0 16px; color: var(--muted); font-size: 0.73rem; } +.playtest-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(300px, 1.4fr); gap: 12px; transition: opacity 150ms ease; } +.playtest-card { min-width: 0; padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); } +.playtest-card-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 11px; } +.playtest-card-heading strong { font-size: 0.78rem; } +.playtest-card-heading span { color: var(--muted); font-size: 0.63rem; font-variant-numeric: tabular-nums; } +.stick-scope { --raw-x: 50%; --raw-y: 50%; --output-x: 50%; --output-y: 50%; --inner-size: 0%; --outer-size: 100%; position: relative; width: min(100%, 174px); margin: auto; aspect-ratio: 1; overflow: hidden; border: 1px solid var(--control-line); border-radius: 50%; background: #080e1c; } +.stick-axis { position: absolute; background: rgba(171, 181, 204, 0.16); } +.stick-axis-x { top: 50%; right: 10%; left: 10%; height: 1px; } +.stick-axis-y { top: 10%; bottom: 10%; left: 50%; width: 1px; } +.stick-ring { position: absolute; top: 50%; left: 50%; border: 1px solid; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; } +.stick-ring-outer { width: var(--outer-size); height: var(--outer-size); border-color: rgba(89, 199, 255, 0.62); } +.stick-ring-inner { width: var(--inner-size); height: var(--inner-size); border-color: rgba(255, 124, 159, 0.8); background: rgba(255, 124, 159, 0.05); } +.stick-dot { position: absolute; top: 50%; left: 50%; border-radius: 50%; transform: translate(-50%, -50%); transition: left 45ms linear, top 45ms linear; } +.stick-dot-raw { top: var(--raw-y); left: var(--raw-x); z-index: 2; width: 15px; height: 15px; border: 3px solid var(--amber); background: transparent; box-shadow: 0 0 0 2px rgba(246, 199, 109, 0.12); } +.stick-dot-output { top: var(--output-y); left: var(--output-x); z-index: 3; width: 8px; height: 8px; border: 2px solid #080e1c; background: var(--blue); box-shadow: 0 0 0 2px rgba(106, 167, 255, 0.3); } +.trigger-playtest { display: flex; flex-direction: column; } +.trigger-meter { display: grid; grid-template-columns: 28px minmax(120px, 1fr) 88px; align-items: center; gap: 9px; margin-top: 12px; } +.trigger-meter > span { color: var(--ink); font-size: 0.7rem; font-weight: 850; } +.trigger-meter output { color: var(--muted); font-size: 0.65rem; font-variant-numeric: tabular-nums; text-align: right; } +.trigger-track { --raw: 0%; --output: 0%; --threshold: 35%; position: relative; height: 28px; overflow: hidden; border: 1px solid var(--control-line); border-radius: 8px; background: #080e1c; } +.trigger-bar { position: absolute; left: 0; height: 7px; border-radius: 0 4px 4px 0; transition: width 45ms linear; } +.trigger-bar-raw { top: 4px; width: var(--raw); background: var(--amber); } +.trigger-bar-output { bottom: 4px; width: var(--output); background: var(--blue); } +.trigger-threshold { position: absolute; top: 0; bottom: 0; left: var(--threshold); width: 2px; background: var(--rose); transform: translateX(-1px); } +.playtest-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 16px; color: var(--muted); font-size: 0.61rem; } +.playtest-legend span { display: inline-flex; align-items: center; gap: 5px; } +.playtest-legend i { width: 8px; height: 8px; border-radius: 50%; } +.legend-raw { background: var(--amber); } +.legend-output { background: var(--blue); } +.legend-threshold { background: var(--rose); } +.controller-hotspots button.pressed { z-index: 4; color: #071018; border-color: var(--mint); background: var(--mint); box-shadow: 0 0 0 5px rgba(74, 222, 165, 0.2), 0 8px 20px rgba(0, 0, 0, 0.42); } +.analog-grid, .feedback-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.subpanel { padding: 19px; border: 1px solid var(--line-soft); border-radius: 16px; background: var(--surface-2); } +.subpanel-heading { align-items: flex-start; margin-bottom: 16px; } +.subpanel-heading span { max-width: 240px; color: var(--muted); font-size: 0.72rem; line-height: 1.45; text-align: right; } +.number-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.number-field.wide { grid-column: 1 / -1; } +.toggle-row { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; } +.checkbox-pill { position: relative; } +.checkbox-pill input { position: absolute; opacity: 0; pointer-events: none; } +.checkbox-pill span { display: block; padding: 8px 11px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 999px; background: rgba(9, 15, 29, 0.45); font-size: 0.7rem; font-weight: 750; cursor: pointer; } +.checkbox-pill span:hover { color: var(--ink); border-color: #7d90b6; background: var(--surface-3); } +.checkbox-pill input:focus-visible + span { outline: 3px solid rgba(89, 199, 255, 0.95); outline-offset: 3px; } +.checkbox-pill input:checked + span { color: #080b15; border-color: var(--blue); background: var(--blue); box-shadow: 0 6px 18px rgba(33, 92, 174, 0.2); } +.checkbox-pill input:disabled + span { opacity: 0.4; cursor: not-allowed; } +.controller-choices { align-items: stretch; gap: 8px; } +.controller-choice span { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 60px; + min-height: 58px; + padding: 8px 9px; + border-radius: 14px; + text-align: center; +} +.controller-choice span[data-button$="_shoulder"], +.controller-choice span[data-button$="_trigger"], +.controller-choice span[data-button$="_stick"] { min-width: 78px; } +.controller-choice b { color: var(--ink); font-size: 1.05rem; font-weight: 930; line-height: 1; } +.controller-choice small { max-width: 78px; margin-top: 6px; color: var(--muted); font-size: 0.59rem; font-weight: 720; line-height: 1.1; } +.controller-choice input:checked + span b, +.controller-choice input:checked + span small { color: #080b15; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="north"] b { color: #6fdda9; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="east"] b { color: #ff8995; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="south"] b { color: #7fbcff; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="west"] b { color: #dda1ff; } +.check-grid { display: flex; flex-wrap: wrap; gap: 8px; } +.range-row { margin-bottom: 18px; } +.range-meta { display: flex; justify-content: space-between; margin-bottom: 9px; color: var(--muted); font-size: 0.74rem; } +.range-meta output { color: var(--ink); font-weight: 760; font-variant-numeric: tabular-nums; } +input[type="range"] { width: 100%; accent-color: var(--blue); } +.builtin-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } +.action-card { padding: 19px; border: 1px solid var(--line-soft); border-radius: 17px; background: var(--surface-2); } +.action-card-heading { min-height: 76px; margin-bottom: 14px; } +.action-card-heading h4 { margin-top: 5px; font-size: 1.04rem; } +.action-card-heading > span { display: block; margin-top: 8px; color: var(--muted); font-size: 0.73rem; line-height: 1.45; } +.action-kind { color: var(--section-accent); font-size: 0.62rem; font-weight: 850; letter-spacing: 0.11em; text-transform: uppercase; } + +.macro-controls { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr); gap: 14px; margin-bottom: 20px; } +.macro-picker { display: flex; grid-column: 1 / -1; align-items: stretch; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 16px; background: #090f1d; } +.macro-tabs { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)); gap: 8px; flex: 1; } +.macro-tab { padding: 10px 12px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 11px; background: var(--surface-2); font-weight: 820; } +.macro-tab small { display: block; margin-top: 4px; font-size: 0.61rem; font-weight: 650; } +.macro-tab.selected { color: #080a15; border-color: transparent; background: var(--rose); box-shadow: 0 7px 20px rgba(255, 124, 159, 0.2); } +.macro-budget { display: flex; flex: 0 0 180px; flex-direction: column; justify-content: center; color: var(--muted); font-size: 0.7rem; } +.macro-budget strong { color: var(--ink); } +.macro-budget progress { width: 100%; height: 6px; margin-top: 9px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--rose); } +.macro-budget progress::-webkit-progress-bar { border-radius: inherit; background: var(--surface-3); } +.macro-budget progress::-webkit-progress-value { border-radius: inherit; background: var(--rose); } +.macro-steps-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 6px 0 13px; padding: 0 4px; } +.macro-steps-heading h4 { margin-top: 4px; font-size: 1.05rem; } +.macro-step-action { display: flex; } +.macro-steps { display: grid; gap: 13px; } +.macro-step { padding: 18px; border: 1px solid var(--line); border-left: 3px solid var(--rose); border-radius: 15px; background: var(--surface-2); } +.macro-step.end { min-height: 68px; border: 1px dashed #43506c; border-left: 3px solid var(--subtle); background: rgba(20, 29, 50, 0.46); opacity: 1; } +.macro-step.end .field-help { color: var(--muted); } +.step-header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 15px; } +.step-number { color: var(--rose); font-size: 0.7rem; font-weight: 850; letter-spacing: 0.12em; text-transform: uppercase; } +.remove-step { min-height: 32px; padding: 6px 10px; color: #ff9ab2; border: 1px solid rgba(255, 124, 159, 0.25); border-radius: 8px; background: rgba(255, 124, 159, 0.08); font-size: 0.7rem; font-weight: 720; } +.remove-step:hover { border-color: var(--rose); background: rgba(255, 124, 159, 0.14); } +.step-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.step-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line-soft); } +.step-group-title { display: block; margin-bottom: 9px; color: var(--muted); font-size: 0.69rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } +.step-identity, .step-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; } +.step-timing { color: var(--muted); font-size: 0.7rem; font-variant-numeric: tabular-nums; } +.step-tool { min-height: 34px; padding: 6px 9px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 8px; background: var(--input); font-size: 0.7rem; font-weight: 720; } +.step-tool:hover:not(:disabled) { color: var(--ink); border-color: var(--sky); } +.step-handle { cursor: grab; touch-action: pan-y; } +.step-handle:active { cursor: grabbing; } +.step-tool:disabled, .remove-step:disabled { cursor: not-allowed; opacity: 0.4; } +.macro-step.dragging { opacity: 0.5; } +.macro-step.drop-before { box-shadow: 0 -4px 0 var(--sky); } +.macro-step.drop-after { box-shadow: 0 4px 0 var(--sky); } +.macro-step.preview-active { border-color: var(--mint); box-shadow: 0 0 0 1px var(--mint); } +.macro-notice { min-height: 1.4em; margin: 9px 0 14px; color: var(--mint); font-size: 0.76rem; line-height: 1.5; } +.macro-notice.error { color: var(--rose); } +.macro-preview { margin-bottom: 24px; padding: 19px; border: 1px solid var(--line); border-radius: 17px; background: #0b1324; } +.macro-preview[data-state="playing"] { border-color: var(--mint); } +.macro-preview-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 14px; } +.macro-preview-heading h4 { margin-top: 5px; } +.macro-preview-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.macro-preview > .field-help { margin-block: 12px; } +.macro-preview-timing { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-block: 10px; color: var(--muted); font-size: 0.73rem; } +.macro-preview-timing output { font-variant-numeric: tabular-nums; } +.macro-preview progress { width: 100%; height: 8px; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--mint); } +.macro-preview progress::-webkit-progress-bar { border-radius: 999px; background: var(--surface-3); } +.macro-preview progress::-webkit-progress-value { border-radius: 999px; background: var(--mint); } +.macro-preview-buttons { margin-block: 14px; padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); } +.preview-button { display: inline-flex; flex-direction: column; gap: 3px; min-width: 64px; padding: 7px 9px; color: var(--muted); border: 1px solid var(--line); border-radius: 8px; font-size: 0.7rem; } +.preview-button small { font-size: 0.58rem; font-weight: 600; } +.preview-button.pressed { color: #080e1c; border-color: var(--mint); background: var(--mint); } +.macro-preview [data-mode="override"] { border-color: var(--mint); } +.macro-preview [data-mode="override"] [data-preview-mode] { color: var(--mint); } +.macro-preview [data-mode="passthrough"] .stick-dot-output { visibility: hidden; } +.macro-preview [data-mode="passthrough"] progress { opacity: 0.35; } +.macro-preview [data-preview-value] { display: block; margin-top: 9px; color: var(--muted); font-size: 0.7rem; font-variant-numeric: tabular-nums; } +.macro-preview-triggers { display: flex; flex-direction: column; justify-content: center; gap: 16px; } +.macro-preview-triggers > div { padding: 10px; border: 1px dashed var(--line); border-radius: 9px; } +.macro-capture { margin-bottom: 24px; padding: 19px; border: 1px solid rgba(255, 124, 159, 0.35); border-radius: 17px; background: #141323; } +.macro-capture[data-state="recording"] { border-color: var(--rose); box-shadow: 0 0 0 1px rgba(255, 124, 159, 0.22); } +.macro-capture > .field-help { margin-block: 12px; } +.capture-options { min-width: 0; margin-block: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; } +.capture-options legend { padding-inline: 6px; color: var(--muted); font-size: 0.73rem; font-weight: 700; } +.capture-options:disabled { opacity: 0.65; } +.capture-number-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; } +.macro-capture > progress { width: 100%; height: 8px; accent-color: var(--rose); } +.capture-notice { min-height: 1.5em; margin-block: 12px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; } +.macro-capture:is([data-state="full"], [data-state="disconnected"], [data-state="error"]) .capture-notice { color: var(--amber); } +.capture-events { display: grid; gap: 7px; max-height: 260px; margin: 14px 0; padding-left: 28px; overflow: auto; color: var(--muted); font-size: 0.72rem; font-variant-numeric: tabular-nums; } +.capture-events:empty { display: none; } +.capture-events li { padding: 9px; border: 1px solid var(--line-soft); border-radius: 8px; background: var(--surface-2); overflow-wrap: anywhere; } +.capture-events strong { color: var(--ink); } +#profileForm [inert] { opacity: 0.55; } +.loading-card { display: grid; place-items: center; min-height: 300px; color: var(--muted); border: 1px dashed var(--line); border-radius: 22px; background: var(--surface); } +.spinner { width: 30px; height: 30px; margin-bottom: -90px; border: 3px solid var(--surface-3); border-top-color: var(--blue); border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.toast { position: fixed; right: 24px; bottom: 24px; z-index: 40; max-width: min(430px, calc(100vw - 48px)); padding: 14px 17px; color: var(--ink); border: 1px solid var(--line); border-radius: 13px; background: #172139; box-shadow: var(--shadow); opacity: 0; transform: translateY(12px); pointer-events: none; transition: 180ms ease; } +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { border-color: var(--rose); } + +.identity-tools .field-label { margin-top: 18px; } +.identity-actions { display: flex; gap: 8px; margin-top: 9px; } +.identity-actions .button { flex: 1; padding-inline: 8px; } +.controller-details { min-height: 18px; margin-top: 8px; color: var(--subtle); font-size: 0.67rem; } +.profile-name-editor { display: grid; grid-template-columns: minmax(140px, 240px) auto; align-items: end; gap: 8px; margin-top: 12px; } +.profile-name-editor label { grid-column: 1 / -1; color: var(--muted); font-size: 0.66rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; } +.profile-name-editor input { min-height: 40px; padding: 8px 11px; color: var(--ink); border: 1px solid var(--control-line); border-radius: 10px; background: var(--input); } +.panel-heading-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 8px; } +.panel-heading-actions p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; } +.section-reset { padding: 0; color: var(--sky); border: 0; background: transparent; font-size: 0.68rem; font-weight: 760; } +.section-reset:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } +.curve-editor { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(150px, 1.2fr); gap: 14px; margin-bottom: 16px; padding: 12px; border: 1px solid var(--line-soft); border-radius: 13px; background: #0a1120; } +.curve-editor svg { width: 100%; min-height: 110px; overflow: visible; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.curve-editor path { fill: none; vector-effect: non-scaling-stroke; } +.curve-guide { stroke: var(--subtle); stroke-dasharray: 3 3; opacity: 0.45; } +.curve-line { stroke: var(--blue); stroke-width: 2.5; } +.curve-marker { fill: var(--amber); stroke: #0a1120; stroke-width: 2; opacity: 0; transition: opacity 120ms ease; } +.curve-marker.visible { opacity: 1; } +.curve-controls { display: flex; flex-direction: column; gap: 10px; } +.curve-controls label { color: var(--muted); font-size: 0.67rem; font-weight: 720; } +.curve-controls output { float: right; color: var(--ink); } +.curve-controls input[type="range"] { display: block; margin-top: 8px; } +.copy-side { min-height: 34px; margin-top: auto; padding-block: 6px; } +.copy-dialog { width: min(440px, calc(100% - 28px)); padding: 24px; color: var(--ink); border: 1px solid var(--line); border-radius: 18px; background: var(--surface-solid); box-shadow: var(--shadow); } +.copy-dialog::backdrop { background: rgba(2, 5, 13, 0.76); } +.copy-dialog h3 { margin-bottom: 18px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } +@media (max-width: 1120px) { + .workspace { grid-template-columns: 250px minmax(0, 1fr); } + .profile-list { grid-template-columns: 1fr; } + .mapping-grid, .mapping-grid.compact { grid-template-columns: repeat(2, minmax(140px, 1fr)); } + .controller-mapper { grid-template-columns: 1fr; } + .playtest-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } + .trigger-playtest { grid-column: 1 / -1; } + .mapping-inspector { min-height: 270px; } + .editor-header { align-items: flex-start; } +} +@media (max-width: 780px) { + .topbar { position: relative; min-height: 76px; } + .topbar-meta > p { display: none; } + .connection { max-width: 52%; } + .workspace { display: block; width: min(100% - 24px, 700px); margin-top: 14px; } + .sidebar { position: relative; top: auto; margin-bottom: 18px; max-height: none; overflow: visible; scrollbar-gutter: auto; } + .profile-list { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .profile-button { min-height: 62px; } + .profile-number span { font-size: 0.74rem; } + .mini-active { margin-left: 0; } + .editor-header, .panel-heading { align-items: flex-start; flex-direction: column; } + .header-actions { justify-content: flex-start; } + .curve-editor { grid-template-columns: 1fr; } + .section-nav { top: 8px; } + .builtin-actions, .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } + .playtest-grid { grid-template-columns: 1fr; } + .trigger-playtest { grid-column: auto; } + .panel-heading > p, .subpanel-heading span { text-align: left; } + .macro-picker { flex-direction: column; } + .macro-steps-heading { align-items: stretch; flex-direction: column; } + .macro-step-action .button { width: 100%; } + .macro-budget { flex-basis: auto; } + .step-grid { grid-template-columns: 1fr 1fr; } + .macro-preview .playtest-grid { grid-template-columns: 1fr; } + .macro-preview-heading, .macro-preview-actions { align-items: stretch; } + .macro-preview-actions .button, .step-tool, .remove-step { min-height: 44px; } +} +@media (max-width: 500px) { + .topbar { padding-inline: 14px; } + .brand-mark { width: 38px; height: 38px; } + .connection { max-width: 48%; padding: 7px 9px; font-size: 0.68rem; } + .workspace { width: min(100% - 16px, 480px); } + .sidebar, .panel, .editor-header { border-radius: 17px; } + .profile-name-editor { grid-template-columns: 1fr; } + .panel-heading-actions { align-items: flex-start; } + .panel-heading-actions p { text-align: left; } + .sidebar, .panel { padding: 18px; } + .trigger-meter { grid-template-columns: 25px minmax(100px, 1fr); } + .trigger-meter output { grid-column: 2; text-align: left; } + .profile-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .mapping-grid, .mapping-grid.compact, .number-grid, .step-grid { grid-template-columns: 1fr; } + .button { flex: 1 1 auto; min-height: 44px; } + .header-actions { width: 100%; } + .section-nav a { padding-inline: 11px; } + .macro-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .controller-canvas { padding: 12px; } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html new file mode 100644 index 0000000..260ec7a --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -0,0 +1,436 @@ + + + + + + Switch Pico Profile Studio + + + +
+
+
+
+ +
+

Switch Pico

+

Controller Studio

+
+
+
+

Map · tune · automate

+
+ + Connecting to adapter… +
+
+
+ +
+ + +
+
+
+

Global fallback

+
+

Profile 1

+ + +
+
+ + + +
+
+
+ + + + + + + +
+
+ + + +
+
+

Reading profile from the adapter…

+
+ + +
+
+ + + +
+

Duplicate profile

+

Choose a destination

+

Copy this current draft, including unsaved settings, to the destination. This replaces its stored profile and name; the source draft is unchanged.

+ + + + +
+ + +
+
+
+
+ + + + diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js new file mode 100644 index 0000000..f79114d --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -0,0 +1,2438 @@ +"use strict"; + +const PROFILE_OWNER_STORAGE_KEY = "switch-pico.profile-owner"; + +const MACRO_STEP_LIMIT = 8; +const MACRO_SHARED_STEP_LIMIT = 16; +const MACRO_BYTE_LIMIT = 136; +const DEFAULT_MACRO_STEP_BYTES = 5; + +// Visual-only state never enters the persisted profile or USB output. +const macroPreview = { + frame: 0, + running: false, + startedAt: 0, + steps: [], + duration: 0, + index: -1, + cycles: 1, + cycle: -1, +}; +let draggedMacroStep = null; +const macroCapture = { + session: null, + requestActive: false, + timer: 0, +}; +const state = { + schema: null, + identities: [], + identityIndex: 0, + profileIndex: 0, + profile: null, + profileNames: Array(8).fill(""), + original: "", + pendingName: false, + active: false, + selectedButton: "south", + selectedMacro: 0, + token: "", + busy: false, + identifyAvailable: false, + adapterConnected: false, + previewInputConnected: false, + playtestRequestActive: false, + liveSample: null, + playtestTimer: 0, + libraryRequestActive: false, + libraryTimer: 0, +}; + +const elements = { + connection: document.querySelector("#connectionState"), + connectionText: document.querySelector("#connectionText"), + identity: document.querySelector("#identitySelect"), + profileList: document.querySelector("#profileList"), + profileOwner: document.querySelector("#profileOwner"), + controllerAlias: document.querySelector("#controllerAlias"), + controllerDetails: document.querySelector("#controllerDetails"), + saveAlias: document.querySelector("#saveAliasButton"), + identify: document.querySelector("#identifyButton"), + profileTitle: document.querySelector("#profileTitle"), + activeBadge: document.querySelector("#activeBadge"), + dirtyBadge: document.querySelector("#dirtyBadge"), + profileName: document.querySelector("#profileName"), + saveProfileName: document.querySelector("#saveProfileNameButton"), + loading: document.querySelector("#loadingCard"), + form: document.querySelector("#profileForm"), + controllerCanvas: document.querySelector("#controllerCanvas"), + controllerImage: document.querySelector("#controllerImage"), + controllerModel: document.querySelector("#controllerModel"), + controllerCredit: document.querySelector("#controllerCredit"), + controllerHotspots: document.querySelector("#controllerHotspots"), + selectedMapping: document.querySelector("#selectedMapping"), + selectedControlGlyph: document.querySelector("#selectedControlGlyph"), + selectedControlName: document.querySelector("#selectedControlName"), + selectedControlDescription: document.querySelector("#selectedControlDescription"), + analog: document.querySelector("#analogFields"), + rumble: document.querySelector("#rumbleFields"), + builtinActions: document.querySelector("#builtinActions"), + turbo: document.querySelector("#turboFields"), + turboDefaults: document.querySelector("#turboDefaultFields"), + turboTimingNotice: document.querySelector("#turboTimingNotice"), + shortcutModifier: document.querySelector("#shortcutModifierFields"), + shortcuts: document.querySelector("#shortcutFields"), + shift: document.querySelector("#shiftFields"), + shiftMap: document.querySelector("#shiftMapFields"), + macroControls: document.querySelector("#macroControls"), + macroSteps: document.querySelector("#macroSteps"), + macroStepsTitle: document.querySelector("#macroStepsTitle"), + macroDuration: document.querySelector("#macroDuration"), + macroNotice: document.querySelector("#macroNotice"), + macroPreview: document.querySelector("#macroPreview"), + macroPreviewTitle: document.querySelector("#macroPreviewTitle"), + macroPreviewModeHelp: document.querySelector("#macroPreviewModeHelp"), + macroPreviewPlay: document.querySelector("#macroPreviewPlay"), + macroPreviewStop: document.querySelector("#macroPreviewStop"), + macroPreviewRestart: document.querySelector("#macroPreviewRestart"), + macroPreviewStatus: document.querySelector("#macroPreviewStatus"), + macroPreviewTime: document.querySelector("#macroPreviewTime"), + macroPreviewProgress: document.querySelector("#macroPreviewProgress"), + macroPreviewButtons: document.querySelector("#macroPreviewButtons"), + macroCapture: document.querySelector("#macroCapture"), + captureTitle: document.querySelector("#macroCaptureTitle"), + captureRecord: document.querySelector("#macroCaptureRecord"), + captureStop: document.querySelector("#macroCaptureStop"), + captureOptions: document.querySelector("#macroCaptureOptions"), + captureAxis: document.querySelector("#macroCaptureAxis"), + captureTrigger: document.querySelector("#macroCaptureTrigger"), + captureDuration: document.querySelector("#macroCaptureDuration"), + captureBudget: document.querySelector("#macroCaptureBudget"), + captureState: document.querySelector("#macroCaptureState"), + captureElapsed: document.querySelector("#macroCaptureElapsed"), + captureProgress: document.querySelector("#macroCaptureProgress"), + captureNotice: document.querySelector("#macroCaptureNotice"), + captureEvents: document.querySelector("#macroCaptureEvents"), + captureUse: document.querySelector("#macroCaptureUse"), + captureDiscard: document.querySelector("#macroCaptureDiscard"), + captureRecover: document.querySelector("#macroCaptureRecover"), + playtestPanel: document.querySelector("#playtestPanel"), + playtestTitle: document.querySelector("#playtestTitle"), + playtestStatus: document.querySelector("#playtestStatus"), + playtestHelp: document.querySelector("#playtestHelp"), + playtestLeftValues: document.querySelector("#playtestLeftValues"), + playtestRightValues: document.querySelector("#playtestRightValues"), + playtestLeftTriggerLabel: document.querySelector("#playtestLeftTriggerLabel"), + playtestRightTriggerLabel: document.querySelector("#playtestRightTriggerLabel"), + refresh: document.querySelector("#refreshButton"), + resetDraft: document.querySelector("#resetDraftButton"), + activate: document.querySelector("#activateButton"), + save: document.querySelector("#saveButton"), + addMacroStep: document.querySelector("#addMacroStepButton"), + copyProfile: document.querySelector("#copyProfileButton"), + exportProfile: document.querySelector("#exportProfileButton"), + importProfile: document.querySelector("#importProfileButton"), + importProfileFile: document.querySelector("#importProfileFile"), + copyDialog: document.querySelector("#copyDialog"), + copyIdentity: document.querySelector("#copyIdentity"), + copySlot: document.querySelector("#copySlot"), + confirmCopy: document.querySelector("#confirmCopyButton"), + toast: document.querySelector("#toast"), +}; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function label(value) { + return value + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + + +const directionalLabels = { + dpad_up: "D-pad Up", + dpad_right: "D-pad Right", + dpad_down: "D-pad Down", + dpad_left: "D-pad Left", +}; + +function currentControllerStyle() { + return state.identities[state.identityIndex]?.controller?.style || "generic"; +} + +function controlLabel(control, style = currentControllerStyle()) { + return state.schema.control_labels?.[style]?.[control] || + directionalLabels[control] || + label(control); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function canonical(value) { + return JSON.stringify(value); +} + +const { + transformStick, + transformTrigger, + stickCoordinates, + triggerPercent, +} = ProfilePlaytestMath; + +function updateStickPlaytest(side, raw, output, config) { + const scope = document.querySelector(`[data-playtest-stick="${side}"]`); + const rawPosition = stickCoordinates(raw); + const outputPosition = stickCoordinates(output); + scope.style.setProperty("--raw-x", `${rawPosition.left}%`); + scope.style.setProperty("--raw-y", `${rawPosition.top}%`); + scope.style.setProperty("--output-x", `${outputPosition.left}%`); + scope.style.setProperty("--output-y", `${outputPosition.top}%`); + scope.style.setProperty( + "--inner-size", + `${Math.max(0, Math.min(100, config.inner_deadzone / 32767 * 100))}%` + ); + scope.style.setProperty( + "--outer-size", + `${Math.max(0, Math.min(100, config.outer_saturation / 32767 * 100))}%` + ); + const values = `${raw.x}, ${raw.y} → ${output.x}, ${output.y}`; + (side === "left" ? elements.playtestLeftValues : elements.playtestRightValues) + .textContent = values; +} + +function updateTriggerPlaytest(side, raw, output, config) { + const meter = document.querySelector(`[data-playtest-trigger="${side}"]`); + const track = meter.querySelector(".trigger-track"); + track.style.setProperty("--raw", `${triggerPercent(raw)}%`); + track.style.setProperty("--output", `${triggerPercent(output)}%`); + track.style.setProperty( + "--threshold", + `${config.digital_threshold / 65535 * 100}%` + ); + meter.querySelector("output").textContent = `${raw} → ${output}`; +} + +function updateCurveMarker(group, side, input) { + const panel = document.querySelector( + `[data-analog-group="${group}"][data-analog-side="${side}"]` + ); + if (!panel) return; + const normalized = Math.max(0, Math.min(1, input)); + const config = state.profile[group][side]; + const output = ProfilePlaytestMath.transformTrigger( + Math.round(normalized * 65535), + { + lower_deadzone: 0, + upper_saturation: 65535, + curve_q8_8: config.curve_q8_8, + } + ) / 65535; + const marker = panel.querySelector(".curve-marker"); + marker.setAttribute("cx", String(normalized * 100)); + marker.setAttribute("cy", String(60 - output * 60)); + marker.classList.add("visible"); +} + +function clearPlaytest(message, stateName = "waiting") { + state.liveSample = null; + if (state.previewInputConnected) stopMacroPreview("Preview stopped: controller disconnected."); + state.previewInputConnected = false; + state.identifyAvailable = false; + elements.identify.disabled = true; + elements.playtestPanel.dataset.state = stateName; + elements.playtestStatus.textContent = + stateName === "error" ? "Unavailable" : "Waiting"; + elements.playtestTitle.textContent = + stateName === "error" ? "Live input unavailable" : "Waiting for controller input"; + elements.playtestHelp.textContent = message; + elements.controllerHotspots.querySelectorAll(".pressed") + .forEach((button) => button.classList.remove("pressed")); + document.querySelectorAll(".curve-marker.visible") + .forEach((marker) => marker.classList.remove("visible")); + renderCapture(); +} + +function renderPlaytest(sample) { + state.liveSample = sample; + if (!sample.connected) { + clearPlaytest( + "Connect or move a controller to compare its raw input with this draft." + ); + return; + } + state.previewInputConnected = true; + const rawLeft = sample.left_stick; + const rawRight = sample.right_stick; + const outputLeft = transformStick(rawLeft, state.profile.sticks.left); + const outputRight = transformStick(rawRight, state.profile.sticks.right); + const outputLeftTrigger = transformTrigger( + sample.triggers.left, state.profile.triggers.left + ); + const outputRightTrigger = transformTrigger( + sample.triggers.right, state.profile.triggers.right + ); + updateStickPlaytest( + "left", rawLeft, outputLeft, state.profile.sticks.left + ); + updateStickPlaytest( + "right", rawRight, outputRight, state.profile.sticks.right + ); + updateTriggerPlaytest( + "left", sample.triggers.left, outputLeftTrigger, + state.profile.triggers.left + ); + updateTriggerPlaytest( + "right", sample.triggers.right, outputRightTrigger, + state.profile.triggers.right + ); + updateCurveMarker( + "sticks", "left", + Math.max(Math.abs(rawLeft.x), Math.abs(rawLeft.y)) / 32767 + ); + updateCurveMarker( + "sticks", "right", + Math.max(Math.abs(rawRight.x), Math.abs(rawRight.y)) / 32767 + ); + updateCurveMarker("triggers", "left", sample.triggers.left / 65535); + updateCurveMarker("triggers", "right", sample.triggers.right / 65535); + const style = sample.controller?.style || currentControllerStyle(); + elements.playtestLeftTriggerLabel.textContent = + controlLabel("left_trigger", style); + elements.playtestRightTriggerLabel.textContent = + controlLabel("right_trigger", style); + const pressed = new Set(sample.buttons); + if (sample.triggers.left > 512) pressed.add("left_trigger"); + if (sample.triggers.right > 512) pressed.add("right_trigger"); + elements.controllerHotspots.querySelectorAll("[data-controller-button]") + .forEach((button) => { + button.classList.toggle( + "pressed", pressed.has(button.dataset.controllerButton) + ); + }); + elements.playtestPanel.dataset.state = "live"; + elements.playtestStatus.textContent = "Live"; + const owner = currentOwner(); + state.identifyAvailable = Boolean( + owner && owner.index !== 0 && sample.identity_key === owner.key && + sample.capabilities?.some((capability) => + ["rumble", "lightbar", "player_leds"].includes(capability) + ) + ); + elements.identify.disabled = state.busy || !state.identifyAvailable; + elements.playtestTitle.textContent = sample.label || "Connected controller"; + const details = [ + sample.controller?.model, + sample.identity?.transport, + sample.battery === null ? null : `${sample.battery}% battery`, + ...(sample.capabilities || []).map(label), + ].filter(Boolean); + elements.controllerDetails.textContent = details.join(" · "); + elements.playtestHelp.textContent = + "Yellow is raw input; blue is the output produced by this unsaved draft."; + renderCapture(); +} + +async function pollPlaytest() { + window.clearTimeout(state.playtestTimer); + if ( + document.hidden || state.busy || state.playtestRequestActive || + captureBlocking() || macroCapture.requestActive || !state.schema || !state.profile + ) { + state.playtestTimer = window.setTimeout(pollPlaytest, 250); + return; + } + const identityIndex = state.identityIndex; + const profileIndex = state.profileIndex; + state.playtestRequestActive = true; + try { + const sample = await api( + `/api/profiles/${identityIndex}/${profileIndex + 1}/playtest` + ); + if ( + !captureBlocking() && identityIndex === state.identityIndex && + profileIndex === state.profileIndex + ) { + renderPlaytest(sample); + } + } catch (error) { + if ( + identityIndex === state.identityIndex && + profileIndex === state.profileIndex + ) { + clearPlaytest( + `${error.message}. Flash current firmware to enable live playtest.`, + "error" + ); + } + } finally { + state.playtestRequestActive = false; + renderCapture(); + state.playtestTimer = window.setTimeout(pollPlaytest, 75); + } +} + +function isDirty() { + return state.profile !== null && + (canonical(state.profile) !== state.original || state.pendingName); +} + +function setConnection(mode, text) { + const wasConnected = state.adapterConnected; + state.adapterConnected = mode === "ready"; + if (wasConnected && !state.adapterConnected) stopMacroPreview("Preview stopped: adapter disconnected."); + if (wasConnected && !state.adapterConnected && captureBlocking()) { + stopCapture("Adapter disconnected; retaining the last received recording."); + } + elements.connection.dataset.state = mode; + elements.connectionText.textContent = text; + elements.copyProfile.disabled = state.busy || !state.adapterConnected; + if (state.profile) { + elements.save.disabled = + !state.adapterConnected || state.busy || !isDirty(); + elements.activate.disabled = + !state.adapterConnected || state.busy || state.active; + } + renderCapture(); +} + +let toastTimer = 0; +function toast(message, error = false) { + window.clearTimeout(toastTimer); + elements.toast.textContent = message; + elements.toast.classList.toggle("error", error); + elements.toast.classList.add("show"); + toastTimer = window.setTimeout(() => elements.toast.classList.remove("show"), 3600); +} + +async function api(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (options.method && options.method !== "GET") { + headers["X-Switch-Pico-Token"] = state.token; + } + const response = await fetch(path, { ...options, headers }); + let payload; + try { + payload = await response.json(); + } catch { + throw new Error(`Unexpected server response (${response.status})`); + } + if (!response.ok) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +function setBusy(busy) { + state.busy = busy; + busy ||= captureBlocking(); + elements.save.disabled = + busy || !state.adapterConnected || !isDirty(); + elements.activate.disabled = + busy || !state.adapterConnected || state.active; + elements.resetDraft.disabled = busy; + elements.refresh.disabled = busy; + elements.identity.disabled = busy; + elements.identify.disabled = busy || !state.identifyAvailable; + elements.profileName.disabled = busy; + elements.saveProfileName.disabled = busy || !state.adapterConnected; + elements.controllerAlias.disabled = + busy || currentOwner()?.index === 0; + elements.saveAlias.disabled = + busy || !state.adapterConnected || currentOwner()?.index === 0; + elements.copyProfile.disabled = busy || !state.adapterConnected; + elements.importProfile.disabled = busy; + elements.exportProfile.disabled = busy; + if (state.schema && state.profile) updateMacroBudgets(); + document.querySelectorAll(".profile-button").forEach((button) => { + button.disabled = busy; + }); + renderCapture(); +} + +function updateDirtyState() { + const dirty = isDirty(); + elements.dirtyBadge.hidden = !dirty; + elements.save.disabled = + !state.adapterConnected || state.busy || captureBlocking() || !dirty; +} + +function confirmDiscard() { + return !isDirty() || window.confirm("Discard the unsaved changes to this profile?"); +} + +function currentOwner() { + return state.identities.find( + (entry) => entry.index === state.identityIndex + ) || null; +} + +function storedOwnerKey() { + try { + return window.localStorage.getItem(PROFILE_OWNER_STORAGE_KEY); + } catch { + return null; + } +} + +function persistOwnerKey(key) { + try { + window.localStorage.setItem(PROFILE_OWNER_STORAGE_KEY, key); + } catch { + // Storage can be unavailable in private or hardened browser contexts. + } +} + +function identitySignature(identities) { + return identities.map((entry) => ( + `${entry.index}:${entry.key}:${entry.label}:` + + `${entry.controller.model}:${entry.controller.style}` + )).join("|"); +} + +function syncLibraryMetadata(identities, restoreStoredOwner = false) { + const oldOwner = currentOwner(); + const oldSignature = identitySignature(state.identities); + const preferredKey = ( + restoreStoredOwner ? storedOwnerKey() : oldOwner?.key + ) || storedOwnerKey(); + if (oldOwner && state.profile && !identities.some((entry) => entry.key === oldOwner.key)) { + throw new Error("The selected profile owner is unavailable. Your draft is retained; reconnect the owner or export it before choosing another."); + } + state.identities = identities; + const nextOwner = ( + identities.find((entry) => entry.key === preferredKey) || + identities[0] || + null + ); + state.identityIndex = nextOwner?.index ?? 0; + if (nextOwner) persistOwnerKey(nextOwner.key); + + const ownerChanged = oldOwner?.key !== nextOwner?.key; + const identitiesChanged = + oldSignature !== identitySignature(state.identities); + const activeChanged = + oldOwner?.active_profile !== nextOwner?.active_profile; + if (state.schema && (identitiesChanged || ownerChanged)) { + renderIdentities(); + } + if (state.schema && (activeChanged || ownerChanged)) { + renderProfileList(); + } + if (state.profile) { + state.active = + nextOwner?.active_profile === state.profileIndex + 1; + elements.activeBadge.hidden = !state.active; + elements.activate.disabled = + !state.adapterConnected || state.busy || state.active; + } + return ownerChanged; +} + +async function pollLibraryMetadata() { + window.clearTimeout(state.libraryTimer); + if ( + document.hidden || state.busy || captureBlocking() || macroCapture.requestActive || + state.libraryRequestActive || !state.schema + ) { + state.libraryTimer = + window.setTimeout(pollLibraryMetadata, 500); + return; + } + state.libraryRequestActive = true; + try { + const payload = await api("/api/profiles"); + syncLibraryMetadata(payload.identities); + setConnection("ready", "Adapter connected"); + } catch { + setConnection("error", "Adapter disconnected"); + } finally { + state.libraryRequestActive = false; + renderCapture(); + state.libraryTimer = + window.setTimeout(pollLibraryMetadata, 750); + } +} + +function renderIdentities() { + elements.identity.innerHTML = state.identities + .map((entry) => ``) + .join(""); + elements.identity.value = String(state.identityIndex); + const owner = currentOwner(); + elements.profileOwner.textContent = owner ? owner.label : "No controller"; +} + +function renderProfileList() { + const owner = currentOwner(); + elements.profileList.innerHTML = Array.from({ length: state.schema.profile_capacity }, (_, index) => { + const selected = index === state.profileIndex; + const active = owner && owner.active_profile === index + 1; + return ` + `; + }).join(""); + + elements.profileList.querySelectorAll(".profile-button").forEach((button) => { + button.addEventListener("click", async () => { + const next = Number(button.dataset.profileIndex); + if (captureBlocking() || next === state.profileIndex || !confirmDiscard()) return; + state.profileIndex = next; + await loadProfile(); + }); + }); +} + +function buttonOptions( + selected, + includeNone = true, + choices = state.schema.controls, + style = currentControllerStyle() +) { + const none = includeNone + ? `` + : ""; + return none + choices.map((button) => ( + `` + )).join(""); +} + +function modeOptions(modes, selected) { + return modes.map((mode) => + `` + ).join(""); +} + +function modifierOptions(selected, excluded = []) { + const available = currentOwner()?.modifier_controls || state.schema.buttons; + return `` + + state.schema.controls.map((control) => + `` + ).join(""); +} + +function renderShortcuts() { + const shortcuts = state.profile.shortcuts; + elements.shortcutModifier.innerHTML = ` +
+ + +

None clears every shortcut. Analog trigger modifiers require a known Xbox or PlayStation owner; unsupported choices remain unavailable.

+
`; + elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => ` +
+ + +
`).join(""); +} + +function renderShift() { + const shift = state.profile.shift; + elements.shift.innerHTML = ` +
+ + +
+
+ + +

Hold uses the alternate map while pressed. Toggle switches layers on each fresh press and resets when the profile or mode changes. Analog modifiers follow the same availability as shortcuts.

+
`; + elements.shiftMap.innerHTML = state.schema.buttons.map((button) => ` +
+ + +
`).join(""); + updateShiftValidity(); +} + +function updateShiftValidity() { + const modifier = elements.shift.querySelector("#shift-modifier"); + modifier.setCustomValidity(state.profile.shift.mode !== "off" && state.profile.shift.modifier === null + ? "Choose a modifier for Hold or Toggle, or turn Shift off." : ""); +} + +const controllerArtwork = { + generic: { + source: "/assets/controller-xbox.svg", + alt: "Generic Xbox-layout controller artwork", + }, + xbox: { + source: "/assets/controller-xbox.svg", + alt: "Xbox controller artwork", + }, + switch: { + source: "/assets/controller-switch-pro.svg", + alt: "Nintendo Switch Pro Controller artwork", + }, + playstation: { + source: "/assets/controller-dualsense.svg", + alt: "Sony DualSense controller artwork", + }, +}; + +const controllerGlyphs = { + generic: { + north: "Y", east: "B", south: "A", west: "X", + left_shoulder: "LB", right_shoulder: "RB", + left_trigger: "LT", right_trigger: "RT", + select: "⧉", start: "☰", capture: "↥", system: "X", + }, + xbox: { + north: "Y", east: "B", south: "A", west: "X", + left_shoulder: "LB", right_shoulder: "RB", + left_trigger: "LT", right_trigger: "RT", + select: "⧉", start: "☰", capture: "↥", system: "X", + }, + switch: { + north: "X", east: "A", south: "B", west: "Y", + left_shoulder: "L", right_shoulder: "R", + left_trigger: "ZL", right_trigger: "ZR", + select: "−", start: "+", capture: "▣", system: "⌂", + }, + playstation: { + north: "△", east: "○", south: "×", west: "□", + left_shoulder: "L1", right_shoulder: "R1", + left_trigger: "L2", right_trigger: "R2", + select: "⧉", start: "☰", capture: "TP", system: "PS", + }, +}; + +const fixedControlGlyphs = { + dpad_up: "▲", + dpad_right: "▶", + dpad_down: "▼", + dpad_left: "◀", + left_stick: "L3", + right_stick: "R3", + select: "−", + start: "+", + capture: "▣", + system: "⌂", +}; + +function controlGlyph(button, style) { + return controllerGlyphs[style]?.[button] || fixedControlGlyphs[button] || "?"; +} + +function getControlMapping(button) { + if (button === "left_trigger") return state.profile.triggers.left.output; + if (button === "right_trigger") return state.profile.triggers.right.output; + return state.profile.button_map[button]; +} + +function setControlMapping(button, output) { + const triggerSides = { + left_trigger: "left", + right_trigger: "right", + }; + const side = triggerSides[button]; + if (side) { + const otherSide = side === "left" ? "right" : "left"; + if ( + (output === "left_trigger" || output === "right_trigger") && + state.profile.triggers[otherSide].output === output + ) { + state.profile.triggers[otherSide].output = button; + } + state.profile.triggers[side].output = output; + } else { + state.profile.button_map[button] = output; + } +} + + +function renderButtonMap() { + const owner = state.identities[state.identityIndex]; + const style = owner?.controller?.style || "generic"; + const artwork = controllerArtwork[style] || controllerArtwork.generic; + const selected = state.selectedButton; + const mappedOutput = getControlMapping(selected); + + elements.controllerCanvas.dataset.style = style; + elements.controllerImage.src = artwork.source; + elements.controllerImage.alt = artwork.alt; + elements.controllerModel.textContent = owner?.controller?.model || "Generic controller"; + elements.controllerCredit.href = "https://github.com/AL2009man/Gamepad-Asset-Pack"; + elements.controllerCredit.textContent = "Controller artwork by Al. Lopez · MIT"; + + elements.controllerHotspots.querySelectorAll("[data-controller-button]").forEach((hotspot) => { + const button = hotspot.dataset.controllerButton; + const output = getControlMapping(button); + hotspot.textContent = controlGlyph(button, style); + hotspot.classList.toggle("selected", button === selected); + hotspot.classList.toggle("disabled-map", output === null); + hotspot.title = `${controlLabel(button, style)} → ${output === null ? "Disabled" : controlLabel(output, style)}`; + hotspot.setAttribute("aria-label", hotspot.title); + hotspot.onclick = () => { + state.selectedButton = button; + stopMacroPreview("Preview stopped: control selection changed."); + renderButtonMap(); + }; + }); + + elements.selectedControlGlyph.textContent = controlGlyph(selected, style); + elements.selectedControlName.textContent = controlLabel(selected, style); + elements.selectedControlDescription.textContent = ( + `Physical ${controlLabel(selected, style)} currently produces ${mappedOutput === null ? "no output" : controlLabel(mappedOutput, style)}.` + ); + elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.controls, style); + elements.selectedMapping.onchange = () => { + setControlMapping(selected, elements.selectedMapping.value || null); + renderButtonMap(); + updateDirtyState(); + }; +} + +const analogDefinitions = [ + ["sticks", "left", "Left stick", [ + ["center_x", "Center X", -32768, 32767], + ["center_y", "Center Y", -32768, 32767], + ["inner_deadzone", "Inner deadzone", 0, 32767], + ["outer_saturation", "Outer saturation", 1, 32767], + ]], + ["sticks", "right", "Right stick", [ + ["center_x", "Center X", -32768, 32767], + ["center_y", "Center Y", -32768, 32767], + ["inner_deadzone", "Inner deadzone", 0, 32767], + ["outer_saturation", "Outer saturation", 1, 32767], + ]], + ["triggers", "left", "Left trigger", [ + ["lower_deadzone", "Lower deadzone", 0, 65535], + ["upper_saturation", "Upper saturation", 1, 65535], + ["digital_threshold", "Digital threshold", 0, 65535], + ]], + ["triggers", "right", "Right trigger", [ + ["lower_deadzone", "Lower deadzone", 0, 65535], + ["upper_saturation", "Upper saturation", 1, 65535], + ["digital_threshold", "Digital threshold", 0, 65535], + ]], +]; + +const curvePresets = { + quick: 128, + linear: 256, + precise: 512, + deliberate: 768, +}; + +function curvePreset(value) { + return Object.entries(curvePresets) + .find(([, preset]) => preset === value)?.[0] || "custom"; +} + +function curvePath(curve) { + return Array.from({ length: 21 }, (_, index) => { + const input = Math.round(index / 20 * 65535); + const output = ProfilePlaytestMath.transformTrigger(input, { + lower_deadzone: 0, + upper_saturation: 65535, + curve_q8_8: curve, + }); + return `${index === 0 ? "M" : "L"} ${index * 5} ${60 - output / 65535 * 60}`; + }).join(" "); +} + +function renderAnalog() { + elements.analog.innerHTML = analogDefinitions.map(([group, side, title, fields]) => { + const config = state.profile[group][side]; + const toggles = group === "sticks" ? ` +
+ ${["invert_x", "invert_y"].map((field) => ` + `).join("")} +
` : ""; + return ` +
+

${title}

${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}
+
+ + + + + +
+ + + +
+
+
+ ${fields.map(([field, fieldLabel, min, max]) => ` +
+ + +
`).join("")} + ${toggles} +
+
`; + }).join(""); +} + +function renderFeedback() { + const rumble = state.profile.rumble; + elements.rumble.innerHTML = ` +

Rumble & confirmation

0 is silent · 255 is full strength
+ ${["weak_scale", "strong_scale"].map((field) => ` +
+
${rumble[field]}
+ +
`).join("")} +
+ + +
`; +} + +function turboSettingFields(settings, button = null, disabled = false) { + const titles = { rate_hz: "Rate (Hz)", duty_percent: "Duty (%)", burst_count: "Burst pulses" }; + return Object.entries(state.schema.turbo_settings_bounds).map(([field, bounds]) => { + const id = `turbo-setting-${button || "defaults"}-${field}`; + const inactive = disabled || (button !== null && field === "burst_count" && state.profile.turbo[button] !== "burst"); + return `
+ + +
`; + }).join(""); +} + +function renderTurbo() { + const settings = state.profile.turbo_settings; + elements.turboDefaults.innerHTML = ` +

Shared defaults

Settings apply to physical buttons, before either button map.
+
${turboSettingFields(settings.defaults)}
+

Duty is the ON portion of each cycle. Burst count applies only to Burst mode. Enable a per-button override to replace all three defaults for that button.

`; + elements.turbo.innerHTML = state.schema.buttons.map((button) => { + const override = settings.overrides[button]; + const off = state.profile.turbo[button] === "off"; + return `
+ + + +
${turboSettingFields(override || settings.defaults, button, off || !override)}
+

+
`; + }).join(""); + updateTurboTiming(); +} + +function updateTurboTiming() { + const narrow = []; + elements.turbo.querySelectorAll("[data-turbo-button]").forEach((card) => { + const button = card.dataset.turboButton; + const mode = state.profile.turbo[button]; + const settings = state.profile.turbo_settings.overrides[button] || state.profile.turbo_settings.defaults; + const on = 1000 * settings.duty_percent / (100 * settings.rate_hz); + const off = 1000 / settings.rate_hz - on; + const tooNarrow = mode !== "off" && Math.min(on, off) < 15; + card.classList.toggle("narrow-pulse", tooNarrow); + card.querySelector("[data-turbo-timing]").textContent = + `${on.toFixed(2)} ms ON / ${off.toFixed(2)} ms OFF${mode === "off" ? " · inactive" : ""}`; + if (tooNarrow) narrow.push(controlLabel(button)); + }); + elements.turboTimingNotice.classList.toggle("warning", narrow.length > 0); + elements.turboTimingNotice.textContent = narrow.length + ? `Narrow phases: ${narrow.join(", ")}. The adapter does not report live USB cadence. Switch normally sends at 15 ms with an 8 ms endpoint interval; these shorter ON/OFF windows may be missed or quantized. Other USB modes differ. Your values are unchanged.` + : "USB timing: live cadence is not reported. Switch normally sends at 15 ms with an 8 ms endpoint interval; other USB modes differ. Phases shorter than a report interval may be missed or quantized."; +} + +function macroNumber(index, field, value, min, max, title, disabled = false) { + const id = `macro-step-${index}-${field.replace(".", "-")}`; + return ` +
+ + +
`; +} + +function actionChordCard(action, title, description, selectedButtons, defaults) { + const inherited = selectedButtons.length === 0 && defaults?.length; + const effectiveButtons = inherited ? defaults : selectedButtons; + const selected = new Set(effectiveButtons); + const defaultText = inherited + ? `Using default: ${defaults.map((button) => controlLabel(button)).join(" + ")}.` + : defaults?.length + ? `Clear every selection to restore ${defaults.map((button) => controlLabel(button)).join(" + ")}.` + : "Empty disables this action."; + return ` +
+
+
${action === "custom_macro" ? "Custom macro" : "Built-in action"}

${title}

+ ${description} +
+
+ ${state.schema.controls.map((button) => ` + `).join("")} +
+

${defaultText}

+
`; +} + +function macroStepWireSize(step) { + const overrides = new Set(step.overrides); + return 3 + + (overrides.has("buttons") ? 2 : 0) + + (overrides.has("left_stick") ? 4 : 0) + + (overrides.has("right_stick") ? 4 : 0) + + (overrides.has("left_trigger") ? 2 : 0) + + (overrides.has("right_trigger") ? 2 : 0); +} + +function defaultMacroStep() { + return { + type: "state", + overrides: ["buttons"], + duration_ms: 100, + output_buttons: [], + left_stick: { x: 0, y: 0 }, + right_stick: { x: 0, y: 0 }, + triggers: { left: 0, right: 0 }, + }; +} + +function macroBudget(macros = state.profile.macros) { + return macros.reduce((total, macro) => { + total.steps += macro.steps.length; + for (const step of macro.steps) { + total.bytes += macroStepWireSize(step); + total.duration += step.duration_ms; + } + return total; + }, { steps: 0, bytes: 0, duration: 0 }); +} + +function macroBudgetError(macros) { + if (!Array.isArray(macros) || macros.length !== 4) return "A profile must contain four independent macros."; + if (macros.some((macro) => !Array.isArray(macro.steps))) return "Every macro must contain a step list."; + if (macros.some((macro) => macro.steps.length > MACRO_STEP_LIMIT)) { + return "Each macro can contain at most 8 steps."; + } + for (const macro of macros) { + for (const step of macro.steps) { + if (step.type !== "state" || !Number.isInteger(step.duration_ms) || + step.duration_ms < 0 || step.duration_ms > 10000) { + return "State steps require a whole-number duration from 0 to 10000 ms."; + } + } + if (!state.schema.macro_playback_modes.includes(macro.playback) || + !Number.isInteger(macro.repeat_count) || + macro.repeat_count < state.schema.macro_repeat_bounds.min || + macro.repeat_count > state.schema.macro_repeat_bounds.max) { + return "Choose a valid playback mode and whole-number repeat count within the displayed limits."; + } + if (macro.trigger.length && macro.playback !== "once" && macro.steps.length && + macro.steps.every((step) => step.duration_ms === 0)) { + return "A looping macro must have a nonzero cycle duration."; + } + } + const budget = macroBudget(macros); + if (budget.steps > MACRO_SHARED_STEP_LIMIT) return "The four macros share a limit of 16 steps."; + if (budget.bytes > MACRO_BYTE_LIMIT) return "The four macros share a limit of 136 sparse bytes."; + return ""; +} + +function captureBlocking() { + const session = macroCapture.session; + return Boolean(session && (!session.terminal || session.applying)); +} + +function captureOriginMatches(session) { + return session.profile === state.profile && + session.ownerKey === currentOwner()?.key && + session.profileIndex === state.profileIndex && + session.macroIndex === state.selectedMacro; +} + +function captureOptions() { + const channels = Array.from( + elements.captureOptions.querySelectorAll("[data-capture-channel]:checked") + ).reduce((mask, input) => mask | Number(input.dataset.captureChannel), 0); + const stepBytes = 3 + [2, 4, 4, 2, 2].reduce( + (sum, size, index) => sum + (channels & (1 << index) ? size : 0), 0 + ); + const otherBudget = state.profile ? macroBudget( + state.profile.macros.filter((_, index) => index !== state.selectedMacro) + ) : { steps: 0, bytes: 0 }; + return { + channels, + axis_quantum: Number(elements.captureAxis.value), + trigger_quantum: Number(elements.captureTrigger.value), + max_duration_ms: Number(elements.captureDuration.value), + max_events: Math.max(0, Math.min( + MACRO_STEP_LIMIT, MACRO_SHARED_STEP_LIMIT - otherBudget.steps, + Math.floor((MACRO_BYTE_LIMIT - otherBudget.bytes) / stepBytes) + )), + stepBytes, + otherBudget, + }; +} + +function renderCapture() { + const session = macroCapture.session; + const page = session?.page; + const options = session?.options || captureOptions(); + const locked = captureBlocking(); + const pending = macroCapture.requestActive; + const live = state.liveSample; + const owner = currentOwner(); + const sourceReady = live?.connected && live.owner_key === owner?.key && + (owner?.index === 0 || live.identity_key === owner?.key); + const invalid = elements.captureOptions.querySelector("input:invalid"); + elements.captureTitle.textContent = session + ? `${session.ownerLabel} · profile ${session.profileIndex + 1} · macro ${session.macroIndex + 1}` + : `Record into macro ${state.selectedMacro + 1}`; + elements.captureOptions.disabled = Boolean(session) || state.busy; + elements.captureRecord.disabled = Boolean(session) || state.busy || + !state.adapterConnected || !sourceReady || document.hidden || + state.playtestRequestActive || state.libraryRequestActive || + !options.channels || !options.max_events || Boolean(invalid); + elements.captureStop.disabled = !session || session.terminal || + pending || !session.runId; + elements.captureUse.disabled = !session?.terminal || pending || state.busy || + !page?.steps?.length || session.used || !captureOriginMatches(session); + elements.captureDiscard.disabled = !session || pending || state.busy; + elements.captureRecover.hidden = !session?.error; + elements.captureRecover.disabled = pending || state.busy; + const status = session?.error ? "error" : page?.state_name || (session ? "starting" : "idle"); + elements.macroCapture.dataset.state = status; + elements.captureState.textContent = session?.applying ? "Validating recorded steps" : { + idle: "Ready", starting: "Starting recording", recording: "Recording raw input", + stopped: "Stopped", full: "Full · recording stopped", + timed_out: "Time limit reached", disconnected: "Controller disconnected", + error: "Recording needs attention", + }[status] || status; + elements.captureElapsed.textContent = + `${Math.round((page?.elapsed_us || 0) / 1000)} / ${options.max_duration_ms} ms`; + const count = page?.total_events || 0; + elements.captureProgress.max = Math.max(1, options.max_events); + elements.captureProgress.value = count; + elements.captureBudget.textContent = + `${count}/${options.max_events} recorded states · ${count * options.stepBytes}/${options.max_events * options.stepBytes} reserved sparse bytes · ` + + `other macros: ${options.otherBudget.steps}/16 shared steps, ${options.otherBudget.bytes}/136 bytes. ` + + "Initial input counts as a state; held states split at 10 seconds. Capture stops before exceeding capacity."; + const terminalNotice = { + stopped: "Recording retained. Review it, then Use or Discard.", + full: "The recording filled its requested state/byte capacity and stopped. This is a bounded partial sequence, not a complete longer performance.", + timed_out: "The configured time limit ended this recording. Review the retained sequence before using it.", + disconnected: "The controller disconnected. Partial recorded input is retained; reconnecting will not resume this run.", + }[page?.state_name]; + const originWarning = session && !captureOriginMatches(session) + ? "This capture belongs to another draft or macro. Use is disabled; it cannot overwrite this selection." + : ""; + elements.captureNotice.textContent = [ + session?.error, terminalNotice, page?.conversion_error, + session?.message, originWarning, + session?.used ? "Recorded steps are now in the unsaved draft. Review the visual preview below; Save remains a separate action." : "", + !session && !sourceReady ? "Connect the selected controller and wait for its live input before recording." : "", + !session && !options.channels ? "Select at least one channel." : "", + !session && !options.max_events ? "Other macros leave no room for a recorded state. Free shared steps or bytes first." : "", + ].filter(Boolean).join(" "); + // Each response contains the complete firmware prefix (at most eight states), + // not samples taken by this poll. Terminal pages are collected by the host. + const events = page?.events || []; + elements.captureEvents.innerHTML = events.map((event, index) => { + const values = []; + if (options.channels & 1) { + const buttons = state.schema.buttons.filter((_, bit) => event.buttons & (1 << bit)); + values.push(`Buttons: ${buttons.map((button) => controlLabel(button)).join(" + ") || "released"}`); + } + if (options.channels & 2) values.push(`Left stick: ${event.left_x}, ${event.left_y}`); + if (options.channels & 4) values.push(`Right stick: ${event.right_x}, ${event.right_y}`); + if (options.channels & 8) values.push(`Left trigger: ${event.left_trigger}`); + if (options.channels & 16) values.push(`Right trigger: ${event.right_trigger}`); + const duration = page.steps?.[index]?.duration_ms; + return `
  • ${Math.round(event.at_us / 1000)} ms${duration === undefined ? "" : ` · hold ${duration} ms`} · ${escapeHtml(values.join(" · "))}
  • `; + }).join(""); + elements.form.querySelectorAll(":scope > .panel:not(#macro), #macro > :not(#macroCapture)") + .forEach((node) => { node.inert = locked; }); + if (locked) { + [ + elements.identity, elements.refresh, elements.resetDraft, elements.save, + elements.activate, elements.identify, elements.saveAlias, + elements.controllerAlias, elements.profileName, elements.saveProfileName, + elements.copyProfile, elements.importProfile, elements.exportProfile, + ...elements.profileList.querySelectorAll("button"), + ].forEach((control) => { control.disabled = true; }); + } +} + +function capturePath(session, action) { + return `/api/profiles/${session.identityIndex}/${session.profileIndex + 1}/capture/${action}`; +} + +function captureQuery(session) { + return `?owner_key=${encodeURIComponent(session.ownerKey)}&connection_generation=${session.generation}&capture_id=${encodeURIComponent(session.captureId)}`; +} + +function acceptCapturePage(session, page) { + if (macroCapture.session !== session) return; + if ( + page.owner_key !== session.ownerKey || + page.capture_id !== session.captureId || + page.profile_number !== session.profileIndex + 1 || + page.macro_index !== session.macroIndex || + page.connection_generation !== session.generation || + page.slot !== session.slot || (session.runId && page.run_id !== session.runId) + ) throw new Error("Stale recording response refused. The draft and retained input are unchanged."); + session.runId = page.run_id; + session.page = page; + session.error = ""; + session.terminal = !["idle", "recording"].includes(page.state_name); +} + +function finishCaptureRequest(session) { + macroCapture.requestActive = false; + setBusy(state.busy); + if (macroCapture.session !== session) return; + if (session.terminal) { + state.playtestTimer = window.setTimeout(pollPlaytest, 75); + state.libraryTimer = window.setTimeout(pollLibraryMetadata, 750); + } + if (!session.terminal && session.stopRequested && !session.stopAttempted) { + stopCapture(session.message); + } else if (!session.terminal && !session.error && !document.hidden) { + macroCapture.timer = window.setTimeout(refreshCapture, 75); + } +} + +async function beginCapture() { + if (elements.captureRecord.disabled || state.busy || macroCapture.session || + state.playtestRequestActive || state.libraryRequestActive) return; + if (!elements.form.reportValidity() || !macroInputsValid(true)) return; + const options = captureOptions(); + if (!options.channels || !options.max_events) return; + const owner = currentOwner(); + const sample = state.liveSample; + const session = { + captureId: crypto.randomUUID(), + profile: state.profile, ownerKey: owner.key, ownerLabel: owner.label, + identityIndex: state.identityIndex, profileIndex: state.profileIndex, + macroIndex: state.selectedMacro, slot: sample.slot, + generation: sample.connection_generation, options, runId: null, + page: null, terminal: false, error: "", message: "", used: false, + stopRequested: false, stopAttempted: false, applying: false, + }; + macroCapture.session = session; + macroCapture.requestActive = true; + window.clearTimeout(state.playtestTimer); + window.clearTimeout(state.libraryTimer); + stopMacroPreview("Preview stopped: recording live input."); + setBusy(state.busy); + try { + const page = await api(capturePath(session, "start"), { + method: "POST", keepalive: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + owner_key: session.ownerKey, capture_id: session.captureId, slot: session.slot, + connection_generation: session.generation, macro_index: session.macroIndex, + profile: state.profile, channels: options.channels, + max_events: options.max_events, axis_quantum: options.axis_quantum, + trigger_quantum: options.trigger_quantum, max_duration_ms: options.max_duration_ms, + }), + }); + acceptCapturePage(session, page); + } catch (error) { + session.error = `${error.message} Draft unchanged. Read retained recording to recover an acknowledged start whose response was lost, or Discard.`; + } finally { + finishCaptureRequest(session); + } +} + +async function refreshCapture() { + window.clearTimeout(macroCapture.timer); + const session = macroCapture.session; + if (!session || macroCapture.requestActive || session.terminal) return; + macroCapture.requestActive = true; + renderCapture(); + try { + const page = await api( + capturePath(session, session.runId || "current") + captureQuery(session), + { keepalive: true } + ); + acceptCapturePage(session, page); + if (document.hidden) session.stopRequested = true; + } catch (error) { + session.error = `${error.message} The last received input and draft are retained. The firmware time/event limits still apply.`; + session.stopRequested = true; + } finally { + finishCaptureRequest(session); + } +} + +async function stopCapture(message = "") { + window.clearTimeout(macroCapture.timer); + const session = macroCapture.session; + if (!session || session.terminal) return; + session.stopRequested = true; + if (message) session.message = message; + if (macroCapture.requestActive) return; + session.stopAttempted = true; + if (!session.runId) { + session.error ||= "Start has not been acknowledged. Read retained recording to recover its run before stopping."; + renderCapture(); + return; + } + macroCapture.requestActive = true; + renderCapture(); + try { + const page = await api(capturePath(session, "stop"), { + method: "POST", keepalive: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + run_id: session.runId, owner_key: session.ownerKey, capture_id: session.captureId, + connection_generation: session.generation, + }), + }); + acceptCapturePage(session, page); + } catch (error) { + session.error = `${error.message} Stop was not acknowledged. Last received input and draft are retained; the bounded firmware run cannot resume automatically.`; + } finally { + finishCaptureRequest(session); + } +} + +async function useCapture() { + const session = macroCapture.session; + if (!session?.terminal || macroCapture.requestActive || !captureOriginMatches(session)) return; + if (!session.page?.steps?.length || !elements.form.reportValidity()) return; + const candidate = clone(state.profile); + candidate.macros[session.macroIndex].steps = clone(session.page.steps); + const error = macroBudgetError(candidate.macros); + if (error) { + session.message = `${error} Draft unchanged; recording retained.`; + renderCapture(); + return; + } + session.applying = true; + macroCapture.requestActive = true; + setBusy(state.busy); + try { + const validated = await api("/api/profiles/validate", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(candidate), + }); + if (!captureOriginMatches(session) || macroCapture.session !== session) { + throw new Error("Capture destination changed; refusing to overwrite another draft"); + } + const steps = validated.profile.macros[session.macroIndex].steps; + const macros = state.profile.macros.map((macro, index) => + index === session.macroIndex ? { ...macro, steps } : macro + ); + const budgetError = macroBudgetError(macros); + if (budgetError) throw new Error(budgetError); + stopMacroPreview("Ready to preview recorded steps."); + state.profile.macros[session.macroIndex].steps = clone(steps); + session.used = true; + session.message = ""; + renderMacro(); + updateDirtyState(); + } catch (error) { + session.message = `${error.message} Draft unchanged; recording retained.`; + } finally { + session.applying = false; + finishCaptureRequest(session); + } +} + +async function discardCapture() { + const session = macroCapture.session; + if (!session || macroCapture.requestActive) return; + if (!session.terminal) { + await stopCapture("Recording stopped before discard."); + if (!session.terminal && !window.confirm( + "The recorder could not acknowledge Stop. Discard the visible capture anyway? The firmware still stops at its event/time limit. Your draft will not change." + )) return; + } + window.clearTimeout(macroCapture.timer); + macroCapture.session = null; + setBusy(state.busy); + pollPlaytest(); + pollLibraryMetadata(); +} + +function canAddMacroStep(bytes, budget) { + return state.profile.macros[state.selectedMacro].steps.length < MACRO_STEP_LIMIT && + budget.steps < MACRO_SHARED_STEP_LIMIT && budget.bytes + bytes <= MACRO_BYTE_LIMIT; +} + +function macroNotice(message, error = false) { + elements.macroNotice.textContent = message; + elements.macroNotice.classList.toggle("error", error); +} + +function updateMacroBudgets() { + const macro = state.profile.macros[state.selectedMacro]; + const budget = macroBudget(); + elements.macroControls.querySelector("[data-budget-steps]")?.replaceChildren( + `${budget.steps}/${MACRO_SHARED_STEP_LIMIT} shared steps` + ); + elements.macroControls.querySelector("[data-budget-bytes]")?.replaceChildren( + `${budget.bytes}/${MACRO_BYTE_LIMIT} sparse bytes` + ); + const progress = elements.macroControls.querySelector("[data-budget-progress]"); + if (progress) progress.value = budget.bytes; + elements.macroControls.querySelectorAll("[data-macro-index]").forEach((button) => { + const item = state.profile.macros[Number(button.dataset.macroIndex)]; + const duration = item.steps.reduce((sum, step) => sum + step.duration_ms, 0); + button.querySelector("small").textContent = `${item.steps.length}/8 steps · ${duration} ms`; + }); + const duration = macro.steps.reduce((sum, step) => sum + step.duration_ms, 0); + const bytes = macro.steps.reduce((sum, step) => sum + macroStepWireSize(step), 0); + elements.macroDuration.textContent = + `${macro.steps.length}/8 steps · ${bytes} bytes · ${duration} ms total · ${budget.duration} ms across all macros`; + elements.addMacroStep.disabled = state.busy || !canAddMacroStep(DEFAULT_MACRO_STEP_BYTES, budget); + elements.addMacroStep.title = "Add a 100 ms button step (5 sparse bytes)"; + let startsAt = 0; + elements.macroSteps.querySelectorAll("[data-step-index]").forEach((card) => { + const index = Number(card.dataset.stepIndex); + const step = macro.steps[index]; + card.querySelector(".step-number").textContent = + `Step ${index + 1} · ${macroStepWireSize(step)} bytes`; + card.querySelector(".step-timing").textContent = + `${startsAt}–${startsAt + step.duration_ms} ms`; + startsAt += step.duration_ms; + card.querySelectorAll("[data-step-action]").forEach((button) => { + const action = button.dataset.stepAction; + button.disabled = state.busy || + (action === "up" && index === 0) || + (action === "down" && index === macro.steps.length - 1) || + (action === "insert" && !canAddMacroStep(DEFAULT_MACRO_STEP_BYTES, budget)) || + (action === "duplicate" && !canAddMacroStep(macroStepWireSize(step), budget)); + }); + }); + if (!macroPreview.running) { + const total = duration * (macro.playback === "repeat" ? macro.repeat_count : 1); + elements.macroPreviewTime.textContent = `0 / ${total} ms${["while_held", "toggle"].includes(macro.playback) ? " per cycle" : ""}`; + elements.macroPreviewProgress.max = Math.max(1, total); + elements.macroPreviewProgress.value = 0; + } + updateMacroPreviewControls(); + renderCapture(); +} + +function macroInputsValid(report = false) { + const invalid = elements.macroSteps.querySelector("input:invalid") || + elements.macroControls.querySelector("input:invalid"); + if (report && invalid) invalid.reportValidity(); + return !invalid; +} + +function updateMacroPlayback() { + const macro = state.profile.macros[state.selectedMacro]; + const count = elements.macroControls.querySelector("#macro-repeat-count"); + if (count) count.disabled = macro.playback !== "repeat"; + elements.macroPreviewModeHelp.textContent = { + once: "Once: the preview stops at the end of one sequence.", + repeat: `Repeat: the preview stops after ${macro.repeat_count} complete cycles.`, + while_held: "While held: hardware repeats while the entire trigger chord is held. This visual preview repeats until you choose Stop; it does not monitor or inject trigger input.", + toggle: "Toggle: hardware starts and stops on fresh trigger presses. This visual preview repeats until you choose Stop; it never sends USB input.", + }[macro.playback]; +} + +// Clone before changing anything: rejected additions and override expansions +// leave all four drafts, including nested output values, untouched. +function mutateMacroSteps(change, focusIndex, focusSelector = "[data-step-handle]") { + if (state.busy || captureBlocking() || !macroInputsValid(true)) return false; + stopMacroPreview("Preview stopped: draft edited."); + const macro = state.profile.macros[state.selectedMacro]; + const steps = clone(macro.steps); + change(steps); + const macros = state.profile.macros.map((item, index) => + index === state.selectedMacro ? { ...item, steps } : item + ); + const error = macroBudgetError(macros); + if (error) { + macroNotice(`${error} Draft unchanged.`, true); + return false; + } + macro.steps = steps; + renderMacroSteps(); + updateMacroBudgets(); + updateDirtyState(); + const index = Math.min(focusIndex, steps.length - 1); + const focus = elements.macroSteps.querySelector(`[data-step-index="${index}"] ${focusSelector}`); + (focus || elements.addMacroStep).focus(); + return true; +} + +function updateMacroPreviewControls() { + const macro = state.profile?.macros[state.selectedMacro]; + const zeroLoop = macro && macro.playback !== "once" && + macro.steps.every((step) => step.duration_ms === 0); + const unavailable = !macro?.steps.length || zeroLoop || + state.busy || captureBlocking() || document.hidden || !macroInputsValid(); + elements.macroPreviewPlay.disabled = unavailable || macroPreview.running; + elements.macroPreviewRestart.disabled = unavailable; + elements.macroPreviewStop.disabled = !macroPreview.running; +} + +function showMacroPreviewStep(step) { + const overrides = new Set(step?.overrides || []); + elements.macroPreview.querySelectorAll("[data-preview-field]").forEach((card) => { + const field = card.dataset.previewField; + const overridden = overrides.has(field); + card.dataset.mode = overridden ? "override" : "passthrough"; + card.querySelector("[data-preview-mode]").textContent = overridden ? "Overridden" : "Passthrough"; + if (field.endsWith("_stick")) { + const position = stickCoordinates(overridden ? step[field] : { x: 0, y: 0 }); + const scope = card.querySelector(".stick-scope"); + scope.style.setProperty("--output-x", `${position.left}%`); + scope.style.setProperty("--output-y", `${position.top}%`); + card.querySelector("output").textContent = overridden + ? `${step[field].x}, ${step[field].y}` : "Controller input"; + } else if (field.endsWith("_trigger")) { + const value = overridden ? step.triggers[field === "left_trigger" ? "left" : "right"] : 0; + const progress = card.querySelector("progress"); + progress.value = value; + progress.setAttribute("aria-valuetext", overridden ? String(value) : "Passthrough: controller input"); + card.querySelector("output").textContent = overridden ? String(value) : "Controller input"; + } + }); + elements.macroPreviewButtons.querySelectorAll("[data-preview-button]").forEach((button) => { + const overridden = overrides.has("buttons"); + const pressed = overridden && step.output_buttons.includes(button.dataset.previewButton); + button.classList.toggle("pressed", pressed); + button.querySelector("small").textContent = overridden ? (pressed ? "Pressed" : "Released") : "Passthrough"; + }); +} + +function stopMacroPreview(message = "Ready", reset = false) { + if (!macroPreview.running && !reset) return; + window.cancelAnimationFrame(macroPreview.frame); + macroPreview.frame = 0; + macroPreview.running = false; + macroPreview.steps = []; + macroPreview.index = -1; + macroPreview.cycle = -1; + elements.macroPreview.dataset.state = "stopped"; + elements.macroPreviewStatus.textContent = message; + elements.macroSteps.querySelectorAll(".preview-active") + .forEach((card) => card.classList.remove("preview-active")); + showMacroPreviewStep(null); + updateMacroPreviewControls(); +} + +function tickMacroPreview(now) { + if (!macroPreview.running) return; + const elapsed = Math.max(0, now - macroPreview.startedAt); + const total = macroPreview.duration * macroPreview.cycles; + const finite = macroPreview.cycles !== Infinity; + if (macroPreview.duration === 0 || (finite && elapsed >= total)) { + elements.macroPreviewTime.textContent = `${total} / ${total} ms`; + elements.macroPreviewProgress.value = total; + stopMacroPreview("Preview complete. All fields return to passthrough."); + return; + } + const cycle = Math.floor(elapsed / macroPreview.duration); + const phase = elapsed % macroPreview.duration; + let endsAt = 0; + let index = 0; + // Skip elapsed cycles arithmetically; inspect at most eight states per frame. + for (; index < macroPreview.steps.length; index += 1) { + endsAt += macroPreview.steps[index].duration_ms; + if (phase < endsAt) break; + } + elements.macroPreviewTime.textContent = finite + ? `${Math.floor(elapsed)} / ${total} ms` + : `${Math.floor(phase)} / ${macroPreview.duration} ms · cycle ${cycle + 1}`; + elements.macroPreviewProgress.value = finite ? elapsed : phase; + if (index !== macroPreview.index || cycle !== macroPreview.cycle) { + macroPreview.index = index; + macroPreview.cycle = cycle; + elements.macroPreviewStatus.textContent = + `Cycle ${cycle + 1}${finite ? ` of ${macroPreview.cycles}` : " · Stop to end"} · step ${index + 1} of ${macroPreview.steps.length}`; + showMacroPreviewStep(macroPreview.steps[index]); + elements.macroSteps.querySelectorAll("[data-step-index]").forEach((card) => { + card.classList.toggle("preview-active", Number(card.dataset.stepIndex) === index); + }); + } + macroPreview.frame = window.requestAnimationFrame(tickMacroPreview); +} + +function playMacroPreview() { + if (state.busy || document.hidden || !state.profile || !macroInputsValid(true)) return; + const macro = state.profile.macros[state.selectedMacro]; + const steps = macro.steps; + if (!steps.length) return; + const duration = steps.reduce((sum, step) => sum + step.duration_ms, 0); + if (macro.playback !== "once" && duration === 0) { + macroNotice("A looping preview requires a nonzero cycle duration.", true); + return; + } + stopMacroPreview("Ready", true); + macroPreview.steps = clone(steps); + macroPreview.duration = duration; + macroPreview.cycles = macro.playback === "once" ? 1 : + macro.playback === "repeat" ? macro.repeat_count : Infinity; + macroPreview.startedAt = performance.now(); + macroPreview.running = true; + elements.macroPreview.dataset.state = "playing"; + elements.macroPreviewProgress.max = Math.max(1, macroPreview.duration * + (macroPreview.cycles === Infinity ? 1 : macroPreview.cycles)); + updateMacroPreviewControls(); + tickMacroPreview(macroPreview.startedAt); +} + +function renderMacro() { + stopMacroPreview("Ready", true); + draggedMacroStep = null; + macroNotice(""); + const controllerStyle = + state.identities[state.identityIndex]?.controller?.style || "generic"; + elements.builtinActions.dataset.controllerStyle = controllerStyle; + elements.macroControls.dataset.controllerStyle = controllerStyle; + const macro = state.profile.macros[state.selectedMacro]; + const budget = macroBudget(); + elements.builtinActions.innerHTML = [ + actionChordCard( + "profile_switch", + "Cycle active profile", + `Advance through profile slots 1–${state.schema.profile_capacity}.`, + state.profile.switching_chord, + state.schema.default_switching_chord + ), + actionChordCard( + "motion_toggle", + "Toggle motion", + "Enable or disable motion for this controller.", + state.profile.motion_toggle_chord, + state.schema.default_motion_toggle_chord + ), + ].join(""); + elements.macroControls.innerHTML = ` +
    +
    + ${state.profile.macros.map((item, index) => ` + `).join("")} +
    +
    + ${budget.steps}/16 shared steps + ${budget.bytes}/136 sparse bytes + +
    +
    + ${actionChordCard( + "custom_macro", + `Run macro ${state.selectedMacro + 1}`, + "Start this editable sequence.", + macro.trigger, + null + )} +
    + + +
    +
    + + +
    +
    + + +
    +
    +

    Once plays one cycle. While held repeats while the entire trigger is held. Toggle repeats until pressed again. Repeat plays the selected cycle count. Cancel ends any active mode.

    +
    `; + + elements.macroControls.querySelectorAll("[data-macro-index]").forEach((button) => { + button.onclick = () => { + if (state.busy || captureBlocking() || !macroInputsValid(true)) return; + state.selectedMacro = Number(button.dataset.macroIndex); + renderCapture(); + renderMacro(); + elements.macroControls.querySelector(`[data-macro-index="${state.selectedMacro}"]`).focus(); + }; + }); + elements.macroPreviewTitle.textContent = `Macro ${state.selectedMacro + 1} draft preview`; + elements.macroPreviewButtons.innerHTML = state.schema.buttons.map((button) => ` + + ${escapeHtml(controlLabel(button, controllerStyle))}Passthrough + `).join(""); + renderMacroSteps(); + updateMacroPlayback(); + updateMacroBudgets(); +} + +function renderMacroSteps() { + const macro = state.profile.macros[state.selectedMacro]; + const controllerStyle = currentControllerStyle(); + + elements.macroSteps.innerHTML = macro.steps.length === 0 + ? '

    No steps yet. Add a state step to build this macro.

    ' + : macro.steps.map((step, index) => { + const overrides = new Set(step.overrides); + const outputButtons = new Set(step.output_buttons); + return ` +
    +
    +
    + + Step ${index + 1} · ${macroStepWireSize(step)} bytes + +
    +
    + + + + + +
    +
    +
    + ${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")} + ${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))} + ${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))} + ${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))} + ${macroNumber(index, "right_stick.y", step.right_stick.y, -32768, 32767, "Right stick Y", !overrides.has("right_stick"))} + ${macroNumber(index, "triggers.left", step.triggers.left, 0, 65535, "Left trigger", !overrides.has("left_trigger"))} + ${macroNumber(index, "triggers.right", step.triggers.right, 0, 65535, "Right trigger", !overrides.has("right_trigger"))} +
    +
    + Fields this step overrides +
    + ${state.schema.macro_overrides.map((name) => ` + `).join("")} +
    +
    +
    + Output buttons +
    + ${state.schema.buttons.map((button) => ` + `).join("")} +
    +
    +
    `; + }).join(""); + elements.macroStepsTitle.textContent = `Macro ${state.selectedMacro + 1} steps`; + elements.addMacroStep.textContent = `Add step to macro ${state.selectedMacro + 1}`; +} + +function renderEditor() { + elements.profileTitle.textContent = + state.profileNames[state.profileIndex] || `Profile ${state.profileIndex + 1}`; + elements.activeBadge.hidden = !state.active; + elements.activate.disabled = + !state.adapterConnected || state.busy || state.active; + elements.profileName.value = state.profileNames[state.profileIndex] || ""; + const owner = currentOwner(); + elements.controllerAlias.value = owner?.alias || ""; + elements.identify.disabled = + state.busy || !state.identifyAvailable; + elements.controllerAlias.disabled = !owner || owner.index === 0; + elements.saveAlias.disabled = + !state.adapterConnected || !owner || owner.index === 0; + elements.controllerDetails.textContent = owner && owner.index !== 0 + ? owner.controller.model + : "Used when no dedicated controller profile exists."; + renderIdentities(); + renderProfileList(); + renderButtonMap(); + renderShortcuts(); + renderShift(); + renderAnalog(); + renderFeedback(); + renderTurbo(); + renderMacro(); + elements.loading.hidden = true; + elements.form.hidden = false; + updateDirtyState(); +} + +async function loadProfile() { + if (captureBlocking()) return; + stopMacroPreview("Preview stopped: profile selection changed."); + clearPlaytest("Loading the selected controller profile."); + setBusy(true); + elements.form.hidden = true; + elements.loading.hidden = false; + renderIdentities(); + renderProfileList(); + try { + const payload = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`); + state.profile = payload.profile; + state.original = canonical(payload.profile); + state.pendingName = false; + state.profileNames = payload.profile_names; + const owner = currentOwner(); + if (owner) owner.alias = payload.alias; + state.active = payload.active; + setConnection("ready", "Adapter connected"); + renderEditor(); + } catch (error) { + state.profile = null; + setConnection("error", "Adapter unavailable"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } finally { + setBusy(false); + } +} + +async function loadLibrary(preserveDraft = false) { + setBusy(true); + try { + const payload = await api("/api/profiles"); + syncLibraryMetadata(payload.identities, !preserveDraft); + if (!preserveDraft || !state.profile) { + await loadProfile(); + } else { + setConnection("ready", "Adapter connected"); + toast("Library refreshed. Your unsaved draft is unchanged."); + } + } catch (error) { + setConnection("error", "Adapter unavailable"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } finally { + setBusy(false); + } +} + +function updateNestedStep(step, path, value) { + const parts = path.split("."); + let target = step; + for (const part of parts.slice(0, -1)) target = target[part]; + target[parts.at(-1)] = value; +} + +function handleFormChange(event) { + const target = event.target; + const kind = target.dataset.kind; + if (!kind || !state.profile || captureBlocking()) return; + stopMacroPreview("Preview stopped: draft edited."); + if (event.type === "change" && kind.startsWith("macro-") && kind !== "macro-selector") return; + if (target.disabled) return; + if (["turbo-setting", "macro-repeat"].includes(kind) && !target.validity.valid) { + updateMacroPreviewControls(); + return; + } + if (kind === "analog-number") { + state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = Number(target.value); + } else if (kind === "analog-bool") { + state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = target.checked; + } else if (kind === "curve-preset") { + if (target.value !== "custom") { + state.profile[target.dataset.group][target.dataset.side].curve_q8_8 = + curvePresets[target.value]; + renderAnalog(); + } + } else if (kind === "curve-range") { + const config = state.profile[target.dataset.group][target.dataset.side]; + config.curve_q8_8 = Number(target.value); + const editor = target.closest(".curve-editor"); + editor.querySelector("output").textContent = target.value; + editor.querySelector(".curve-line").setAttribute( + "d", curvePath(config.curve_q8_8) + ); + editor.querySelector("[data-kind='curve-preset']").value = + curvePreset(config.curve_q8_8); + } else if (kind === "rumble-range") { + state.profile.rumble[target.dataset.field] = Number(target.value); + document.querySelector(`#rumble-${target.dataset.field}-value`).value = target.value; + } else if (kind === "rumble-policy") { + state.profile.rumble.confirmation_policy = target.value; + } else if (kind === "action-chord") { + const fields = { + profile_switch: [state.profile, "switching_chord"], + motion_toggle: [state.profile, "motion_toggle_chord"], + custom_macro: [state.profile.macros[state.selectedMacro], "trigger"], + }; + const [owner, field] = fields[target.dataset.action]; + const defaults = { + profile_switch: state.schema.default_switching_chord, + motion_toggle: state.schema.default_motion_toggle_chord, + }[target.dataset.action]; + const current = owner[field].length === 0 && defaults ? defaults : owner[field]; + const selected = new Set(current); + target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); + owner[field] = state.schema.controls.filter((name) => selected.has(name)); + } else if (kind === "shortcut-modifier") { + state.profile.shortcuts.modifier = target.value || null; + if (!target.value) state.profile.shortcuts.profiles.fill(null); + renderShortcuts(); + elements.shortcutModifier.querySelector("select").focus(); + } else if (kind === "shortcut-selector") { + state.profile.shortcuts.profiles[Number(target.dataset.index)] = target.value || null; + renderShortcuts(); + elements.shortcuts.querySelector(`#shortcut-${target.dataset.index}`).focus(); + } else if (kind === "shift-mode" || kind === "shift-modifier") { + state.profile.shift[kind === "shift-mode" ? "mode" : "modifier"] = target.value || null; + renderShift(); + elements.shift.querySelector(`#${kind}`).focus(); + } else if (kind === "shift-map") { + state.profile.shift.button_map[target.dataset.name] = target.value || null; + } else if (kind === "turbo") { + state.profile.turbo[target.dataset.name] = target.value; + renderTurbo(); + elements.turbo.querySelector(`#turbo-${target.dataset.name}`).focus(); + } else if (kind === "turbo-override") { + const overrides = state.profile.turbo_settings.overrides; + if (target.checked) { + overrides[target.dataset.name] = clone(state.profile.turbo_settings.defaults); + } else { + delete overrides[target.dataset.name]; + } + renderTurbo(); + elements.turbo.querySelector(`[data-kind="turbo-override"][data-name="${target.dataset.name}"]`).focus(); + } else if (kind === "turbo-setting") { + const settings = state.profile.turbo_settings; + const config = target.dataset.name ? settings.overrides[target.dataset.name] : settings.defaults; + config[target.dataset.field] = Number(target.value); + if (!target.dataset.name) { + elements.turbo.querySelectorAll(`[data-kind="turbo-setting"][data-field="${target.dataset.field}"]`).forEach((input) => { + if (!settings.overrides[input.dataset.name]) input.value = target.value; + }); + } + updateTurboTiming(); + } else if (kind === "macro-selector") { + state.profile.macros[state.selectedMacro][target.dataset.field] = target.value || null; + } else if (kind === "macro-playback" || kind === "macro-repeat") { + state.profile.macros[state.selectedMacro][kind === "macro-playback" ? "playback" : "repeat_count"] = + kind === "macro-playback" ? target.value : Number(target.value); + updateMacroPlayback(); + } else if (kind === "macro-number") { + if (!target.validity.valid) { + macroNotice("Enter a whole number within the displayed limits. The last valid draft value is retained.", true); + updateMacroPreviewControls(); + return; + } + updateNestedStep(state.profile.macros[state.selectedMacro].steps[Number(target.dataset.index)], target.dataset.field, Number(target.value)); + } else if (kind === "macro-output") { + const step = state.profile.macros[state.selectedMacro].steps[Number(target.dataset.index)]; + const selected = new Set(step.output_buttons); + target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); + step.output_buttons = state.schema.buttons.filter((name) => selected.has(name)); + } else if (kind === "macro-override") { + const index = Number(target.dataset.index); + const macro = state.profile.macros[state.selectedMacro]; + const step = clone(macro.steps[index]); + const selected = new Set(step.overrides); + target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); + step.overrides = state.schema.macro_overrides.filter((name) => selected.has(name)); + if (!target.checked) { + if (target.dataset.name === "buttons") step.output_buttons = []; + if (target.dataset.name === "left_stick") step.left_stick = { x: 0, y: 0 }; + if (target.dataset.name === "right_stick") step.right_stick = { x: 0, y: 0 }; + if (target.dataset.name === "left_trigger") step.triggers.left = 0; + if (target.dataset.name === "right_trigger") step.triggers.right = 0; + } + const macros = state.profile.macros.map((item, macroIndex) => + macroIndex === state.selectedMacro + ? { ...item, steps: item.steps.map((itemStep, stepIndex) => stepIndex === index ? step : itemStep) } + : item + ); + const error = macroBudgetError(macros); + if (error) { + target.checked = macro.steps[index].overrides.includes(target.dataset.name); + macroNotice(`${error} Draft unchanged.`, true); + return; + } + macro.steps[index] = step; + const card = target.closest("[data-step-index]"); + card.querySelectorAll("[data-kind='macro-number']").forEach((input) => { + const [group, side] = input.dataset.field.split("."); + if (!side) return; + const override = group === "triggers" ? `${side}_trigger` : group; + if (override !== target.dataset.name) return; + input.disabled = !selected.has(override); + input.value = step[group][side]; + }); + card.querySelectorAll("[data-kind='macro-output']").forEach((input) => { + if (target.dataset.name !== "buttons") return; + input.disabled = !selected.has("buttons"); + input.checked = step.output_buttons.includes(input.dataset.name); + }); + } + if (kind.startsWith("macro-")) { + macroNotice(""); + updateMacroBudgets(); + } + updateDirtyState(); +} + +elements.analog.addEventListener("click", (event) => { + const button = event.target.closest("[data-copy-analog]"); + if (!button) return; + stopMacroPreview("Preview stopped: draft edited."); + const group = button.dataset.copyAnalog; + const source = button.dataset.sourceSide; + const destination = source === "left" ? "right" : "left"; + state.profile[group][destination] = clone(state.profile[group][source]); + renderAnalog(); + updateDirtyState(); + toast(`${label(source)} ${label(group)} settings applied to ${destination}.`); +}); + +elements.form.addEventListener("input", handleFormChange); +elements.form.addEventListener("change", handleFormChange); + +elements.identity.addEventListener("change", async () => { + const previous = state.identityIndex; + if (captureBlocking() || !confirmDiscard()) { + elements.identity.value = String(previous); + return; + } + state.identityIndex = Number(elements.identity.value); + const owner = currentOwner(); + if (owner) persistOwnerKey(owner.key); + state.profileIndex = 0; + state.profileNames = Array(state.schema.profile_capacity).fill(""); + await loadProfile(); +}); + +elements.refresh.addEventListener("click", async () => { + await loadLibrary(true); +}); + +elements.resetDraft.addEventListener("click", () => { + if (!state.schema || !window.confirm("Replace this draft with the default profile? Nothing is saved until you choose Save to Pico.")) return; + state.profile = clone(state.schema.default_profile); + renderEditor(); + toast("Default profile loaded into the draft."); +}); + +elements.addMacroStep.addEventListener("click", () => { + const index = state.profile.macros[state.selectedMacro].steps.length; + if (mutateMacroSteps((steps) => steps.push(defaultMacroStep()), index)) { + macroNotice(`Added step ${index + 1}.`); + } +}); + +function moveMacroStep(from, to, focusSelector = "[data-step-handle]") { + const count = state.profile.macros[state.selectedMacro].steps.length; + if (from === to || from < 0 || to < 0 || from >= count || to >= count) return; + if (mutateMacroSteps((steps) => { + const [step] = steps.splice(from, 1); + steps.splice(to, 0, step); + }, to, focusSelector)) { + macroNotice(`Moved step ${from + 1} to position ${to + 1}.`); + } +} + +elements.macroSteps.addEventListener("click", (event) => { + const button = event.target.closest("[data-step-action]"); + if (!button || button.disabled) return; + const index = Number(button.closest("[data-step-index]").dataset.stepIndex); + const action = button.dataset.stepAction; + if (action === "up" || action === "down") { + moveMacroStep(index, index + (action === "up" ? -1 : 1)); + return; + } + const focusIndex = action === "duplicate" ? index + 1 : index; + if (mutateMacroSteps((steps) => { + if (action === "insert") steps.splice(index, 0, defaultMacroStep()); + else if (action === "duplicate") steps.splice(index + 1, 0, clone(steps[index])); + else if (action === "remove") steps.splice(index, 1); + }, focusIndex)) { + macroNotice(action === "remove" ? `Removed step ${index + 1}.` : + action === "insert" ? `Inserted step ${index + 1}.` : `Duplicated step ${index + 1} into position ${index + 2}.`); + } +}); + +elements.macroSteps.addEventListener("keydown", (event) => { + const handle = event.target.closest("[data-step-handle]"); + if (!handle || !event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return; + event.preventDefault(); + const index = Number(handle.dataset.stepHandle); + moveMacroStep(index, index + (event.key === "ArrowUp" ? -1 : 1)); +}); + +function clearMacroDrag() { + draggedMacroStep = null; + elements.macroSteps.querySelectorAll(".dragging, .drop-before, .drop-after").forEach((card) => { + card.classList.remove("dragging", "drop-before", "drop-after"); + }); +} + +elements.macroSteps.addEventListener("dragstart", (event) => { + const handle = event.target.closest("[data-step-handle]"); + if (!handle || state.busy || !macroInputsValid(true)) { + event.preventDefault(); + return; + } + stopMacroPreview("Preview stopped: reordering steps."); + draggedMacroStep = Number(handle.dataset.stepHandle); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(draggedMacroStep)); + handle.closest("[data-step-index]").classList.add("dragging"); +}); + +elements.macroSteps.addEventListener("dragover", (event) => { + const card = event.target.closest("[data-step-index]"); + if (draggedMacroStep === null || !card) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + elements.macroSteps.querySelectorAll(".drop-before, .drop-after") + .forEach((item) => item.classList.remove("drop-before", "drop-after")); + const bounds = card.getBoundingClientRect(); + card.classList.add(event.clientY < bounds.top + bounds.height / 2 ? "drop-before" : "drop-after"); +}); + +elements.macroSteps.addEventListener("drop", (event) => { + const card = event.target.closest("[data-step-index]"); + if (draggedMacroStep === null || !card) return; + event.preventDefault(); + const from = draggedMacroStep; + const bounds = card.getBoundingClientRect(); + const gap = Number(card.dataset.stepIndex) + (event.clientY >= bounds.top + bounds.height / 2 ? 1 : 0); + clearMacroDrag(); + moveMacroStep(from, gap > from ? gap - 1 : gap); +}); + +elements.macroSteps.addEventListener("dragend", clearMacroDrag); +elements.macroPreviewPlay.addEventListener("click", playMacroPreview); +elements.macroPreviewRestart.addEventListener("click", playMacroPreview); +elements.macroPreviewStop.addEventListener("click", () => stopMacroPreview("Preview stopped.")); +elements.captureRecord.addEventListener("click", beginCapture); +elements.captureStop.addEventListener("click", () => stopCapture()); +elements.captureUse.addEventListener("click", useCapture); +elements.captureDiscard.addEventListener("click", discardCapture); +elements.captureRecover.addEventListener("click", () => { + const session = macroCapture.session; + if (!session || macroCapture.requestActive) return; + session.stopRequested = true; + session.stopAttempted = false; + refreshCapture(); +}); +elements.captureOptions.addEventListener("input", renderCapture); +elements.captureOptions.addEventListener("change", renderCapture); +window.addEventListener("pagehide", () => stopCapture("Recording stopped because you left the editor.")); +window.addEventListener("hashchange", () => stopCapture("Recording stopped because you navigated to another section.")); +document.querySelector("#macroCaptureSettings").addEventListener("submit", (event) => { + event.preventDefault(); +}); +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopMacroPreview("Preview stopped: tab hidden."); + clearMacroDrag(); + stopCapture("Recording stopped because the tab was hidden."); + } + updateMacroPreviewControls(); + renderCapture(); +}); +elements.form.addEventListener("input", () => { + stopMacroPreview("Preview stopped: draft edited."); +}); +elements.form.addEventListener("change", () => { + stopMacroPreview("Preview stopped: draft edited."); +}); + +elements.saveProfileName.addEventListener("click", async () => { + setBusy(true); + try { + const value = elements.profileName.value.trim(); + const result = await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + } + ); + state.profileNames[state.profileIndex] = value; + state.pendingName = false; + elements.profileTitle.textContent = value || `Profile ${state.profileIndex + 1}`; + renderProfileList(); + updateDirtyState(); + toast(`Profile name saved · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.saveAlias.addEventListener("click", async () => { + const owner = currentOwner(); + if (!owner) return; + setBusy(true); + try { + const value = elements.controllerAlias.value.trim(); + const result = await api(`/api/identities/${owner.index}/alias`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + owner.alias = value; + owner.label = result.label; + renderIdentities(); + toast(`Controller alias saved · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.identify.addEventListener("click", async () => { + const owner = currentOwner(); + if (!owner || owner.index === 0) return; + setBusy(true); + try { + await api(`/api/identities/${owner.index}/identify`, { method: "POST" }); + toast("Identification pulse sent."); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.exportProfile.addEventListener("click", () => { + const name = state.profileNames[state.profileIndex] || ""; + const filename = name || `profile-${state.profileIndex + 1}`; + const blob = new Blob([ + JSON.stringify({ name, profile: state.profile }, null, 2), + ], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `${filename.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.json`; + link.click(); + URL.revokeObjectURL(link.href); +}); + +elements.importProfile.addEventListener("click", () => { + elements.importProfileFile.click(); +}); + +elements.importProfileFile.addEventListener("change", async () => { + const [file] = elements.importProfileFile.files; + if (!file) return; + setBusy(true); + try { + const imported = JSON.parse(await file.text()); + if ( + typeof imported.name === "string" && + new TextEncoder().encode(imported.name).length > 31 + ) { + throw new Error("Profile name exceeds 31 UTF-8 bytes"); + } + const result = await api("/api/profiles/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(imported.profile || imported), + }); + state.profile = result.profile; + if (typeof imported.name === "string") { + state.profileNames[state.profileIndex] = imported.name; + state.pendingName = true; + } + renderEditor(); + toast("Profile imported into the unsaved draft."); + } catch (error) { + toast(`Import failed: ${error.message}`, true); + } finally { + elements.importProfileFile.value = ""; + setBusy(false); + } +}); + +elements.copyProfile.addEventListener("click", () => { + if (!elements.form.reportValidity()) return; + elements.copyIdentity.innerHTML = state.identities.map((identity) => ( + `` + )).join(""); + elements.copyIdentity.value = String(state.identityIndex); + elements.copySlot.innerHTML = Array.from( + { length: state.schema.profile_capacity }, + (_, index) => `` + ).join(""); + elements.copySlot.value = String( + Math.min(state.schema.profile_capacity, state.profileIndex + 2) + ); + elements.copyDialog.showModal(); +}); + +elements.confirmCopy.addEventListener("click", async (event) => { + event.preventDefault(); + if (!elements.form.reportValidity()) return; + setBusy(true); + try { + const result = await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/copy`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity_index: Number(elements.copyIdentity.value), + profile_number: Number(elements.copySlot.value), + profile: state.profile, + name: state.profileNames[state.profileIndex], + }), + } + ); + if (Number(elements.copyIdentity.value) === state.identityIndex) { + state.profileNames[Number(elements.copySlot.value) - 1] = + state.profileNames[state.profileIndex]; + renderProfileList(); + } + elements.copyDialog.close(); + toast(`Profile copied · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +document.querySelectorAll("[data-reset-section]").forEach((button) => { + button.addEventListener("click", () => { + const defaults = state.schema.default_profile; + const section = button.dataset.resetSection; + if (section === "mapping") { + state.profile.button_map = clone(defaults.button_map); + state.profile.triggers.left.output = defaults.triggers.left.output; + state.profile.triggers.right.output = defaults.triggers.right.output; + } else if (section === "analog") { + state.profile.sticks = clone(defaults.sticks); + const leftOutput = state.profile.triggers.left.output; + const rightOutput = state.profile.triggers.right.output; + state.profile.triggers = clone(defaults.triggers); + state.profile.triggers.left.output = leftOutput; + state.profile.triggers.right.output = rightOutput; + } else if (section === "feedback") { + state.profile.rumble = clone(defaults.rumble); + } else if (section === "turbo") { + state.profile.turbo = clone(defaults.turbo); + state.profile.turbo_settings = clone(defaults.turbo_settings); + } else if (section === "shortcuts" || section === "shift") { + state.profile[section] = clone(defaults[section]); + } else if (section === "macro") { + state.profile.switching_chord = clone(defaults.switching_chord); + state.profile.motion_toggle_chord = clone(defaults.motion_toggle_chord); + state.profile.macros = clone(defaults.macros); + } + renderEditor(); + toast(`${label(section)} reset in the unsaved draft.`); + }); +}); + +elements.save.addEventListener("click", async () => { + if (!elements.form.reportValidity()) return; + const error = macroBudgetError(state.profile.macros); + if (error) { + macroNotice(error, true); + toast(error, true); + return; + } + setBusy(true); + try { + const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state.profile), + }); + if (state.pendingName) { + await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + value: state.profileNames[state.profileIndex], + }), + } + ); + state.pendingName = false; + } + state.original = canonical(state.profile); + updateDirtyState(); + toast(`Saved atomically · generation ${result.stored_generation} · CRC ${result.stored_crc}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.activate.addEventListener("click", async () => { + if (isDirty()) { + toast("Save or discard the draft before activating this profile.", true); + return; + } + setBusy(true); + try { + const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/activate`, { method: "POST" }); + state.identities[state.identityIndex].active_profile = state.profileIndex + 1; + state.active = true; + renderEditor(); + toast(`Profile ${state.profileIndex + 1} is active · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +window.addEventListener("beforeunload", (event) => { + stopMacroPreview("Preview stopped: leaving the editor."); + stopCapture("Recording stopped because you left the editor."); + if (!isDirty() && !macroCapture.session) return; + event.preventDefault(); + event.returnValue = ""; +}); + +async function start() { + try { + const schema = await api("/api/schema"); + state.schema = schema; + state.token = schema.mutation_token; + await loadLibrary(); + pollPlaytest(); + pollLibraryMetadata(); + } catch (error) { + setConnection("error", "Editor failed to start"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } +} + +start(); diff --git a/src/switch_pico_bridge/web/profile_playtest.js b/src/switch_pico_bridge/web/profile_playtest.js new file mode 100644 index 0000000..e9d6357 --- /dev/null +++ b/src/switch_pico_bridge/web/profile_playtest.js @@ -0,0 +1,112 @@ +"use strict"; + +(function publishProfilePlaytestMath(root) { + function applyCurveQ16(input, curve) { + if (input === 0 || input === 65536 || curve === 256) return input; + const denominator = curve * (65536 - input) + 256 * input; + return Math.min( + 65536, + Math.floor( + (input * 256 * 65536 + Math.floor(denominator / 2)) / + denominator + ) + ); + } + + function transformStickAxis(adjusted, magnitude, response, invert) { + if (adjusted === 0 || magnitude === 0 || response === 0) return 0; + const negative = (adjusted < 0) !== invert; + const limit = negative ? 32768 : 32767; + const denominator = magnitude * 65536; + const output = Math.min( + limit, + Math.floor( + ( + Math.abs(adjusted) * limit * response + + Math.floor(denominator / 2) + ) / denominator + ) + ); + return negative ? -output : output; + } + + function transformStick(input, config) { + if ( + config.center_x === 0 && config.center_y === 0 && + config.inner_deadzone === 0 && config.outer_saturation === 32767 && + config.curve_q8_8 === 256 && !config.invert_x && !config.invert_y + ) { + return { x: input.x, y: input.y }; + } + const x = Math.max(-32768, Math.min(32767, input.x - config.center_x)); + const y = Math.max(-32768, Math.min(32767, input.y - config.center_y)); + if (config.outer_saturation <= config.inner_deadzone) { + return { x: 0, y: 0 }; + } + const magnitude = Math.max(Math.abs(x), Math.abs(y)); + let response = 0; + if (magnitude >= config.outer_saturation) { + response = 65536; + } else if (magnitude > config.inner_deadzone) { + const range = config.outer_saturation - config.inner_deadzone; + const normalized = Math.floor( + ( + (magnitude - config.inner_deadzone) * 65536 + + Math.floor(range / 2) + ) / range + ); + response = applyCurveQ16(normalized, config.curve_q8_8); + } + return { + x: transformStickAxis(x, magnitude, response, config.invert_x), + y: transformStickAxis(y, magnitude, response, config.invert_y), + }; + } + + function transformTrigger(input, config) { + if ( + config.lower_deadzone === 0 && + config.upper_saturation === 65535 && + config.curve_q8_8 === 256 + ) return input; + if (config.upper_saturation <= config.lower_deadzone) return 0; + if (input <= config.lower_deadzone) return 0; + if (input >= config.upper_saturation) return 65535; + const range = config.upper_saturation - config.lower_deadzone; + const offset = input - config.lower_deadzone; + if (config.curve_q8_8 === 256) { + return Math.floor( + (offset * 65535 + Math.floor(range / 2)) / range + ); + } + const normalized = Math.floor( + (offset * 65536 + Math.floor(range / 2)) / range + ); + return Math.floor( + (applyCurveQ16(normalized, config.curve_q8_8) * 65535 + 32768) / + 65536 + ); + } + + function stickPosition(value) { + return Math.max(3, Math.min(97, 50 + value / 32768 * 47)); + } + + function stickCoordinates(input) { + return { + left: stickPosition(input.x), + top: stickPosition(input.y), + }; + } + + function triggerPercent(value) { + return Math.max(0, Math.min(100, value / 65535 * 100)); + } + + root.ProfilePlaytestMath = Object.freeze({ + transformStick, + transformTrigger, + stickCoordinates, + triggerPercent, + }); +})(globalThis); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 2933d48..7926519 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -2,9 +2,15 @@ #include #include #include +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include +#include +#include +#endif #include #include "platform/pico/controller_color_config.h" +#include "input/switch2_wake.h" namespace { @@ -44,6 +50,9 @@ 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 switch2_wake_initializations = 0; +int switch2_wake_requests = 0; +bool switch2_connections_ready = true; int device_disconnect_calls = 0; uni_hid_device_t* last_disconnected_device = nullptr; uni_hid_device_t* lookup_devices[8]{}; @@ -429,7 +438,9 @@ void cyw43_arch_gpio_put(int, bool enabled) { ++observed_status_led_writes; } -void multicore_launch_core1(void (*)()) { +void multicore_launch_core1_with_stack(void (*)(), uint32_t* stack, size_t size) { + require(stack != nullptr && size % 8 == 0, + "core 1 launch needs an aligned bounded stack"); ++core1_launch_calls; } @@ -441,10 +452,80 @@ uint32_t btstack_run_loop_get_time_ms() { return now_ms; } +uint32_t time_us_32() { + return now_ms * 1000u; +} + + +void switch2_wake_initialize() { + ++switch2_wake_initializations; +} +bool switch2_wake_ready_for_connections() { + return switch2_connections_ready; +} + + + +bool switch2_wake_request() { + ++switch2_wake_requests; + return true; +} + +void switch2_wake_diagnostics(Switch2WakeDiagnostics*) { +} #include "core/controller_identity.cpp" #include "input/bluepad32_input_backend.cpp" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +namespace { +std::vector native_timers; +std::array last_native_packet{}; +uint16_t last_native_cid = 0; +} + +void native_test_add_timer(btstack_timer_source_t* timer) { + btstack_run_loop_remove_timer(timer); + timer->due_ms = uint64_t{now_ms} + timer->timeout_ms + 1; + native_timers.push_back(timer); +} + +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + const auto found = std::find(native_timers.begin(), native_timers.end(), timer); + if (found == native_timers.end()) return 0; + native_timers.erase(found); + return 1; +} + +uint64_t time_us_64() { return uint64_t{now_ms} * 1000; } +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; } +uint8_t l2cap_request_can_send_now_event(uint16_t cid) { + for (const auto& slot : g_slots) { + if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) { + require(haptics_experiment_on_can_send_now(slot.device, cid), + "native send permission was not consumed"); + return ERROR_CODE_SUCCESS; + } + } + require(false, "native permission targeted a detached connection"); + return 1; +} + +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { + require(size == last_native_packet.size(), "native report size changed"); + std::copy(data, data + size, last_native_packet.begin()); + last_native_cid = cid; + return ERROR_CODE_SUCCESS; +} + +void haptics_transport_probe_prepare() {} +void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {} +void haptics_transport_probe_end() {} +void haptics_transport_probe_timer(uint32_t) {} +void haptics_transport_probe_permission(uint32_t) {} +void haptics_transport_probe_send(uint32_t, uint32_t, bool) {} +#endif + namespace { void require_clear_completion_pending() { if (expected_pending_clear_token != 0) { @@ -548,7 +629,8 @@ void start_backend() { kClassicLinkSupervisionTimeout && !bondable && accepted_stk_methods == 0 && !ssp_auto_accept && pairing_event_handler != nullptr && - identity_event_handler != nullptr, + identity_event_handler != nullptr && + switch2_wake_initializations == 1, "initialization must register Classic and BLE identity policy"); } void start_pairing_backend() { @@ -767,6 +849,19 @@ void test_rejections() { "valid device must occupy its indexed slot"); require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS, "different device cannot replace an occupied slot"); + g_slots[0].identity.stable = true; + g_slots[0].identity.transport = ControllerTransport::kClassic; + g_slots[0].identity.address[5] = 1; + Bluepad32SlotSnapshot identity_snapshot{}; + bluepad32_input_backend_snapshot(0, &identity_snapshot); + require(bluepad32_input_backend_identify( + identity_snapshot.identity) && + g_slots[0].pending_profile_feedback_count == 1 && + !bluepad32_input_backend_identify( + controller_identity_global()), + "Identify did not target only the selected live controller"); + g_slots[0].pending_profile_feedback_count = 0; + g_slots[0].pending_profile_feedback[0] = {}; uni_controller_t collision_data{}; collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; @@ -785,6 +880,22 @@ void test_rejections() { require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == 3, "valid slot input must remain observable"); + slot_zero.controller.battery = 201; + g_last_snapshot_generation[0] = 0; + Bluepad32PlaytestSnapshot playtest{}; + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.active && playtest.state_generation != 0 && + playtest.state.motion_sample_count == 3 && + playtest.battery == 201 && + (playtest.capabilities & 0x08u) != 0, + "playtest snapshot did not expose input capabilities"); + bluepad32_input_backend_report_sent(0); + require(read_controller_state(0, &snapshot) && + snapshot.motion_sample_count == 3, + "playtest snapshot consumed report-path motion"); + bluepad32_input_backend_playtest_snapshot(4, &playtest); + require(!playtest.active && playtest.state_generation == 0, + "playtest snapshot accepted slot 4"); require(!read_controller_state(4, &snapshot), "public snapshot must reject slot 4"); bluepad32_input_backend_report_sent(4); @@ -1970,13 +2081,22 @@ void test_stateful_host_rumble_restore() { #endif } -void test_motion_hotkey() { +void test_motion_toggle_action() { 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, - "motion hotkey test controllers did not become ready"); + "motion action test controllers did not become ready"); + + Bluepad32SlotSnapshot backend_snapshot{}; + bluepad32_input_backend_snapshot(0, &backend_snapshot); + require(!bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation + 1u), + "stale connection toggled motion"); + require(bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "live connection did not toggle motion"); uni_controller_t input{}; input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; @@ -1985,24 +2105,8 @@ void test_motion_hotkey() { ControllerState snapshot{}; require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == - (kDefaultMotionEnabled ? 3 : 0), - "slot 0 did not start with configured motion state"); - - input.gamepad.dpad = kMotionHotkeyDpadMask; - input.gamepad.buttons = kMotionHotkeyButtonMask; - input.gamepad.misc_buttons = kMotionHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - 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 was not published pre-hotkey or suppressed " - "from normal output"); + (kDefaultMotionEnabled ? 0 : 3), + "motion toggle did not change slot motion publication"); process_rumble_timer(&g_rumble_timer); require(slot_zero.rumble_calls == 1 && @@ -2020,12 +2124,6 @@ void test_motion_hotkey() { : kMotionEnabledFeedbackStrongMagnitude), "motion toggle did not send distinct state feedback"); - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].motion_enabled == !kDefaultMotionEnabled && - slot_zero.rumble_calls == 1, - "held motion chord toggled or rumbled more than once"); - uni_controller_t peer_input{}; peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; peer_input.gamepad.accel[0] = 8192; @@ -2033,32 +2131,27 @@ void test_motion_hotkey() { require(read_controller_state(1, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), - "slot 0 motion chord changed slot 1 motion state"); + "slot 0 motion action changed slot 1 motion state"); - input.gamepad = {}; - platform_on_controller_data(&slot_zero, &input); - input.gamepad.accel[0] = 8192; - input.gamepad.dpad = kMotionHotkeyDpadMask; - input.gamepad.buttons = kMotionHotkeyButtonMask; - input.gamepad.misc_buttons = kMotionHotkeyMiscMask; + require(bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "second live motion toggle failed"); platform_on_controller_data(&slot_zero, &input); require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), - "released motion chord did not re-arm or restore motion"); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].motion_enabled == kDefaultMotionEnabled && - slot_zero.rumble_calls == 2, - "second motion chord did not restore configured state"); + "second motion toggle did not restore configured state"); platform_on_device_disconnected(&slot_zero); + require(!bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "disconnected generation toggled motion"); uni_hid_device_t replacement = device(0); 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"); + "disconnect did not reset slot 0 motion action state"); } void test_protocol_neutral_analog_state() { @@ -2154,6 +2247,16 @@ void test_protocol_neutral_analog_state() { peer_state.left_trigger == 16399 && peer_state.right_trigger == 49199, "slot 0 digital trigger fallback changed slot 1"); + input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE; + platform_on_controller_data(&controller, &input); + require(read_controller_state(0, &state) && + state.button_capture, + "touchpad/capture input did not reach the logical capture button"); + input.gamepad.misc_buttons = 0; + platform_on_controller_data(&controller, &input); + require(read_controller_state(0, &state) && + !state.button_capture, + "released touchpad/capture input remained pressed"); } void test_host_rumble_mode_duration() { @@ -2352,6 +2455,71 @@ void test_flash_core_start_contract() { "backend start must remain idempotent"); } + + +void test_wake_identity_gates_connections() { + switch2_connections_ready = false; + bluepad32_input_backend_init(); + platform_on_init_complete(); + require(switch2_wake_initializations == 1 && + g_connection_policy_state == + ConnectionPolicyState::Uninitialized && + scan_starts == 0 && classic_scan_starts == 0 && + !incoming_connections, + "controller discovery started before wake identity was ready"); + + switch2_connections_ready = true; + process_rumble_timer(&g_rumble_timer); + require(g_connection_policy_state == ConnectionPolicyState::Open && + scan_starts == 1 && classic_scan_starts == 1 && + incoming_connections, + "controller discovery did not start after wake identity setup"); +} + + +void test_system_button_wake_trigger() { + start_pairing_backend(); + uni_hid_device_t controller = device(0); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "wake trigger controller did not become ready"); + + uni_controller_t input{}; + input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 0, + "neutral input requested a wake burst"); + + input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 0, + "plain system button requested a wake burst"); + + input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + platform_on_controller_data(&controller, &input); + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 1, + "held L+R+System chord did not produce exactly one wake request"); + + input.gamepad.buttons = 0; + input.gamepad.misc_buttons = 0; + platform_on_controller_data(&controller, &input); + input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 1, + "L+R without System requested wake"); + input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 2, + "second L+R+System chord edge did not request wake"); + + input.gamepad.buttons = 0; + input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE; + platform_on_controller_data(&controller, &input); + require(switch2_wake_requests == 2, + "non-system misc button requested wake"); +} + + void test_flash_core_init_fatal() { bluepad32_input_backend_init(); flash_core_init_result = false; @@ -2368,11 +2536,220 @@ void test_flash_core_init_fatal() { "flash-safe Core1 init failure must halt before CYW43 init"); } +#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES) +void advance_native_backend(uint32_t duration_ms) { + const uint32_t end_ms = now_ms + duration_ms; + while (now_ms < end_ms) { + ++now_ms; + for (;;) { + const auto due = std::find_if( + native_timers.begin(), native_timers.end(), + [](const auto* timer) { + return timer != &g_rumble_timer && + timer != &g_configuration_timer && + timer->due_ms <= now_ms; + }); + if (due == native_timers.end()) break; + auto* timer = *due; + native_timers.erase(due); + timer->handler(timer); + } + if (now_ms % kRumblePollIntervalMs == 0) + process_rumble_timer(&g_rumble_timer); + } +} + +void require_native_channels(bool left, bool right) { + HapticsExperimentDiagnostics status; + haptics_experiment_snapshot(&status); + require(status.state == HapticsExperimentState::kRunning && + status.mode == 1, + "stateful host rumble lost native gameplay ownership"); + unsigned active[2]{}; + for (unsigned frame = 0; frame < status.packet_frames; ++frame) { + active[0] += last_native_packet[10 + frame * 2] != 0; + active[1] += last_native_packet[11 + frame * 2] != 0; + } + require((left ? active[0] > status.packet_frames / 2u : active[0] == 0) && + (right ? active[1] > status.packet_frames / 2u : active[1] == 0), + "native PCM did not preserve the requested stateful channels"); +} + +void test_native_stateful_routing() { + start_pairing_backend(); + test_adapter_mode = AdapterUsbMode::kXInput; + auto selected = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + selected.vendor_id = 0x054c; + selected.product_id = 0x0ce6; + selected.conn.connected = true; + selected.conn.interrupt_cid = 0x80; + selected.outgoing_buffer.queued = 1; + require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "selected DualSense was rejected"); + process_rumble_timer(&g_rumble_timer); + HapticsExperimentDiagnostics status; + haptics_experiment_snapshot(&status); + require(status.mode == 1 && status.state == HapticsExperimentState::kPending, + "XInput DualSense did not auto-arm into native Prepare"); + const uint32_t generation = g_slots[0].connection_generation; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{180, 0}); + advance_native_backend(70); + const int prepare_calls = selected.rumble_calls; + selected.outgoing_buffer.queued = 0; + advance_native_backend(350); + require_native_channels(true, false); + require(selected.rumble_calls == prepare_calls && + last_native_cid == selected.conn.interrupt_cid, + "XInput interleaved compatibility rumble into native PCM"); + + auto unselected = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + unselected.vendor_id = selected.vendor_id; + unselected.product_id = selected.product_id; + unselected.conn.connected = true; + unselected.conn.interrupt_cid = 0x82; + require(platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS, + "unselected DualSense was rejected"); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{22, 33}); + advance_native_backend(10); + require(unselected.rumble_calls == 1 && unselected.last_low == 22 && + unselected.last_high == 33, + "unselected controller lost compatibility fallback"); + require(!haptics_experiment_submit_rumble(1, generation, time_us_64(), 255, 255), + "native stream accepted an unselected slot"); + + bluepad32_input_backend_queue_profile_feedback( + 0, generation, 1, ControllerProfileConfirmationPolicy::kRumble); + advance_native_backend(40); + require_native_channels(true, true); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 170}); + advance_native_backend(220); + require_native_channels(false, true); + require(selected.rumble_calls == prepare_calls, + "profile feedback escaped the native overlay"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 0}); + advance_native_backend(80); + require_native_channels(false, false); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{150, 0}); + advance_native_backend(80); + require(haptics_experiment_request(0, 0), "native stop was rejected"); + advance_native_backend(20); + require(!haptics_experiment_owns(&selected) && selected.last_low == 150 && + selected.last_high == 0 && + selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs, + "explicit native stop lost the latest compatibility fallback"); + require(haptics_experiment_request(2, 0), "manual gameplay rearm failed"); + advance_native_backend(300); + require_native_channels(true, false); + const int rearm_calls = selected.rumble_calls; + advance_native_backend(300); + require(selected.rumble_calls == rearm_calls, + "held XInput state required periodic compatibility refresh"); + + require(haptics_experiment_request(0, 0), "second native stop failed"); + advance_native_backend(20); + require(selected.last_low == 150 && selected.last_high == 0 && + selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs, + "manual rearm discarded held state needed by the next Stop"); + require(haptics_experiment_request(1, 0), "fixture start failed"); + advance_native_backend(10); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 160}); + advance_native_backend(6200); + require(haptics_experiment_request(2, 0), "post-fixture gameplay arm failed"); + advance_native_backend(300); + require_native_channels(false, true); + + // A real HD frame replaces stateful XInput semantics and keeps its watchdog. + test_adapter_mode = AdapterUsbMode::kSwitchProbe; + ControllerRumbleOutput hd{}; + hd.hd.actuators[0].sample_count = 1; + hd.hd.actuators[1].sample_count = 1; + hd.hd.actuators[0].samples[0].low_amplitude_q15 = 20000; + hd.hd.actuators[0].samples[0].low_frequency_index = 64; + bluepad32_input_backend_queue_rumble(0, hd); + advance_native_backend(40); + require_native_channels(true, false); + advance_native_backend(100); + require_native_channels(false, false); + + test_adapter_mode = AdapterUsbMode::kXInput; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{255, 0}); + platform_on_device_disconnected(&selected); + require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255), + "disconnected generation remained eligible for native output"); + require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "replacement DualSense was rejected"); + advance_native_backend(300); + require_native_channels(false, false); + require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255), + "old generation reached a replacement native stream"); + require(haptics_experiment_request(0, 0), "replacement stop failed"); + advance_native_backend(20); + platform_on_device_disconnected(&selected); + platform_on_device_disconnected(&unselected); +} + +void test_native_second_slot_selection() { + start_pairing_backend(); + test_adapter_mode = AdapterUsbMode::kXInput; + auto pro = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + pro.vendor_id = 0x057e; + pro.product_id = 0x2009; + pro.conn.connected = true; + pro.conn.interrupt_cid = 0x80; + require(platform_on_device_ready(&pro) == UNI_ERROR_SUCCESS, + "first-slot Switch Pro was rejected"); + auto dualsense = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + dualsense.vendor_id = 0x054c; + dualsense.product_id = 0x0ce6; + dualsense.conn.connected = true; + dualsense.conn.interrupt_cid = 0x82; + require(platform_on_device_ready(&dualsense) == UNI_ERROR_SUCCESS, + "second-slot DualSense was rejected"); + advance_native_backend(30); + HapticsExperimentDiagnostics status; + haptics_experiment_snapshot(&status); + require(status.state == HapticsExperimentState::kRunning && status.slot == 1, + "native auto-arm ignored the first eligible DualSense outside slot zero"); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{90, 0}); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{20, 0}); + advance_native_backend(200); + require_native_channels(true, false); + require(pro.rumble_calls == 1 && last_native_cid == 0x82, + "mixed controller slots lost their independent rumble paths"); + auto later = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + later.vendor_id = dualsense.vendor_id; + later.product_id = dualsense.product_id; + later.conn.connected = true; + later.conn.interrupt_cid = 0x84; + require(platform_on_device_ready(&later) == UNI_ERROR_SUCCESS, + "later DualSense was rejected"); + advance_native_backend(30); + haptics_experiment_snapshot(&status); + require(status.slot == 1 && last_native_cid == 0x82, + "a later DualSense stole the selected native stream"); + require(haptics_experiment_request(0, 1), "selected stream did not stop"); + advance_native_backend(20); + platform_on_device_disconnected(&dualsense); + platform_on_device_disconnected(&pro); + platform_on_device_disconnected(&later); +} +#endif + } // namespace int main(int argc, char** argv) { require(argc == 2, "scenario argument required"); const std::string scenario = argv[1]; +#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES) + if (scenario == "native-stateful") { + test_native_stateful_routing(); + return 0; + } + if (scenario == "native-second-slot") { + test_native_second_slot_selection(); + return 0; + } +#endif if (scenario == "ready-forward") { test_ready_order(false); } else if (scenario == "ready-reverse") { @@ -2392,7 +2769,7 @@ int main(int argc, char** argv) { } else if (scenario == "profile-feedback") { test_profile_feedback_scheduler(); } else if (scenario == "motion-hotkey") { - test_motion_hotkey(); + test_motion_toggle_action(); } else if (scenario == "analog-state") { test_protocol_neutral_analog_state(); } else if (scenario == "rumble-mode") { @@ -2403,6 +2780,10 @@ int main(int argc, char** argv) { test_configuration_timer_rearms_before_storage_work(); } else if (scenario == "flash-core-start") { test_flash_core_start_contract(); + } else if (scenario == "wake-identity-gate") { + test_wake_identity_gates_connections(); + } else if (scenario == "system-wake") { + test_system_button_wake_trigger(); } else if (scenario == "flash-core-failure") { test_flash_core_init_fatal(); } else { diff --git a/tests/bluepad32_native_stubs/btstack.h b/tests/bluepad32_native_stubs/btstack.h new file mode 100644 index 0000000..f01a815 --- /dev/null +++ b/tests/bluepad32_native_stubs/btstack.h @@ -0,0 +1,8 @@ +#pragma once + +#include "btstack_run_loop.h" +#include "uni.h" + +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid); +uint8_t l2cap_request_can_send_now_event(uint16_t cid); +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size); diff --git a/tests/bluepad32_native_stubs/btstack_run_loop.h b/tests/bluepad32_native_stubs/btstack_run_loop.h index 8505293..a3c5108 100644 --- a/tests/bluepad32_native_stubs/btstack_run_loop.h +++ b/tests/bluepad32_native_stubs/btstack_run_loop.h @@ -6,8 +6,14 @@ struct btstack_timer_source_t { void (*handler)(btstack_timer_source_t*); uint32_t timeout_ms; uint32_t add_count; + uint64_t due_ms; }; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +void native_test_add_timer(btstack_timer_source_t* timer); +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); +#endif + inline void btstack_run_loop_set_timer_handler( btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { @@ -21,6 +27,9 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer, inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { ++timer->add_count; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + native_test_add_timer(timer); +#endif } uint32_t btstack_run_loop_get_time_ms(); inline void btstack_run_loop_execute() {} diff --git a/tests/bluepad32_native_stubs/pico/multicore.h b/tests/bluepad32_native_stubs/pico/multicore.h index 33d06f7..e696605 100644 --- a/tests/bluepad32_native_stubs/pico/multicore.h +++ b/tests/bluepad32_native_stubs/pico/multicore.h @@ -1,3 +1,5 @@ #pragma once -void multicore_launch_core1(void (*entry)()); +#include +#include +void multicore_launch_core1_with_stack(void (*entry)(), uint32_t* stack, size_t size); diff --git a/tests/bluepad32_native_stubs/pico/stdlib.h b/tests/bluepad32_native_stubs/pico/stdlib.h index 510d071..a8e288a 100644 --- a/tests/bluepad32_native_stubs/pico/stdlib.h +++ b/tests/bluepad32_native_stubs/pico/stdlib.h @@ -1,3 +1,6 @@ #pragma once +#include +uint32_t time_us_32(); +uint64_t time_us_64(); void tight_loop_contents(); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 6f35a99..2e21249 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -98,9 +98,11 @@ struct uni_gamepad_t { struct uni_controller_t { int klass; uni_gamepad_t gamepad; + uint8_t battery; }; -struct uni_hid_device_t; +struct uni_hid_device_s; +typedef struct uni_hid_device_s uni_hid_device_t; typedef void (*btstack_packet_handler_t)(uint8_t, uint16_t, uint8_t*, uint16_t); struct btstack_packet_callback_registration_t { @@ -130,14 +132,25 @@ struct uni_bt_conn_t { bd_addr_t btaddr; hci_con_handle_t handle; uni_bt_conn_protocol_t protocol; + bool connected; + uint16_t interrupt_cid; + uint16_t control_cid; }; -struct uni_hid_device_t { +struct uni_circular_buffer_t { + unsigned queued; +}; +inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) { + return buffer->queued == 0; +} + +struct uni_hid_device_s { uint16_t vendor_id; uint16_t product_id; uni_bt_conn_t conn; int idx; bool gamepad; + uni_controller_t controller; uni_report_parser_t report_parser; int rumble_calls; uint8_t last_high; @@ -149,6 +162,7 @@ struct uni_hid_device_t { uint8_t lightbar_blue; int player_led_calls; uint8_t player_leds; + uni_circular_buffer_t outgoing_buffer; }; struct uni_platform { diff --git a/tests/btstack_credit_batch_native_stubs/btstack_config.h b/tests/btstack_credit_batch_native_stubs/btstack_config.h new file mode 100644 index 0000000..8b04cd4 --- /dev/null +++ b/tests/btstack_credit_batch_native_stubs/btstack_config.h @@ -0,0 +1,14 @@ +#ifndef SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H +#define SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H + +#define ENABLE_CLASSIC +#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL +#define ENABLE_BTSTACK_ASSERT +#define HAVE_MALLOC +#define HCI_ACL_PAYLOAD_SIZE 1021 +#define HCI_HOST_ACL_PACKET_LEN 1021 +#define HCI_HOST_ACL_PACKET_NUM 3 +#define HCI_HOST_SCO_PACKET_LEN 64 +#define HCI_HOST_SCO_PACKET_NUM 3 + +#endif diff --git a/tests/btstack_credit_batch_test.c b/tests/btstack_credit_batch_test.c new file mode 100644 index 0000000..7a8eb2b --- /dev/null +++ b/tests/btstack_credit_batch_test.c @@ -0,0 +1,338 @@ +#include +#include +#include + +// Compile the real patched SDK implementation, not a model of its credit logic. +#include "hci.c" + +static uint32_t clock_ms; +static void (*receive_packet)(uint8_t, uint8_t *, uint16_t); +static bool transport_ready; +static unsigned acl_delivered; +static unsigned sco_delivered; +static unsigned wire_count; +static uint8_t wire[32][64]; +static int wire_size[32]; +static void (*during_send)(void); + +noreturn void btstack_assert_failed(const char *file, uint16_t line){ + fprintf(stderr, "BTstack assertion: %s:%u\n", file, line); + abort(); +} + +static void set_timer(btstack_timer_source_t *timer, uint32_t timeout_ms){ + // Match pico_btstack's millisecond quantization, including its extra tick. + timer->timeout = clock_ms + timeout_ms + 1; +} + +static uint32_t get_time_ms(void){ + return clock_ms; +} + +static const btstack_run_loop_t run_loop = { + .init = btstack_run_loop_base_init, + .set_timer = set_timer, + .add_timer = btstack_run_loop_base_add_timer, + .remove_timer = btstack_run_loop_base_remove_timer, + .get_time_ms = get_time_ms, +}; + +static void advance(uint32_t ms){ + clock_ms += ms; + btstack_run_loop_base_process_timers(clock_ms); +} + +static void register_receiver(void (*handler)(uint8_t, uint8_t *, uint16_t)){ + receive_packet = handler; +} + +static int transport_open(void){ + return 0; +} + +static int transport_close(void){ + return 0; +} + +static int can_send(uint8_t packet_type){ + (void) packet_type; + return transport_ready; +} + +static int send_packet(uint8_t type, uint8_t *packet, int size){ + assert(type == HCI_COMMAND_DATA_PACKET); + assert(wire_count < 32 && size <= 64); + memcpy(wire[wire_count], packet, (size_t) size); + wire_size[wire_count++] = size; + if (during_send != NULL){ + void (*callback)(void) = during_send; + during_send = NULL; + callback(); + } + return 0; +} + +static hci_transport_t transport = { + .name = "credit-regression", + .open = transport_open, + .close = transport_close, + .register_packet_handler = register_receiver, + .send_packet = send_packet, +}; + +static void on_acl(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){ + (void) channel; + assert(type == HCI_ACL_DATA_PACKET && size == 9 && packet[8] == 0x5a); + acl_delivered++; +} + +static void on_sco(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){ + (void) channel; + assert(type == HCI_SCO_DATA_PACKET && size == 4 && packet[3] == 0x5a); + sco_delivered++; +} + +static void working(void){ + // Fixture bypasses controller initialization, leaving the production receive/run paths intact. + hci_stack->state = HCI_STATE_WORKING; + hci_stack->gap_tasks_classic = 0; + hci_stack->num_cmd_packets = 1; + hci_register_acl_packet_handler(on_acl); + hci_register_sco_packet_handler(on_sco); +} + +static void begin(uint32_t now, bool asynchronous){ + clock_ms = now; + transport_ready = true; + transport.can_send_packet_now = asynchronous ? can_send : NULL; + during_send = NULL; + wire_count = acl_delivered = sco_delivered = 0; + btstack_run_loop_init(&run_loop); + btstack_memory_init(); + hci_init(&transport, NULL); + working(); +} + +static void finish(void){ + if (hci_stack != NULL){ + // Isolate close's timer cancellation from the unrelated asynchronous shutdown FSM. + hci_stack->state = HCI_STATE_OFF; + hci_close(); + } + advance(10); + btstack_memory_deinit(); + btstack_run_loop_deinit(); +} + +static void add_connection(uint16_t handle, bd_addr_type_t type){ + hci_connection_t *connection = btstack_memory_hci_connection_get(); + assert(connection != NULL); + connection->con_handle = handle; + connection->address_type = type; + hci_connection_init(connection); + connection->state = OPEN; + btstack_linked_list_add_tail(&hci_stack->connections, (btstack_linked_item_t *) connection); +} + +static void acl(uint16_t handle){ + uint8_t packet[] = {0, 0x20, 5, 0, 1, 0, 0x40, 0, 0x5a}; + little_endian_store_16(packet, 0, handle | 0x2000); + receive_packet(HCI_ACL_DATA_PACKET, packet, sizeof(packet)); +} + +static void disconnected(uint16_t handle){ + uint8_t packet[] = {HCI_EVENT_DISCONNECTION_COMPLETE, 4, 0, 0, 0, 0x13}; + little_endian_store_16(packet, 3, handle); + receive_packet(HCI_EVENT_PACKET, packet, sizeof(packet)); +} + +static void expect_credits(unsigned index, uint16_t handle, uint16_t count){ + const uint8_t *packet = wire[index]; + assert(wire_size[index] == 8); + assert(packet[0] == 0x35 && packet[1] == 0x0c && packet[2] == 5 && packet[3] == 1); + assert(little_endian_read_16(packet, 4) == handle); + assert(little_endian_read_16(packet, 6) == count); +} + +#ifdef SWITCH_PICO_HCI_CREDIT_BATCH +static void test_batch_and_ordinary_command(void){ + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + assert(acl_delivered == 1 && wire_count == 0); + gap_set_class_of_device(0x010203); + assert(wire_count == 1 && little_endian_read_16(wire[0], 0) == HCI_OPCODE_HCI_WRITE_CLASS_OF_DEVICE); + // Completed packets remain sendable with zero command credits. + assert(!hci_can_send_command_packet_now()); + acl(0x41); + assert(acl_delivered == 2 && wire_count == 2); + expect_credits(1, 0x41, 2); + advance(2); + assert(wire_count == 2); + finish(); +} + +static void test_deadline_and_retry(void){ + begin(UINT32_MAX - 1, true); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + advance(1); + assert(wire_count == 0); + // Repeated POWER_ON must leave the original deadline intact. + hci_power_control(HCI_POWER_ON); + transport_ready = false; + advance(1); + assert(wire_count == 0); + transport_ready = true; + // No incoming event is needed to retry after a busy transport. + advance(2); + assert(wire_count == 1); + expect_credits(0, 0x41, 1); + uint8_t sent[] = {HCI_EVENT_TRANSPORT_PACKET_SENT, 0}; + receive_packet(HCI_EVENT_PACKET, sent, sizeof(sent)); + advance(2); + assert(wire_count == 1); + finish(); +} + +static void test_multiple_handles_and_disconnect(void){ + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + add_connection(0x42, BD_ADDR_TYPE_ACL); + acl(0x41); + acl(0x42); + assert(wire_count == 1 && wire_size[0] == 12); + const uint8_t expected[] = {0x35, 0x0c, 9, 2, 0x41, 0, 1, 0, 0x42, 0, 1, 0}; + assert(memcmp(wire[0], expected, sizeof(expected)) == 0); + acl(0x41); + advance(1); + disconnected(0x41); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + advance(1); + assert(wire_count == 1); // Removed handle's deadline cannot flush its replacement early. + advance(1); + assert(wire_count == 2); + expect_credits(1, 0x41, 1); + acl(0x42); + disconnected(0x42); + advance(2); + assert(wire_count == 2); // No empty completion command after the last pending handle disappears. + finish(); +} + +static void test_sco_and_malformed_acl(void){ + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + add_connection(0x42, BD_ADDR_TYPE_SCO); + uint8_t sco[] = {0x42, 0, 1, 0x5a}; + receive_packet(HCI_SCO_DATA_PACKET, sco, sizeof(sco)); + assert(sco_delivered == 1 && wire_count == 1); + expect_credits(0, 0x42, 1); + // An orphan continuation is counted by HCI before parsing rejects it. + uint8_t malformed[] = {0x41, 0x10, 1, 0, 0x5a}; + receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed)); + receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed)); + assert(acl_delivered == 0 && wire_count == 2); + expect_credits(1, 0x41, 2); + finish(); +} + +static void receive_during_send(void){ + acl(0x41); +} + +static void replace_stack_during_send(void){ + hci_stack->state = HCI_STATE_OFF; + hci_close(); + hci_init(&transport, NULL); + working(); + hci_reserve_packet_buffer(); +} + +static void test_synchronous_callbacks(void){ + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + during_send = receive_during_send; + advance(2); + assert(acl_delivered == 2 && wire_count == 1); + expect_credits(0, 0x41, 1); + advance(2); + assert(wire_count == 2); + expect_credits(1, 0x41, 1); // Incoming callback's new count/timer survived the previous send. + acl(0x41); + during_send = replace_stack_during_send; + advance(2); + assert(wire_count == 3 && !hci_can_send_command_packet_now()); + // The returning old send must not release the new stack's reserved buffer. + hci_release_packet_buffer(); + advance(2); + assert(wire_count == 3); + finish(); +} + +static void test_lifecycle(void){ + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + hci_stack->state = HCI_STATE_OFF; + hci_close(); + advance(2); + assert(wire_count == 0); + hci_init(&transport, NULL); + working(); + add_connection(0x42, BD_ADDR_TYPE_ACL); + acl(0x42); + // Re-init cancels before memset; preserve connection allocation for explicit cleanup. + hci_connection_t *old_connection = hci_connection_for_handle(0x42); + hci_init(&transport, NULL); + btstack_memory_hci_connection_free(old_connection); + working(); + advance(2); + assert(wire_count == 0); + add_connection(0x43, BD_ADDR_TYPE_ACL); + acl(0x43); + old_connection = hci_connection_for_handle(0x43); + hci_deinit(); + btstack_memory_hci_connection_free(old_connection); + advance(2); + assert(wire_count == 0); + btstack_memory_deinit(); + btstack_run_loop_deinit(); + + begin(100, false); + add_connection(0x44, BD_ADDR_TYPE_ACL); + acl(0x44); + hci_power_control(HCI_POWER_SLEEP); + unsigned after_power_transition = wire_count; + advance(2); + assert(wire_count == after_power_transition); + finish(); +} +#endif + +int main(void){ +#ifdef SWITCH_PICO_HCI_CREDIT_BATCH + test_batch_and_ordinary_command(); + test_deadline_and_retry(); + test_multiple_handles_and_disconnect(); + test_sco_and_malformed_acl(); + test_synchronous_callbacks(); + test_lifecycle(); +#else + begin(100, false); + add_connection(0x41, BD_ADDR_TYPE_ACL); + acl(0x41); + assert(acl_delivered == 1 && wire_count == 1); + expect_credits(0, 0x41, 1); + acl(0x41); + assert(acl_delivered == 2 && wire_count == 2); + expect_credits(1, 0x41, 1); + advance(2); + assert(wire_count == 2); + finish(); +#endif + puts("BTstack credit behavior passed"); + return 0; +} diff --git a/tests/controller_macro_capture_test.cpp b/tests/controller_macro_capture_test.cpp new file mode 100644 index 0000000..278ba9c --- /dev/null +++ b/tests/controller_macro_capture_test.cpp @@ -0,0 +1,522 @@ +#include "input/controller_macro_capture.h" +#include +#include + +namespace { + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +CaptureEvent captured_event(const ControllerMacroCapture& capture, + uint16_t index) { + CaptureEvent event{}; + require(capture.event(index, &event), "recorded event was inaccessible"); + return event; +} + +void require_terminal_frozen(ControllerMacroCapture& capture, + CaptureState expected, uint32_t elapsed) { + const uint16_t count = capture.event_count(); + capture.stop(90000000); + capture.disconnect(capture.slot(), capture.generation(), 90000001); + capture.observe(capture.slot(), capture.generation(), 90000002, + controller_neutral_state()); + capture.tick(90000003); + require(capture.state() == expected && + capture.elapsed_us(90000004) == elapsed && + capture.event_count() == count, + "a terminal capture changed state, end time or event count"); +} + +void test_initial_snapshot_and_start_validation() { + ControllerMacroCapture capture; + CaptureEvent output{}; + require(capture.state() == CaptureState::kIdle && + capture.elapsed_us(1234) == 0 && + !capture.event(0, &output), + "idle capture exposed an event or running clock"); + capture.stop(10); + capture.disconnect(0, 0, 11); + capture.tick(12); + require(capture.state() == CaptureState::kIdle, + "an operation without a run changed idle state"); + + CaptureOptions options; + options.channels = 31; + ControllerState initial = controller_neutral_state(); + initial.button_south = true; + initial.button_left_stick = true; + initial.dpad_up = true; + initial.left_stick_x = -768; + initial.right_stick_y = 768; + initial.left_trigger = 1536; + initial.right_trigger = UINT16_MAX; + require(capture.start(3, 77, options, 100, initial), + "valid capture did not start"); + const uint32_t run = capture.run_id(); + const CaptureEvent first = captured_event(capture, 0); + require(run != 0 && capture.slot() == 3 && capture.generation() == 77 && + capture.event_count() == 1 && first.at_us == 0 && + first.buttons == ((1u << 0) | (1u << 10) | (1u << 12)) && + first.left_x == -1024 && first.right_y == 1024 && + first.left_trigger == 2048 && first.right_trigger == UINT16_MAX, + "capture did not retain the quantized initial state at time zero"); + require(!capture.start(0, 1, options, 200, controller_neutral_state()) && + capture.run_id() == run && capture.elapsed_us(180) == 80 && + captured_event(capture, 0).buttons == first.buttons, + "start replaced an active capture"); + require(!capture.event(1, &output) && !capture.event(0, nullptr), + "event accessor accepted an absent event or null output"); + capture.stop(180); + + const CaptureOptions invalid[] = { + {0, 512, 1024, 8, 10000}, + {32, 512, 1024, 8, 10000}, + {1, 0, 1024, 8, 10000}, + {1, 32768, 1024, 8, 10000}, + {1, 512, 0, 8, 10000}, + {1, 512, 1024, 0, 10000}, + {1, 512, 1024, 129, 10000}, + {1, 512, 1024, 8, 0}, + {1, 512, 1024, 8, 80001}, + }; + for (const CaptureOptions& bad : invalid) { + require(!capture.start(0, 1, bad, 200, initial), + "invalid capture options were accepted"); + } + require(!capture.start(4, 1, options, 200, initial) && + capture.run_id() == run && + capture.state() == CaptureState::kStopped && + capture.elapsed_us(200) == 80 && + captured_event(capture, 0).buttons == first.buttons, + "rejected starts damaged the stopped recording"); + + options.axis_quantum = INT16_MAX; + options.trigger_quantum = UINT16_MAX; + options.max_events = 128; + options.max_duration_ms = 80000; + require(capture.start(0, 1, options, 300, controller_neutral_state()) && + capture.run_id() == run + 1 && capture.event_count() == 1 && + captured_event(capture, 0).buttons == 0 && + capture.elapsed_us(300) == 0, + "a successful restart did not replace the old run at valid bounds"); +} + +void test_positional_logical_buttons() { + constexpr bool ControllerState::* buttons[] = { + &ControllerState::button_south, + &ControllerState::button_east, + &ControllerState::button_west, + &ControllerState::button_north, + &ControllerState::button_left_shoulder, + &ControllerState::button_right_shoulder, + &ControllerState::button_select, + &ControllerState::button_start, + &ControllerState::button_system, + &ControllerState::button_capture, + &ControllerState::button_left_stick, + &ControllerState::button_right_stick, + &ControllerState::dpad_up, + &ControllerState::dpad_down, + &ControllerState::dpad_left, + &ControllerState::dpad_right, + }; + ControllerMacroCapture capture; + CaptureOptions options; + options.max_events = 17; + require(capture.start(0, 1, options, 0, controller_neutral_state()), + "button capture did not start"); + for (uint16_t i = 0; i < 16; ++i) { + ControllerState state = controller_neutral_state(); + state.*buttons[i] = true; + capture.observe(0, 1, i + 1, state); + const CaptureEvent event = captured_event(capture, i + 1); + require(event.buttons == (1u << i) && event.at_us == i + 1u, + "a positional button mapped to the wrong logical bit"); + } +} + +ControllerState channel_state(uint8_t channels) { + ControllerState state = controller_neutral_state(); + state.button_south = (channels & 1u) != 0; + if ((channels & 2u) != 0) { + state.left_stick_x = 1279; + state.left_stick_y = -1279; + } + if ((channels & 4u) != 0) { + state.right_stick_x = -1791; + state.right_stick_y = 1791; + } + if ((channels & 8u) != 0) { + state.left_trigger = 2047; + } + if ((channels & 16u) != 0) { + state.right_trigger = 3071; + } + state.motion_sample_count = 1; + state.motion_samples[0].gyro_x = 1000; + return state; +} + +void test_selected_channels_ignore_unrelated_changes() { + for (uint8_t channel = 1; channel <= 16; channel <<= 1) { + ControllerMacroCapture capture; + CaptureOptions options; + options.channels = channel; + require(capture.start(0, 1, options, 10, controller_neutral_state()), + "single-channel capture did not start"); + capture.observe(0, 1, 20, channel_state(31u ^ channel)); + require(capture.event_count() == 1, + "unselected channels or motion produced a capture edge"); + capture.observe(0, 1, 30, channel_state(31)); + require(capture.event_count() == 2, + "selected channel did not produce exactly one edge"); + const CaptureEvent event = captured_event(capture, 1); + require(event.at_us == 20 && + event.buttons == (channel == 1 ? 1 : 0) && + event.left_x == (channel == 2 ? 1024 : 0) && + event.left_y == (channel == 2 ? -1024 : 0) && + event.right_x == (channel == 4 ? -1536 : 0) && + event.right_y == (channel == 4 ? 1536 : 0) && + event.left_trigger == (channel == 8 ? 2048 : 0) && + event.right_trigger == (channel == 16 ? 3072 : 0), + "selected output leaked or omitted a channel"); + } +} + +void test_analog_quantization_symmetry_and_endpoints() { + ControllerMacroCapture capture; + CaptureOptions options; + options.channels = 30; + ControllerState state = controller_neutral_state(); + state.left_stick_x = 255; + state.left_stick_y = -255; + state.left_trigger = 511; + state.right_trigger = 511; + require(capture.start(0, 1, options, 100, state), + "analog capture did not start"); + CaptureEvent event = captured_event(capture, 0); + require(event.left_x == 0 && event.left_y == 0 && + event.left_trigger == 0 && event.right_trigger == 0, + "near-zero inputs did not quantize to rest"); + + state.left_stick_x = 256; + state.left_stick_y = -256; + state.right_stick_x = -768; + state.right_stick_y = 768; + state.left_trigger = 512; + state.right_trigger = 1536; + capture.observe(0, 1, 101, state); + event = captured_event(capture, 1); + require(event.left_x == 512 && event.left_y == -512 && + event.right_x == -1024 && event.right_y == 1024 && + event.left_trigger == 1024 && event.right_trigger == 2048, + "half-quantum ties did not round symmetrically away from zero"); + state.left_stick_x = 767; + state.left_stick_y = -767; + state.right_stick_x = -1279; + state.right_stick_y = 1279; + state.left_trigger = 1535; + state.right_trigger = 2559; + capture.observe(0, 1, 102, state); + require(capture.event_count() == 2, + "jitter within a quantized bucket produced another edge"); + + state.left_stick_x = INT16_MIN; + state.left_stick_y = INT16_MAX; + state.right_stick_x = -INT16_MAX; + state.right_stick_y = INT16_MAX - 1; + state.left_trigger = 0; + state.right_trigger = UINT16_MAX; + capture.observe(0, 1, 103, state); + event = captured_event(capture, 2); + require(event.left_x == INT16_MIN && event.left_y == INT16_MAX && + event.right_x == -INT16_MAX && event.right_y == INT16_MAX && + event.left_trigger == 0 && event.right_trigger == UINT16_MAX, + "quantization wrapped, lost full scale or broke signed symmetry"); + capture.observe(0, 1, 104, controller_neutral_state()); + event = captured_event(capture, 3); + require(event.left_x == 0 && event.left_y == 0 && event.right_x == 0 && + event.right_y == 0 && event.left_trigger == 0 && + event.right_trigger == 0, + "returning to exact rest retained a quantized analog value"); + capture.stop(105); + + options.axis_quantum = 3; + options.trigger_quantum = 3; + state.left_stick_x = 1; + state.left_stick_y = -1; + state.right_stick_x = 2; + state.right_stick_y = -2; + state.left_trigger = 1; + state.right_trigger = 2; + require(capture.start(0, 2, options, 200, state), + "odd-quantum capture did not start"); + event = captured_event(capture, 0); + require(event.left_x == 0 && event.left_y == 0 && event.right_x == 3 && + event.right_y == -3 && event.left_trigger == 0 && + event.right_trigger == 3, + "odd quanta shifted the signed or unsigned rounding threshold"); +} + +void test_same_timestamp_coalesces_before_budget_check() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_events = 2; + ControllerState state = controller_neutral_state(); + require(capture.start(0, 1, options, 10, state), + "coalescing capture did not start"); + state.button_south = true; + capture.observe(0, 1, 10, state); + state.button_south = false; + capture.observe(0, 1, 10, state); + require(capture.event_count() == 1 && + captured_event(capture, 0).buttons == 0, + "same-time initial changes made zero-length intermediate events"); + state.button_south = true; + capture.observe(0, 1, 20, state); + state.button_south = false; + state.button_west = true; + capture.observe(0, 1, 20, state); + capture.observe(0, 1, 21, state); + require(capture.state() == CaptureState::kRecording && + capture.event_count() == 2 && + captured_event(capture, 1).buttons == (1u << 2), + "a full event budget rejected coalescing or an unchanged sample"); + state.button_west = false; + state.button_north = true; + capture.observe(0, 1, 22, state); + require(capture.state() == CaptureState::kFull && + capture.elapsed_us(30) == 12 && capture.event_count() == 2 && + captured_event(capture, 1).at_us == 10 && + captured_event(capture, 1).buttons == (1u << 2), + "overflow did not retain the last complete recorded state"); + require_terminal_frozen(capture, CaptureState::kFull, 12); +} + +void test_full_static_capacity() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_events = 128; + ControllerState state = controller_neutral_state(); + require(capture.start(0, 1, options, 0, state), + "maximum-capacity capture did not start"); + for (uint16_t i = 1; i < 128; ++i) { + state.button_south = (i & 1u) != 0; + capture.observe(0, 1, i, state); + } + require(capture.event_count() == 128 && + capture.state() == CaptureState::kRecording, + "capture stopped before an edge exceeded its full capacity"); + state.button_south = false; + capture.observe(0, 1, 128, state); + require(capture.state() == CaptureState::kFull && + capture.event_count() == 128 && capture.elapsed_us(129) == 128 && + captured_event(capture, 127).at_us == 127 && + captured_event(capture, 127).buttons == 1, + "capacity overflow overwrote retained events or lost its end time"); +} + +void test_clock_wrap_and_stopped_end() { + ControllerMacroCapture capture; + CaptureOptions options; + ControllerState state = controller_neutral_state(); + constexpr uint32_t started = UINT32_MAX - 100u; + require(capture.start(0, 1, options, started, state), + "wrap capture did not start"); + state.button_east = true; + capture.observe(0, 1, 49, state); + require(captured_event(capture, 1).at_us == 150 && + capture.elapsed_us(49) == 150, + "microsecond clock wrap changed elapsed event timing"); + capture.stop(149); + require_terminal_frozen(capture, CaptureState::kStopped, 250); +} + +void test_long_holds_and_exact_timeout() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_duration_ms = 80000; + ControllerState held = controller_neutral_state(); + held.button_south = true; + constexpr uint32_t start = 123; + require(capture.start(0, 1, options, start, held), + "long-hold capture did not start"); + capture.tick(start + 10000000); + require(capture.event_count() == 2 && + captured_event(capture, 1).at_us == 10000000, + "a live ten-second hold did not split at its boundary"); + capture.observe(0, 1, start + 15000000, held); + require(capture.event_count() == 2, + "unchanged observation added a spurious hold edge"); + capture.tick(start + 35000000); + require(capture.event_count() == 4, + "late tick omitted required ten-second hold boundaries"); + capture.tick(start + 90000000); + require(capture.state() == CaptureState::kTimedOut && + capture.event_count() == 8 && + capture.elapsed_us(start + 90000000) == 80000000, + "long hold did not clip at the exact duration and fit eight steps"); + for (uint16_t i = 0; i < 8; ++i) { + const CaptureEvent event = captured_event(capture, i); + require(event.at_us == i * 10000000u && event.buttons == 1, + "hold split changed the captured state or interval length"); + } + require_terminal_frozen(capture, CaptureState::kTimedOut, 80000000); + + options.max_duration_ms = 25000; + require(capture.start(0, 1, options, 0, held), + "partial-hold timeout capture did not start"); + capture.tick(40000000); + require(capture.state() == CaptureState::kTimedOut && + capture.event_count() == 3 && + captured_event(capture, 2).at_us == 20000000 && + capture.elapsed_us(40000000) == 25000000, + "timeout beyond a hold boundary lost the partial final interval"); + + options.max_duration_ms = 10000; + options.max_events = 1; + require(capture.start(0, 1, options, 0, held), + "single-step timeout capture did not start"); + capture.tick(10000000); + require(capture.state() == CaptureState::kTimedOut && + capture.event_count() == 1 && + capture.elapsed_us(20000000) == 10000000, + "an exact-duration hold created a zero-length edge or false full"); +} + +void test_hold_splits_follow_edges_and_coalesce() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_events = 3; + options.max_duration_ms = 80000; + ControllerState state = controller_neutral_state(); + state.button_south = true; + require(capture.start(0, 1, options, 0, state), + "edge-relative hold capture did not start"); + state.button_south = false; + state.button_east = true; + capture.observe(0, 1, 5000000, state); + capture.tick(15000000); + state.button_east = false; + state.button_west = true; + capture.observe(0, 1, 15000000, state); + require(capture.state() == CaptureState::kRecording && + capture.event_count() == 3 && + captured_event(capture, 1).at_us == 5000000 && + captured_event(capture, 2).at_us == 15000000 && + captured_event(capture, 2).buttons == (1u << 2), + "hold boundary did not follow or coalesce with a real edge"); + capture.stop(25000000); + require(capture.state() == CaptureState::kStopped && + capture.event_count() == 3 && + capture.elapsed_us(30000000) == 25000000, + "stop at the final hold boundary added an unnecessary event"); +} + +void test_hold_budget_exhaustion_and_stop_catchup() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_events = 2; + options.max_duration_ms = 80000; + ControllerState state = controller_neutral_state(); + state.button_south = true; + require(capture.start(0, 1, options, 0, state), + "bounded hold capture did not start"); + capture.tick(UINT32_MAX); + require(capture.state() == CaptureState::kFull && + capture.elapsed_us(UINT32_MAX) == 20000000 && + capture.event_count() == 2 && + captured_event(capture, 1).at_us == 10000000 && + captured_event(capture, 1).buttons == 1, + "large tick did not stop full at the first missing hold boundary"); + require_terminal_frozen(capture, CaptureState::kFull, 20000000); + + options.max_events = 4; + require(capture.start(0, 1, options, 0, state), + "stop-catchup capture did not start"); + capture.stop(35000000); + require(capture.state() == CaptureState::kStopped && + capture.event_count() == 4 && + captured_event(capture, 3).at_us == 30000000 && + capture.elapsed_us(50000000) == 35000000, + "stop without intervening ticks omitted long-hold splits"); +} + +void test_generation_replacement_disconnect_and_slot_isolation() { + ControllerMacroCapture capture; + CaptureOptions options; + ControllerState initial = controller_neutral_state(); + initial.button_south = true; + ControllerState replacement = controller_neutral_state(); + replacement.button_east = true; + require(capture.start(2, 7, options, 100, initial), + "generation capture did not start"); + capture.observe(3, 99, 150, replacement); + capture.disconnect(3, 7, 170); + capture.disconnect(2, 6, 180); + require(capture.state() == CaptureState::kRecording && + capture.event_count() == 1, + "another slot or stale disconnect interrupted capture"); + capture.observe(2, 8, 200, replacement); + require(capture.state() == CaptureState::kDisconnected && + capture.generation() == 7 && capture.event_count() == 1 && + captured_event(capture, 0).buttons == 1 && + capture.elapsed_us(250) == 100, + "generation replacement recorded the new device or lost the end"); + require_terminal_frozen(capture, CaptureState::kDisconnected, 100); + + require(capture.start(2, 8, options, 300, replacement), + "replacement generation could not start a new capture"); + capture.disconnect(2, 7, 400); + require(capture.state() == CaptureState::kRecording, + "old-generation disconnect ended a replacement run"); + capture.disconnect(2, 8, 450); + require_terminal_frozen(capture, CaptureState::kDisconnected, 150); +} + +void test_deadline_precedes_late_edges_and_terminal_requests() { + ControllerMacroCapture capture; + CaptureOptions options; + options.max_duration_ms = 1; + ControllerState state = controller_neutral_state(); + require(capture.start(0, 1, options, 0, state), + "deadline capture did not start"); + state.button_south = true; + capture.observe(0, 1, 1000, state); + require(capture.state() == CaptureState::kTimedOut && + capture.event_count() == 1 && + captured_event(capture, 0).buttons == 0 && + capture.elapsed_us(2000) == 1000, + "an edge at the deadline escaped timeout clipping"); + require(capture.start(0, 1, options, 0, state), + "late-stop capture did not start"); + capture.stop(2000); + require_terminal_frozen(capture, CaptureState::kTimedOut, 1000); + require(capture.start(0, 1, options, 0, state), + "late-disconnect capture did not start"); + capture.disconnect(0, 1, 2000); + require_terminal_frozen(capture, CaptureState::kTimedOut, 1000); +} + +} // namespace + +int main() { + test_initial_snapshot_and_start_validation(); + test_positional_logical_buttons(); + test_selected_channels_ignore_unrelated_changes(); + test_analog_quantization_symmetry_and_endpoints(); + test_same_timestamp_coalesces_before_budget_check(); + test_full_static_capacity(); + test_clock_wrap_and_stopped_end(); + test_long_holds_and_exact_timeout(); + test_hold_splits_follow_edges_and_coalesce(); + test_hold_budget_exhaustion_and_stop_catchup(); + test_generation_replacement_disconnect_and_slot_isolation(); + test_deadline_precedes_late_edges_and_terminal_requests(); + return 0; +} diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h index 98ccfde..3cc9cd7 100644 --- a/tests/controller_profile_legacy_fixtures.h +++ b/tests/controller_profile_legacy_fixtures.h @@ -4,7 +4,7 @@ #include "profile/controller_profile.h" -constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { +constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_LEGACY_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, @@ -24,7 +24,7 @@ constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { }; constexpr uint8_t - kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { + kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_LEGACY_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, @@ -43,7 +43,7 @@ constexpr uint8_t 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; -constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = { +constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_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, diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index a166a24..f105b94 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -28,6 +28,11 @@ unsigned active_snapshot_count = 0; std::array activation_attempts{}; size_t activation_attempt_count = 0; unsigned activation_busy_attempts = 0; +ConfigurationTransactionStatus activation_result = + ConfigurationTransactionStatus::kPending; +uint8_t last_motion_toggle_slot = 0; +uint32_t last_motion_toggle_connection_generation = 0; +unsigned motion_toggle_count = 0; void require(bool condition, const char* message) { if (!condition) { @@ -54,6 +59,10 @@ void prepare_profiles() { activation_attempts = {}; activation_attempt_count = 0; activation_busy_attempts = 0; + activation_result = ConfigurationTransactionStatus::kPending; + last_motion_toggle_slot = 0; + last_motion_toggle_connection_generation = 0; + motion_toggle_count = 0; for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { FakeProfileRow& row = rows[slot]; @@ -390,18 +399,17 @@ void test_initial_profile_indication_once_per_connection() { void test_default_switching_retry_commit_and_feedback() { prepare_profiles(); ControllerProfile& initial_profile = rows[0].profiles[0]; - initial_profile.macro_trigger = static_cast( - ControllerProfileLogicalButton::kLeftShoulder); - initial_profile.macro_step_count = 2; - initial_profile.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + initial_profile.macros[0].trigger_mask = + logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder); + initial_profile.macros[0].first_step = 0; + initial_profile.macros[0].step_count = 1; + initial_profile.macro_step_count = 1; initial_profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; initial_profile.macro_steps[0].duration_ms = 1000; initial_profile.macro_steps[0].output_button_mask = logical_button_bit(ControllerProfileLogicalButton::kNorth); - initial_profile.macro_steps[1].type = - ControllerProfileMacroStepType::kEnd; Bluepad32SlotSnapshot snapshot = make_snapshot(0); (void)runtime_transform(0, snapshot, 0); @@ -526,18 +534,17 @@ void test_identity_promotion_preserves_held_switching() { } 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->macros[0].trigger_mask = + logical_button_bit( + ControllerProfileLogicalButton::kDpadUp); + profile->macros[0].first_step = 0; + profile->macros[0].step_count = 1; + profile->macro_step_count = 1; profile->macro_steps[0].override_flags = kControllerProfileOverrideButtons; profile->macro_steps[0].duration_ms = 1000; profile->macro_steps[0].output_button_mask = logical_button_bit(ControllerProfileLogicalButton::kNorth); - profile->macro_steps[1].type = - ControllerProfileMacroStepType::kEnd; } void test_switching_uses_pre_hotkey_buttons_only() { @@ -623,19 +630,19 @@ void test_custom_switching_chord_and_wrap() { "custom switching chord was not consumed or activated"); prepare_profiles(); - rows[0].active_profile = 3; - rows[0].profiles[3].switching_chord = kCustomChord; + rows[0].active_profile = 7; + rows[0].profiles[7].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"); + "initial profile 8 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"); + "profile switching did not wrap profile 8 to profile 1"); } void test_switching_slot_isolation() { prepare_profiles(); @@ -673,25 +680,61 @@ void test_switching_slot_isolation() { } +void test_profile_motion_toggle_supports_trigger_chords() { + prepare_profiles(); + const uint32_t custom_chord = + logical_button_bit(ControllerProfileLogicalButton::kSouth) | + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + rows[0].profiles[0].motion_toggle_chord = custom_chord; + Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9); + + (void)runtime_transform(0, snapshot, 0); + require(motion_toggle_count == 0, + "profile refresh toggled motion without a chord"); + + apply_button_mask( + logical_button_bit(ControllerProfileLogicalButton::kSouth), + &snapshot); + snapshot.state.left_trigger = UINT16_MAX; + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(motion_toggle_count == 1 && + last_motion_toggle_slot == 0 && + last_motion_toggle_connection_generation == 9 && + controller_profile_extract_button_mask(output.state) == 0 && + output.state.left_trigger == 0, + "trigger-backed motion chord did not toggle and consume inputs"); + + (void)runtime_transform(0, snapshot, 2); + require(motion_toggle_count == 1, + "held trigger-backed motion chord toggled twice"); + snapshot = make_snapshot(0, 9); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask( + logical_button_bit(ControllerProfileLogicalButton::kSouth), + &snapshot); + snapshot.state.left_trigger = UINT16_MAX; + (void)runtime_transform(0, snapshot, 4); + require(motion_toggle_count == 2, + "released trigger-backed motion chord did not re-arm"); +} + 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 = + profile.macros[0].trigger_mask = + logical_button_bit(ControllerProfileLogicalButton::kSouth); + profile.macros[0].cancel_control = static_cast(ControllerProfileLogicalButton::kCapture); - profile.macro_step_count = 2; - profile.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + profile.macros[0].first_step = 0; + profile.macros[0].step_count = 1; + profile.macro_step_count = 1; profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[0].duration_ms = 1000; profile.macro_steps[0].output_button_mask = static_cast( 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; @@ -835,8 +878,298 @@ void test_runtime_slot_synthetic_isolation() { "slot-local configured cancel affected another slot"); } +void test_shortcut_arbitration_latching_and_commit_feedback() { + prepare_profiles(); + ControllerProfile& profile = rows[0].profiles[0]; + constexpr uint16_t modifier = 1u << 9; + constexpr uint16_t chord = modifier | 1u; + profile.shortcuts.modifier = 9; + profile.shortcuts.selectors[2] = 0; + profile.shortcuts.selectors[3] = 1; + profile.switching_chord = chord; + profile.motion_toggle_chord = chord; + profile.shift.mode = ControllerProfileShiftMode::kToggle; + profile.shift.modifier = 9; + profile.shift.button_map[2] = 3; + profile.macros[0] = {chord, CONTROLLER_PROFILE_NO_BUTTON, 0, 1}; + profile.macro_step_count = 1; + profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 8}; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + + apply_button_mask(chord | 2u, &snapshot); + auto output = runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 0 && motion_toggle_count == 0 && + controller_profile_extract_button_mask(output.state) == 0, + "ambiguous direct selectors triggered a lower-priority action"); + apply_button_mask(chord, &snapshot); + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 0 && + controller_profile_extract_button_mask(output.state) == 0, + "ambiguous chord chose a target when one selector was released"); + apply_button_mask(modifier, &snapshot); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask(chord | 4u, &snapshot); + output = runtime_transform(0, snapshot, 4); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 2 && + motion_toggle_count == 0 && output.state.button_west && + !output.state.button_north && !output.state.button_south && + !output.state.button_capture, + "direct shortcut did not outrank cycle, motion, Shift and macro"); + bool available = true; + (void)take_profile_change(0, &available); + require(!available, "accepted but uncommitted shortcut produced feedback"); + + ControllerProfile& committed = rows[0].profiles[2]; + committed.shortcuts.modifier = 9; + committed.shortcuts.selectors[4] = 0; + committed.button_map[2] = 3; + committed.confirmation_policy = ControllerProfileConfirmationPolicy::kLed; + rows[0].active_profile = 2; + ++database_generation; + output = runtime_transform(0, snapshot, 5); + const auto event = take_profile_change(0, &available); + require(available && event.active_profile_number == 3 && + event.policy == ControllerProfileConfirmationPolicy::kLed && + activation_attempt_count == 1 && output.state.button_north && + !output.state.button_capture && !output.state.button_south, + "committed shortcut redirected a held chord or lost commit feedback"); + (void)take_profile_change(0, &available); + require(!available, "shortcut published duplicate commit feedback"); + apply_button_mask(modifier, &snapshot); + (void)runtime_transform(0, snapshot, 6); + activation_result = ConfigurationTransactionStatus::kStorageError; + apply_button_mask(chord, &snapshot); + output = runtime_transform(0, snapshot, 7); + (void)runtime_transform(0, snapshot, 8); + (void)take_profile_change(0, &available); + require(activation_attempt_count == 2 && + activation_attempts[1].profile_index == 4 && !available && + controller_profile_extract_button_mask(output.state) == 0, + "failed activation retried, leaked its chord, or produced feedback"); + + prepare_profiles(); + rows[0].profiles[0].shortcuts.modifier = 9; + rows[0].profiles[0].shortcuts.selectors[0] = 0; + snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + apply_button_mask(chord, &snapshot); + output = runtime_transform(0, snapshot, 1); + (void)take_profile_change(0, &available); + require(activation_attempt_count == 0 && !available && + controller_profile_extract_button_mask(output.state) == 0, + "already-active direct target was not a consumed no-op"); +} + +void test_busy_shortcut_target_and_generation_isolation() { + prepare_profiles(); + constexpr uint16_t chord = (1u << 9) | 1u; + rows[0].profiles[0].shortcuts.modifier = 9; + rows[0].profiles[0].shortcuts.selectors[3] = 0; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + activation_busy_attempts = 3; + apply_button_mask(chord, &snapshot); + (void)runtime_transform(0, snapshot, 1); + rows[0].active_profile = 1; + rows[0].profiles[1].shortcuts.modifier = 9; + rows[0].profiles[1].shortcuts.selectors[6] = 0; + ++database_generation; + (void)runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 2 && + activation_attempts[1].profile_index == 3 && + activation_attempts[0].transaction_id == + activation_attempts[1].transaction_id, + "profile refresh changed a busy shortcut's latched target"); + ++snapshot.connection_generation; + auto output = runtime_transform(0, snapshot, 3); + require(activation_attempt_count == 2 && + controller_profile_extract_button_mask(output.state) == 0, + "new connection generation inherited or retriggered held activation"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 4); + apply_button_mask(chord, &snapshot); + (void)runtime_transform(0, snapshot, 5); + require(activation_attempt_count == 3 && + activation_attempts[2].profile_index == 6 && + activation_attempts[2].transaction_id != + activation_attempts[0].transaction_id, + "new generation could not rearm with its own target"); + output = runtime_transform(0, snapshot, 6, AdapterUsbMode::kXInput); + (void)runtime_transform(0, snapshot, 7, AdapterUsbMode::kXInput); + require(activation_attempt_count == 3 && + controller_profile_extract_button_mask(output.state) == 0, + "output-mode reset leaked a pending activation retry"); +} + +void test_cycle_motion_arbitration_and_held_refresh() { + prepare_profiles(); + constexpr uint16_t chord = (1u << 9) | 1u; + rows[0].profiles[0].switching_chord = chord; + rows[0].profiles[0].motion_toggle_chord = chord; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + apply_button_mask(chord, &snapshot); + auto output = runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 1 && motion_toggle_count == 0 && + controller_profile_extract_button_mask(output.state) == 0, + "one cycle chord also toggled motion"); + apply_button_mask(1u, &snapshot); + (void)runtime_transform(0, snapshot, 2); + apply_button_mask(chord, &snapshot); + (void)runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 2 && motion_toggle_count == 0, + "re-completing a released cycle chord did not rearm"); + apply_button_mask(1u, &snapshot); + (void)runtime_transform(0, snapshot, 2); + rows[0].profiles[0].motion_toggle_chord = 1u; + rows[0].profiles[0].switching_chord = 2u; + ++database_generation; + output = runtime_transform(0, snapshot, 3); + require(motion_toggle_count == 0 && !output.state.button_south, + "partial held chord became a new action after profile refresh"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 4); + apply_button_mask(1u, &snapshot); + output = runtime_transform(0, snapshot, 5); + require(motion_toggle_count == 1 && !output.state.button_south, + "motion did not rearm after the higher-priority chord released"); + ++database_generation; + output = runtime_transform(0, snapshot, 6); + require(motion_toggle_count == 1 && !output.state.button_south, + "profile refresh phantom-toggled held motion chord"); +} + +void test_shift_slot_and_context_resets() { + prepare_profiles(); + std::array snapshots{}; + constexpr uint8_t outputs[4] = {1, 2, 3, 12}; + for (uint8_t slot = 0; slot < 4; ++slot) { + auto& profile = rows[slot].profiles[0]; + profile.shift.mode = ControllerProfileShiftMode::kToggle; + profile.shift.modifier = 9; + profile.shift.button_map[0] = outputs[slot]; + snapshots[slot] = make_snapshot(slot); + (void)runtime_transform(slot, snapshots[slot], 0); + apply_button_mask((1u << 9) | 1u, &snapshots[slot]); + const auto output = runtime_transform(slot, snapshots[slot], 1); + require(controller_profile_extract_button_mask(output.state) == + (1u << outputs[slot]), + "Shift activation was not physical, consumed and slot-local"); + } + ++snapshots[0].connection_generation; + auto output = runtime_transform(0, snapshots[0], 2); + require(output.state.button_south && !output.state.button_capture, + "connection reset phantom-toggled held Shift"); + output = runtime_transform(1, snapshots[1], 2); + require(output.state.button_west && !output.state.button_south, + "one slot's Shift reset disturbed another slot"); + output = runtime_transform(1, snapshots[1], 3, AdapterUsbMode::kXInput); + require(output.state.button_south && !output.state.button_west, + "mode reset retained or retriggered toggle Shift"); + rows[2].profiles[1].shift = rows[2].profiles[0].shift; + rows[2].active_profile = 1; + ++database_generation; + output = runtime_transform(2, snapshots[2], 4); + require(output.state.button_south && !output.state.button_north && + !output.state.button_capture, + "profile activation retained or phantom-toggled Shift"); + output = runtime_transform(3, snapshots[3], 4); + require(output.state.button_south && !output.state.dpad_up, + "database refresh did not reset the remaining Shift slot"); + apply_button_mask(1u, &snapshots[3]); + (void)runtime_transform(3, snapshots[3], 5); + apply_button_mask((1u << 9) | 1u, &snapshots[3]); + output = runtime_transform(3, snapshots[3], 6); + require(output.state.dpad_up && !output.state.button_south, + "Shift could not rearm after reset and physical release"); + ++configuration_reset_generation; + output = runtime_transform(3, snapshots[3], 7); + require(output.state.button_south && !output.state.dpad_up, + "configuration reset retained Shift toggle state"); +} + +void test_held_synthetic_sources_and_disconnect_rearming() { + prepare_profiles(); + auto& profile = rows[0].profiles[0]; + profile.macros[0] = {1u, 9, 0, 1}; + profile.macros[0].mode = ControllerProfileMacroMode::kToggle; + profile.macro_step_count = 1; + profile.macro_steps[0].override_flags = kControllerProfileOverrideLeftStick; + profile.macro_steps[0].duration_ms = 10; + profile.macro_steps[0].left_stick_x = 12345; + profile.turbo_modes[1] = ControllerProfileTurboMode::kBurst; + profile.turbo_modes[2] = ControllerProfileTurboMode::kAutoBurst; + profile.shortcuts.modifier = 9; + profile.shortcuts.selectors[2] = 3; + Bluepad32SlotSnapshot snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + apply_button_mask(7u, &snapshot); + auto output = runtime_transform(0, snapshot, 1); + require(output.state.left_stick_x == 12345 && + output.state.button_east && output.state.button_west && + !output.state.button_south, + "independent looping macro and Burst sources did not start"); + ++database_generation; + output = runtime_transform(0, snapshot, 2); + require(output.state.left_stick_x == 0 && output.state.button_south && + !output.state.button_east && !output.state.button_west, + "refresh phantom-restarted a held edge-triggered synthetic source"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask(7u, &snapshot); + output = runtime_transform(0, snapshot, 4); + require(output.state.left_stick_x == 12345 && + output.state.button_east && output.state.button_west, + "synthetic sources could not rearm after refresh and release"); + snapshot.active = false; + (void)runtime_transform(0, snapshot, 5); + snapshot.active = true; + apply_button_mask((1u << 9) | 8u, &snapshot); + output = runtime_transform(0, snapshot, 6); + require(activation_attempt_count == 0 && + controller_profile_extract_button_mask(output.state) == 0 && + output.state.left_stick_x == 0, + "reconnect treated an already-held shortcut as a new command"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 7); + apply_button_mask((1u << 9) | 8u, &snapshot); + (void)runtime_transform(0, snapshot, 8); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 2, + "reconnected shortcut did not rearm on physical release"); +} + +void test_shortcut_selector_rollover_without_modifier_release() { + prepare_profiles(); + auto& profile = rows[0].profiles[0]; + profile.shortcuts.modifier = 9; + profile.shortcuts.selectors[1] = 0; + profile.shortcuts.selectors[2] = 1; + auto snapshot = make_snapshot(0); + (void)runtime_transform(0, snapshot, 0); + apply_button_mask((1u << 9) | 1u, &snapshot); + (void)runtime_transform(0, snapshot, 1); + apply_button_mask((1u << 9) | 2u, &snapshot); + const auto output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 2 && + activation_attempts[0].profile_index == 1 && + activation_attempts[1].profile_index == 2 && + controller_profile_extract_button_mask(output.state) == 0, + "swapping shortcut selectors while holding the modifier lost the new action"); +} + } // namespace +bool bluepad32_input_backend_toggle_motion( + uint8_t slot, uint32_t connection_generation) { + last_motion_toggle_slot = slot; + last_motion_toggle_connection_generation = connection_generation; + ++motion_toggle_count; + return true; +} + uint32_t profile_service_database_generation() { return database_generation; } @@ -855,7 +1188,7 @@ ConfigurationTransactionStatus profile_service_activate_internal( --activation_busy_attempts; return ConfigurationTransactionStatus::kBusy; } - return ConfigurationTransactionStatus::kPending; + return activation_result; } @@ -889,8 +1222,15 @@ int main() { test_identity_promotion_preserves_held_switching(); test_switching_uses_pre_hotkey_buttons_only(); test_custom_switching_chord_and_wrap(); + test_profile_motion_toggle_supports_trigger_chords(); test_switching_slot_isolation(); test_all_runtime_cancellation_causes(); test_runtime_slot_synthetic_isolation(); + test_shortcut_arbitration_latching_and_commit_feedback(); + test_busy_shortcut_target_and_generation_isolation(); + test_cycle_motion_arbitration_and_held_refresh(); + test_shift_slot_and_context_resets(); + test_held_synthetic_sources_and_disconnect_rearming(); + test_shortcut_selector_rollover_without_modifier_release(); return 0; } diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index b9de8ed..ee0a187 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -45,9 +45,9 @@ void test_profile_wire_schema() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(profile, encoded, sizeof(encoded)), "default profile did not encode"); - require(encoded[0] == 2 && encoded[1] == 0 && - encoded[2] == 0 && encoded[3] == 1, - "profile header is not little-endian v2/256"); + require(encoded[0] == 6 && encoded[1] == 0 && + encoded[2] == 0x80 && encoded[3] == 1, + "profile header is not little-endian v6/384"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { require(encoded[4 + index] == index, @@ -64,25 +64,78 @@ void test_profile_wire_schema() { static_cast( CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), "default trigger encoding changed"); + require(encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + encoded[61] == 0 && + encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + encoded[71] == 0, + "default trigger mappings are not identity"); 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"); - } + encoded[74] == 3 && encoded[75] == 0 && + encoded[78] == 0 && encoded[79] == 0, + "default rumble or action encoding changed"); + require(encoded[96] == 0 && encoded[97] == 0 && + encoded[98] == 0x7c && encoded[99] == 0 && + encoded[100] == 0 && encoded[101] == 0, + "default sparse macro descriptor changed"); ControllerProfile decoded{}; require(controller_profile_decode(encoded, sizeof(encoded), &decoded), "default profile did not decode"); + ControllerProfile action_profile = profile; + action_profile.switching_chord = + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) | + (1u << static_cast( + ControllerProfileLogicalButton::kSouth)); + action_profile.motion_toggle_chord = + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); + action_profile.macros[0].trigger_mask = + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) | + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); + action_profile.macros[0].cancel_control = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + action_profile.macros[0].first_step = 0; + action_profile.macros[0].step_count = 1; + action_profile.macro_step_count = 1; + action_profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + for (uint8_t macro_index = 1; + macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) { + action_profile.macros[macro_index].first_step = 1; + } + action_profile.macro_steps[0].duration_ms = 25; + action_profile.macro_steps[0].output_button_mask = 1; + uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode( + action_profile, action_encoded, sizeof(action_encoded)), + "sparse action profile did not encode"); + require(action_encoded[75] == 0x21, + "sparse action extension encoding changed"); + require(action_encoded[96] == 0 && + action_encoded[97] == 0 && + action_encoded[98] == 0x43, + "sparse macro binding encoding changed"); + require(action_encoded[100] == 1 && + action_encoded[101] == 5, + "sparse macro step bounds changed"); + require(controller_profile_decode( + action_encoded, sizeof(action_encoded), &decoded), + "sparse action profile did not decode"); + require(decoded.switching_chord == + action_profile.switching_chord && + decoded.motion_toggle_chord == + action_profile.motion_toggle_chord && + decoded.macros[0].trigger_mask == + action_profile.macros[0].trigger_mask && + decoded.macros[0].cancel_control == + action_profile.macros[0].cancel_control && + decoded.macros[0].step_count == 1, + "sparse trigger-backed macro did not round-trip"); encoded[252] = 1; require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), "nonzero reserved profile byte was accepted"); ControllerProfile invalid = profile; - invalid.button_map[0] = 16; + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; require(!controller_profile_validate(invalid), "invalid direct output was accepted"); invalid = profile; @@ -121,23 +174,22 @@ void test_profile_wire_schema() { "reversed raw trigger range was accepted"); invalid = profile; invalid.turbo_modes[0] = - static_cast(3); + static_cast(4); require(!controller_profile_validate(invalid), "invalid Turbo mode was accepted"); invalid = profile; - invalid.macro_step_count = 2; - invalid.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + invalid.macros[0].first_step = 0; + invalid.macros[0].step_count = 1; + invalid.macro_step_count = 1; invalid.macro_steps[0].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS + 1; require(!controller_profile_validate(invalid), "unbounded macro wait was accepted"); invalid.macro_steps[0].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS; - invalid.macro_steps[1].type = - ControllerProfileMacroStepType::kState; + invalid.macros[1].first_step = 0; require(!controller_profile_validate(invalid), - "macro without a final end was accepted"); + "noncanonical shared macro pool was accepted"); } void test_legacy_profile_migration() { @@ -155,16 +207,9 @@ void test_legacy_profile_migration() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(migrated, encoded, sizeof(encoded)), "migrated default profile did not encode"); - for (size_t index = 0; index < sizeof(encoded); ++index) { - const bool 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(migrated.macros[0].step_count == 0 && + migrated.macro_step_count == 0, + "legacy end marker was not removed during migration"); require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION && encoded[58] == static_cast( @@ -177,8 +222,10 @@ void test_legacy_profile_migration() { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && encoded[69] == static_cast( - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), - "migrated default profile did not encode as v2"); + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && + encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL, + "migrated default profile did not encode as v6"); require(controller_profile_decode( kLegacyNarrowRawRangeProfile, @@ -195,17 +242,6 @@ void test_legacy_profile_migration() { "legacy narrow raw range or inherited threshold was not migrated"); require(controller_profile_encode(migrated, encoded, sizeof(encoded)), "migrated narrow-raw-range profile did not encode"); - for (size_t index = 0; index < sizeof(encoded); ++index) { - const bool 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, @@ -214,12 +250,67 @@ void test_legacy_profile_migration() { require(migrated.triggers[0].digital_threshold == 0x1234 && migrated.triggers[1].digital_threshold == 0xabcd, "legacy custom thresholds were not preserved"); + require(migrated.triggers[0].output == + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + migrated.triggers[1].output == + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL, + "legacy trigger identity mappings were not restored"); 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"); - } + const uint8_t legacy_macro_trigger = + kLegacyCustomThresholdProfile[78]; + const uint32_t expected_macro_trigger = + legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT + ? static_cast(1u << legacy_macro_trigger) + : 0; + require(migrated.macros[0].trigger_mask == + expected_macro_trigger && + migrated.macros[0].cancel_control == + kLegacyCustomThresholdProfile[79], + "legacy macro trigger was not migrated to descriptor zero"); + + uint8_t previous_encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{}; + memcpy(previous_encoded, kLegacyDefaultProfile, + sizeof(previous_encoded)); + previous_encoded[0] = static_cast( + CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION); + previous_encoded[58] = static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); + previous_encoded[59] = static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8); + previous_encoded[68] = static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); + previous_encoded[69] = static_cast( + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8); + previous_encoded[78] = static_cast( + ControllerProfileLogicalButton::kSouth); + previous_encoded[79] = static_cast( + ControllerProfileLogicalButton::kCapture); + require(controller_profile_decode( + previous_encoded, sizeof(previous_encoded), &migrated) && + migrated.triggers[0].output == + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + migrated.triggers[1].output == + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + migrated.macros[0].trigger_mask == + static_cast( + 1u << static_cast( + ControllerProfileLogicalButton::kSouth)) && + migrated.macros[0].cancel_control == + static_cast( + ControllerProfileLogicalButton::kCapture) && + migrated.motion_toggle_chord == 0, + "v2 profile controls did not migrate to v6"); + previous_encoded[80] = 2; + previous_encoded[100] = 0; + previous_encoded[101] = kControllerProfileOverrideButtons; + previous_encoded[102] = 25; + previous_encoded[104] = 1; + require(controller_profile_decode( + previous_encoded, sizeof(previous_encoded), &migrated) && + migrated.macros[0].step_count == 1 && + migrated.macro_steps[0].output_button_mask == 1, + "legacy nonempty macro did not migrate into shared pool"); ControllerProfile current = controller_profile_default(controller_identity_global(), 0); @@ -265,7 +356,7 @@ void test_database_round_trip_and_capacity() { require(encoded_database[4] == CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION && encoded_database[5] == 0, - "database encoder did not emit v2"); + "database encoder did not emit v3"); require(controller_profile_database_decode( read_encoded_database, nullptr, &decoded_database), "database did not decode"); @@ -279,10 +370,150 @@ void test_database_round_trip_and_capacity() { "nonzero database header reservation was accepted"); } +void test_set_b_sparse_extension_and_migration() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.shortcuts.modifier = 16; + profile.shortcuts.selectors[0] = 0; + profile.shortcuts.selectors[7] = 15; + profile.shift.mode = ControllerProfileShiftMode::kToggle; + profile.shift.modifier = 17; + profile.shift.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON; + profile.turbo_modes[15] = ControllerProfileTurboMode::kBurst; + profile.turbo_defaults = {30, 99, 255}; + profile.turbo_override_mask = (1u << 2) | (1u << 15); + profile.turbo_overrides[2] = {1, 1, 1}; + profile.turbo_overrides[15] = {23, 37, 17}; + profile.turbo_overrides[0] = {0, 0, 0}; + profile.macros[0] = {1, 0xff, 0, 8, ControllerProfileMacroMode::kRepeat, 255}; + profile.macro_step_count = 8; + for (uint8_t macro = 1; macro < CONTROLLER_PROFILE_MACRO_COUNT; ++macro) { + profile.macros[macro].first_step = 8; + profile.macros[macro].mode = static_cast(macro - 1); + } + for (uint8_t step = 0; step < 8; ++step) { + profile.macro_steps[step] = {31, 25, 1, -2, 3, -4, 5, 0x1234, 0xabcd}; + } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), &decoded), + "full sparse stream and Set B extension did not round trip"); + require(encoded[254] == 0xcd && encoded[255] == 0xab && + encoded[256] == 16 && encoded[264] == 15 && + encoded[265] == 2 && encoded[266] == 17 && + encoded[267] == 0xff && encoded[283] == 30 && + encoded[286] == 4 && encoded[287] == 0x80 && + encoded[288] == 1 && encoded[291] == 23 && + encoded[336] == 3 && encoded[337] == 255, + "Set B fields overlap macro data or use wrong sparse ordering"); + require(decoded.turbo_overrides[15].duty_percent == 37 && + decoded.macros[0].repeat_count == 255 && + decoded.shortcuts.selectors[7] == 15, + "Set B extension settings were not decoded"); + for (size_t offset = 294; offset < 336; ++offset) { + require(encoded[offset] == 0, "absent override was not canonical zero"); + } + encoded[294] = 1; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "nonzero sparse Turbo padding was accepted"); + encoded[294] = 0; + encoded[383] = 1; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "nonzero extension reservation was accepted"); + encoded[383] = 0; + encoded[337] = 0; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "zero macro repeat count was accepted"); + encoded[337] = 255; + uint8_t legacy[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{}; + memcpy(legacy, encoded, sizeof(legacy)); + legacy[0] = 5; + legacy[2] = 0; + legacy[95] = 2; + require(controller_profile_decode(legacy, sizeof(legacy), &decoded) && + decoded.macros[0].step_count == 8 && + decoded.macro_steps[7].right_trigger == 0xabcd && + decoded.macros[0].mode == ControllerProfileMacroMode::kOnce && + decoded.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON && + decoded.turbo_override_mask == 0, + "schema5 full136-byte macro stream did not migrate"); + require(controller_profile_encode(decoded, encoded, sizeof(encoded)) && + memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0, + "schema5 migration changed existing profile data"); + legacy[0] = 6; + require(!controller_profile_decode(legacy, sizeof(legacy), &decoded), + "schema6 accepted a legacy-sized payload"); + profile.shortcuts.selectors[7] = 0; + require(!controller_profile_validate(profile), "duplicate shortcut was accepted"); + profile.shortcuts.selectors[7] = 15; + profile.shift.button_map[0] = 16; + require(!controller_profile_validate(profile), "analog Shift output was accepted"); + profile.shift.button_map[0] = 0; + profile.turbo_overrides[2].rate_hz = 31; + require(!controller_profile_validate(profile), "out-of-range Turbo rate was accepted"); + profile.turbo_overrides[2].rate_hz = 1; + for (uint8_t step = 0; step < 8; ++step) { + profile.macro_steps[step].duration_ms = 0; + } + require(!controller_profile_validate(profile), "zero-duration looping macro was accepted"); + profile.macros[0].mode = ControllerProfileMacroMode::kOnce; + require(controller_profile_validate(profile), "zero-duration once macro was rejected"); +} + +void test_legacy_database_strides() { + for (uint8_t version = 1; version <= 2; ++version) { + const uint8_t count = version == 1 ? 4 : 8; + const size_t entry_size = 16 + count * 256; + const size_t entries_offset = 32 + count * 256; + const size_t total_size = entries_offset + 16 * entry_size; + memset(encoded_database, 0, sizeof(encoded_database)); + memcpy(encoded_database, "SPDB", 4); + encoded_database[4] = version; + encoded_database[6] = static_cast(total_size); + encoded_database[7] = static_cast(total_size >> 8); + encoded_database[8] = 16; + encoded_database[9] = count; + encoded_database[10] = count - 1; + encoded_database[11] = 1; + for (uint8_t index = 0; index < count; ++index) { + memcpy(&encoded_database[32 + index * 256], kLegacyDefaultProfile, 256); + } + const size_t last_entry = entries_offset + 15 * entry_size; + require(controller_identity_encode(identity(16), &encoded_database[last_entry], 14), + "legacy database identity did not encode"); + encoded_database[last_entry + 14] = count - 1; + encoded_database[last_entry + 15] = 1; + for (uint8_t index = 0; index < count; ++index) { + memcpy(&encoded_database[last_entry + 16 + index * 256], + kLegacyCustomThresholdProfile, 256); + } + require(controller_profile_database_decode( + read_encoded_database, nullptr, &decoded_database), + "legacy database strides were not preserved"); + const ControllerProfileDatabaseEntry* entry = + controller_profile_database_find(decoded_database, identity(16)); + require(entry != nullptr && entry->active_profile == count - 1 && + entry->profiles[count - 1].triggers[1].digital_threshold == 0xabcd, + "last legacy bank profile was read from the wrong offset"); + if (count == 4) { + require(entry->profiles[7].triggers[0].digital_threshold == + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + "new profile slots were not defaulted during migration"); + } + encoded_database[4] = 3; + require(!controller_profile_database_decode( + read_encoded_database, nullptr, &decoded_database), + "current database version accepted legacy strides"); + } +} + } // namespace int main() { test_profile_wire_schema(); test_legacy_profile_migration(); test_database_round_trip_and_capacity(); + test_set_b_sparse_extension_and_migration(); + test_legacy_database_strides(); return 0; } diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index bd68722..5f012e5 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -63,6 +63,8 @@ uint16_t transform_left_trigger( uint16_t value) { ControllerProfile profile = default_profile(); profile.triggers[0] = configuration; + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; ControllerState state{}; state.left_trigger = value; return controller_profile_transform(state, profile).state.left_trigger; @@ -107,9 +109,9 @@ void test_button_masks_and_direct_mapping() { "disabled button mapping still produced output"); ControllerProfile invalid = default_profile(); - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; require(!controller_profile_validate(invalid), - "logical output 16 was accepted"); + "logical output 18 was accepted"); invalid.button_map[0] = 0xfe; require(!controller_profile_validate(invalid), "logical output 0xfe was accepted"); @@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() { ControllerProfile profile = default_profile(); profile.triggers[0] = slow; + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; profile.triggers[0].digital_threshold = 12345; profile.triggers[1].digital_threshold = 54321; ControllerState state{}; @@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() { "profile-owned digital thresholds were not returned"); } +void test_trigger_and_button_cross_mapping() { + ControllerProfile profile = default_profile(); + profile.triggers[0].output = + static_cast(ControllerProfileLogicalButton::kSouth); + profile.triggers[0].digital_threshold = 30000; + profile.triggers[1].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.triggers[1].digital_threshold = 40000; + profile.button_map[ + static_cast(ControllerProfileLogicalButton::kEast)] = + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + + ControllerState input{}; + input.left_trigger = 29999; + input.right_trigger = 45000; + input.button_east = true; + ControllerProfileTransformResult output = + controller_profile_transform(input, profile); + require(!output.state.button_south && + output.state.left_trigger == 45000 && + output.state.right_trigger == UINT16_MAX && + output.left_trigger_digital_threshold == 40000, + "cross-mapped triggers lost analog or button output"); + + input.left_trigger = 30000; + output = controller_profile_transform(input, profile); + require(output.state.button_south, + "trigger threshold did not produce its mapped button"); + + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + require(!controller_profile_validate(profile), + "two analog triggers targeting one output were accepted"); +} + void test_default_whole_state_equivalence() { ControllerState input{}; input.dpad_up = true; @@ -365,6 +404,28 @@ void test_rumble_scaling_and_confirmation_policy() { UINT8_MAX, "full rumble scaling did not saturate at uint8 maximum"); + ControllerRumbleOutput hd_input{}; + hd_input.hd.actuators[0].sample_count = 2; + hd_input.hd.actuators[0].samples[0] = {48, 96, 18000, 10000}; + hd_input.hd.actuators[0].samples[1] = {49, 97, 31000, 0}; + hd_input.hd.actuators[1].sample_count = 1; + hd_input.hd.actuators[1].samples[0] = {20, 70, 12000, 10000}; + ControllerProfile hd_profile = profile; + hd_profile.strong_rumble_scale = 0; + hd_profile.weak_rumble_scale = 128; + const auto hd_output = controller_profile_scale_host_rumble(hd_input, hd_profile); + require(hd_output.hd.actuators[0].sample_count == 2 && + hd_output.hd.actuators[1].sample_count == 1 && + hd_output.hd.actuators[0].samples[1].low_frequency_index == 49 && + hd_output.hd.actuators[1].samples[0].high_frequency_index == 70, + "profile gain lost HD substeps or side-specific frequencies"); + require(hd_output.hd.actuators[0].samples[0].low_amplitude_q15 == 0 && + hd_output.hd.actuators[1].samples[0].low_amplitude_q15 == 0 && + hd_output.hd.actuators[0].samples[0].high_amplitude_q15 == 5020 && + hd_output.hd.actuators[1].samples[0].high_amplitude_q15 == 5020 && + hd_output.hd.actuators[0].samples[1].high_amplitude_q15 == 0, + "profile band gains were not applied to linear HD amplitudes"); + profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed; require(controller_profile_confirmation_policy(profile) == ControllerProfileConfirmationPolicy::kLed, @@ -378,6 +439,7 @@ int main() { test_stick_center_boundaries_and_inversion(); test_stick_curves_and_monotonicity(); test_trigger_boundaries_curves_and_thresholds(); + test_trigger_and_button_cross_mapping(); 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 index b2af3e6..352c6ee 100644 --- a/tests/controller_synthetic_input_test.cpp +++ b/tests/controller_synthetic_input_test.cpp @@ -39,36 +39,28 @@ ControllerProfile profile_with_macro( ControllerProfileLogicalButton::kCapture) { ControllerProfile profile = controller_profile_default(controller_identity_global(), 0); - profile.macro_trigger = button_index(trigger); - profile.macro_cancel = button_index(cancel); + profile.macros[0].trigger_mask = button_bit(trigger); + profile.macros[0].cancel_control = button_index(cancel); + profile.macros[0].first_step = 0; return profile; } -void set_end(ControllerProfile* profile, uint8_t index) { - profile->macro_steps[index] = {}; - profile->macro_steps[index].type = - ControllerProfileMacroStepType::kEnd; -} void test_immediate_press_release_dpad_and_explicit_end() { ControllerProfile profile = profile_with_macro(ControllerProfileLogicalButton::kSouth); - profile.macro_step_count = 3; - profile.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + profile.macro_step_count = 2; + profile.macros[0].step_count = 2; profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].output_button_mask = button_bit(ControllerProfileLogicalButton::kNorth) | button_bit(ControllerProfileLogicalButton::kDpadUp); - profile.macro_steps[1].type = - ControllerProfileMacroStepType::kState; profile.macro_steps[1].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[1].duration_ms = 20; profile.macro_steps[1].output_button_mask = 0; - set_end(&profile, 2); ControllerSyntheticInputContext context{}; ControllerState input = @@ -100,12 +92,58 @@ void test_immediate_press_release_dpad_and_explicit_end() { "explicit end did not clear overrides and restore physical input"); } +void test_macro_trigger_chord_requires_every_button() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSouth); + profile.macros[0].trigger_mask = + button_bit(ControllerProfileLogicalButton::kSouth) | + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + profile.macros[0].cancel_control = + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + profile.macro_step_count = 1; + profile.macros[0].step_count = 1; + profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + profile.macro_steps[0].duration_ms = 100; + profile.macro_steps[0].output_button_mask = + button_bit(ControllerProfileLogicalButton::kNorth); + + ControllerSyntheticInputContext context{}; + ControllerState input = + state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)); + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, input, profile, 0); + require(!context.macro_active && + has_button(output, ControllerProfileLogicalButton::kSouth), + "partial macro chord triggered or consumed a normal button"); + + input = state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)); + input.left_trigger = UINT16_MAX; + output = controller_synthetic_input_apply( + &context, input, profile, 1); + require(context.macro_active && + has_button(output, ControllerProfileLogicalButton::kNorth) && + !has_button(output, ControllerProfileLogicalButton::kSouth) && + output.state.left_trigger == 0, + "completed trigger-backed macro chord did not consume inputs"); + + input = controller_neutral_state(); + input.right_trigger = UINT16_MAX; + output = controller_synthetic_input_apply( + &context, input, profile, 2); + require(!context.macro_active && + output.state.right_trigger == 0, + "trigger-backed macro cancellation leaked or stayed active"); +} + void test_optional_field_overrides_and_motion_preservation() { ControllerProfile profile = profile_with_macro(ControllerProfileLogicalButton::kSelect); - profile.macro_step_count = 2; + profile.macro_step_count = 1; + profile.macros[0].step_count = 1; ControllerProfileMacroStep& step = profile.macro_steps[0]; - step.type = ControllerProfileMacroStepType::kState; step.override_flags = kControllerProfileOverrideLeftStick | kControllerProfileOverrideRightStick | kControllerProfileOverrideLeftTrigger | @@ -117,7 +155,6 @@ void test_optional_field_overrides_and_motion_preservation() { step.right_stick_y = INT16_MAX; step.left_trigger = 0; step.right_trigger = UINT16_MAX; - set_end(&profile, 1); ControllerState input = state_with_buttons( button_bit(ControllerProfileLogicalButton::kSelect) | @@ -155,29 +192,23 @@ void test_optional_field_overrides_and_motion_preservation() { void test_zero_max_wait_and_scheduled_catch_up() { ControllerProfile profile = profile_with_macro(ControllerProfileLogicalButton::kSouth); - profile.macro_step_count = 4; - profile.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + profile.macro_step_count = 3; + profile.macros[0].step_count = 3; profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[0].duration_ms = 0; profile.macro_steps[0].output_button_mask = button_bit(ControllerProfileLogicalButton::kNorth); - profile.macro_steps[1].type = - ControllerProfileMacroStepType::kState; profile.macro_steps[1].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[1].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS; profile.macro_steps[1].output_button_mask = button_bit(ControllerProfileLogicalButton::kEast); - profile.macro_steps[2].type = - ControllerProfileMacroStepType::kState; profile.macro_steps[2].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[2].duration_ms = 0; profile.macro_steps[2].output_button_mask = button_bit(ControllerProfileLogicalButton::kWest); - set_end(&profile, 3); ControllerSyntheticInputContext context{}; ControllerProfileTransformResult output = @@ -204,7 +235,8 @@ void test_zero_max_wait_and_scheduled_catch_up() { !context.macro_active, "zero-duration catch-up did not reach the explicit end"); - profile.macro_step_count = 4; + profile.macro_step_count = 3; + profile.macros[0].step_count = 3; profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].output_button_mask = button_bit(ControllerProfileLogicalButton::kNorth); @@ -229,11 +261,11 @@ void test_zero_max_wait_and_scheduled_catch_up() { controller_profile_extract_button_mask(output.state) == 0, "large time jump did not finish the bounded macro"); - profile.macro_step_count = 2; + profile.macro_step_count = 1; + profile.macros[0].step_count = 1; profile.macro_steps[0].duration_ms = 10; profile.macro_steps[0].output_button_mask = button_bit(ControllerProfileLogicalButton::kNorth); - set_end(&profile, 1); context = {}; constexpr uint32_t macro_near_wrap = UINT32_MAX - 5u; output = controller_synthetic_input_apply( @@ -257,15 +289,13 @@ void test_consumption_cancel_precedence_and_duplicate_contributors() { ControllerProfile profile = profile_with_macro(ControllerProfileLogicalButton::kSelect, ControllerProfileLogicalButton::kCapture); - profile.macro_step_count = 2; - profile.macro_steps[0].type = - ControllerProfileMacroStepType::kState; + profile.macro_step_count = 1; + profile.macros[0].step_count = 1; profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; profile.macro_steps[0].duration_ms = 100; profile.macro_steps[0].output_button_mask = button_bit(ControllerProfileLogicalButton::kDpadLeft); - set_end(&profile, 1); profile.button_map[button_index(ControllerProfileLogicalButton::kSouth)] = button_index(ControllerProfileLogicalButton::kNorth); profile.button_map[button_index(ControllerProfileLogicalButton::kEast)] = @@ -421,6 +451,53 @@ void test_auto_burst_toggle_cancel_and_external_cancel() { "Auto Burst did not restart after release following cancellation"); } +void test_multiple_macro_bindings_share_step_pool() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.macros[0] = { + button_bit(ControllerProfileLogicalButton::kSouth), + CONTROLLER_PROFILE_NO_BUTTON, 0, 1}; + profile.macros[1] = { + button_bit(ControllerProfileLogicalButton::kEast), + CONTROLLER_PROFILE_NO_BUTTON, 1, 1}; + profile.macros[2].first_step = 2; + profile.macros[3].first_step = 2; + profile.macro_step_count = 2; + profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + profile.macro_steps[0].duration_ms = 1; + profile.macro_steps[0].output_button_mask = + button_bit(ControllerProfileLogicalButton::kNorth); + profile.macro_steps[1].override_flags = + kControllerProfileOverrideButtons; + profile.macro_steps[1].duration_ms = 1; + profile.macro_steps[1].output_button_mask = + button_bit(ControllerProfileLogicalButton::kWest); + + ControllerSyntheticInputContext context{}; + ControllerProfileTransformResult output = + controller_synthetic_input_apply( + &context, + state_with_buttons( + button_bit(ControllerProfileLogicalButton::kEast)), + profile, 0); + require(context.macro_active && context.macro_index == 1 && + has_button(output, ControllerProfileLogicalButton::kWest), + "second macro descriptor did not execute its shared step"); + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 1); + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 2); + output = controller_synthetic_input_apply( + &context, + state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)), + profile, 3); + require(context.macro_active && context.macro_index == 0 && + has_button(output, ControllerProfileLogicalButton::kNorth), + "first macro descriptor did not execute independently"); +} + void test_four_contexts_are_isolated() { ControllerProfile profile = controller_profile_default(controller_identity_global(), 0); @@ -450,15 +527,231 @@ void test_four_contexts_are_isolated() { "cancelling one slot changed another slot's Auto Burst state"); } +void test_parameterized_turbo_and_finite_burst() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.turbo_defaults = {10, 25, 3}; + profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst; + profile.turbo_modes[1] = ControllerProfileTurboMode::kTurbo; + profile.turbo_override_mask = 1u << 1; + profile.turbo_overrides[1] = {20, 80, 1}; + profile.button_map[0] = 3; + profile.button_map[1] = 2; + ControllerSyntheticInputContext context{}; + const ControllerState pressed = state_with_buttons(3); + auto output = controller_synthetic_input_apply(&context, pressed, profile, 0); + require(output.state.button_north && output.state.button_west, + "parameterized bindings did not start immediately"); + output = controller_synthetic_input_apply(&context, pressed, profile, 24); + require(output.state.button_north && output.state.button_west, + "duty window ended early"); + output = controller_synthetic_input_apply(&context, pressed, profile, 25); + require(!output.state.button_north && output.state.button_west, + "defaults or physical-source override selected the wrong duty"); + output = controller_synthetic_input_apply(&context, pressed, profile, 40); + require(!output.state.button_north && !output.state.button_west, + "override duty boundary remained ON"); + output = controller_synthetic_input_apply(&context, pressed, profile, 50); + require(!output.state.button_north && output.state.button_west, + "override period followed the default rate"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 100); + require(output.state.button_north && !output.state.button_west, + "finite Burst release cancelled its windows or hold Turbo latched"); + output = controller_synthetic_input_apply(&context, pressed, profile, 101); + require(output.state.button_north, "fresh Burst press did not restart"); + output = controller_synthetic_input_apply(&context, pressed, profile, 325); + require(output.state.button_north, "third Burst ON window ended early"); + output = controller_synthetic_input_apply(&context, pressed, profile, 326); + require(!output.state.button_north, "Burst did not stop at final ON end"); + output = controller_synthetic_input_apply(&context, pressed, profile, 401); + require(!output.state.button_north, "held completed Burst rearmed itself"); + + context = {}; + profile.turbo_defaults.burst_count = 1; + constexpr uint32_t start = UINT32_MAX - 10u; + (void)controller_synthetic_input_apply(&context, pressed, profile, start); + output = controller_synthetic_input_apply(&context, pressed, profile, 13); + require(output.state.button_north, "one-window Burst ended early at wrap"); + output = controller_synthetic_input_apply(&context, pressed, profile, 14); + require(!output.state.button_north, "one-window Burst crossed its deadline"); + context = {}; + profile.turbo_defaults = {1, 99, 255}; + (void)controller_synthetic_input_apply(&context, pressed, profile, 0); + output = controller_synthetic_input_apply(&context, pressed, profile, 254989); + require(output.state.button_north, "maximum Burst lost its final window"); + output = controller_synthetic_input_apply(&context, pressed, profile, 254990); + require(!output.state.button_north, "maximum Burst count overflowed"); + context = {}; + (void)controller_synthetic_input_apply(&context, pressed, profile, 0); + output = controller_synthetic_input_apply( + &context, pressed, profile, 4000000000u); + require(!output.state.button_north && output.state.button_west, + "long-gap catch-up replayed missed Burst pulses or overflowed Turbo"); + context = {}; + profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo; + profile.turbo_defaults = {30, 1, 1}; + output = controller_synthetic_input_apply(&context, pressed, profile, 0); + require(output.state.button_north, "narrow Turbo duty did not start ON"); + output = controller_synthetic_input_apply(&context, pressed, profile, 1); + require(!output.state.button_north, + "narrow duty was silently clamped to the polling cadence"); + output = controller_synthetic_input_apply(&context, pressed, profile, 100); + require(output.state.button_north, "narrow duty lost its absolute cycle phase"); + context = {}; + profile.turbo_defaults = {30, 99, 1}; + (void)controller_synthetic_input_apply(&context, pressed, profile, 0); + output = controller_synthetic_input_apply(&context, pressed, profile, 33); + require(!output.state.button_north, "99-percent duty included its OFF boundary"); + output = controller_synthetic_input_apply(&context, pressed, profile, 34); + require(output.state.button_north, "fractional period accumulated polling drift"); +} + +void test_shift_maps_consumption_and_physical_bindings() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSelect); + profile.shift.mode = ControllerProfileShiftMode::kHold; + profile.shift.modifier = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.shift.button_map[0] = 3; + profile.button_map[0] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + profile.sticks[0].invert_x = true; + profile.triggers[1].lower_deadzone = 1000; + profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo; + profile.turbo_defaults = {10, 20, 3}; + ControllerState input = state_with_buttons(1); + input.left_trigger = UINT16_MAX; + input.right_trigger = 32768; + input.left_stick_x = 12345; + const auto base = controller_profile_transform(input, profile); + ControllerSyntheticInputContext context{}; + auto output = controller_synthetic_input_apply(&context, input, profile, 0); + require(output.state.button_north && !output.state.button_south && + output.state.left_trigger == 0 && + output.state.right_trigger < UINT16_MAX && + output.state.left_stick_x == base.state.left_stick_x, + "Shift did not consume analog modifier or replaced base analog tuning"); + const uint16_t tuned_trigger = output.state.right_trigger; + output = controller_synthetic_input_apply(&context, input, profile, 20); + require(!output.state.button_north && + output.state.right_trigger == tuned_trigger, + "Shift moved Turbo settings to the mapped output"); + input.left_trigger = 0; + output = controller_synthetic_input_apply(&context, input, profile, 100); + require(!output.state.button_north && + output.state.right_trigger == UINT16_MAX, + "releasing hold Shift did not restore base button-to-trigger mapping"); + + context = {}; + profile.shift.mode = ControllerProfileShiftMode::kToggle; + profile.shift.modifier = button_index(ControllerProfileLogicalButton::kSelect); + profile.turbo_modes[0] = ControllerProfileTurboMode::kOff; + profile.macros[0].step_count = profile.macro_step_count = 1; + profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 2}; + input = state_with_buttons(1u | (1u << profile.shift.modifier)); + output = controller_synthetic_input_apply(&context, input, profile, 0); + require(output.state.button_north && !output.state.button_select && + !output.state.button_east, + "Shift modifier leaked into mapping or its competing macro"); + output = controller_synthetic_input_apply(&context, input, profile, 1); + require(output.state.button_north, "held toggle Shift retriggered"); + output = controller_synthetic_input_apply( + &context, state_with_buttons(1), profile, 2); + require(output.state.button_north, "toggle Shift did not latch after release"); + output = controller_synthetic_input_apply(&context, input, profile, 3); + require(!output.state.button_north && output.state.right_trigger == UINT16_MAX, + "second Shift rising edge did not restore base mapping"); + controller_synthetic_input_cancel( + &context, controller_profile_extract_control_mask(input, profile)); + output = controller_synthetic_input_apply(&context, input, profile, 4); + require(!output.state.button_north, "cancel phantom-toggled a held modifier"); + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 5); + output = controller_synthetic_input_apply(&context, input, profile, 6, 0, true); + require(!output.state.button_north, "winning hotkey did not suppress Shift"); + output = controller_synthetic_input_apply(&context, input, profile, 7); + require(!output.state.button_north, "suppression release phantom-toggled Shift"); +} + +void test_macro_playback_modes_and_bounded_cycle_skips() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSouth); + profile.macros[0].trigger_mask |= 1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.macros[0].step_count = profile.macro_step_count = 3; + profile.macro_steps[0] = {kControllerProfileOverrideButtons, 10, 8}; + profile.macro_steps[1] = {kControllerProfileOverrideButtons, 0, 2}; + profile.macro_steps[2] = {kControllerProfileOverrideButtons, 20, 4}; + ControllerState held = state_with_buttons(1); + held.left_trigger = UINT16_MAX; + ControllerSyntheticInputContext context{}; + profile.macros[0].mode = ControllerProfileMacroMode::kWhileHeld; + auto output = controller_synthetic_input_apply(&context, held, profile, 0); + require(output.state.button_north && output.state.left_trigger == 0, + "while-held macro did not consume its entire physical trigger"); + output = controller_synthetic_input_apply(&context, held, profile, 10); + require(output.state.button_west && !output.state.button_east, + "zero-duration interior step delayed the next timed state"); + output = controller_synthetic_input_apply(&context, held, profile, 30); + require(output.state.button_north, "while-held macro did not repeat"); + output = controller_synthetic_input_apply( + &context, state_with_buttons(1), profile, 31); + require(!output.state.button_north && !output.state.button_west, + "partial trigger release did not stop while-held playback"); + output = controller_synthetic_input_apply(&context, held, profile, 32); + require(output.state.button_north, "completed trigger did not rearm playback"); + + context = {}; + profile.macros[0].mode = ControllerProfileMacroMode::kRepeat; + profile.macros[0].repeat_count = 3; + (void)controller_synthetic_input_apply(&context, held, profile, 0); + output = controller_synthetic_input_apply(&context, held, profile, 89); + require(output.state.button_west, "repeat count omitted its last cycle"); + output = controller_synthetic_input_apply(&context, held, profile, 90); + require(!output.state.button_west && !output.state.button_north, + "finite macro repeated past its exact cycle count"); + output = controller_synthetic_input_apply(&context, held, profile, 120); + require(!output.state.button_west && !output.state.button_north, + "held finite macro phantom-restarted after completing"); + + context = {}; + profile.macros[0].mode = ControllerProfileMacroMode::kToggle; + constexpr uint32_t start = UINT32_MAX - 5u; + (void)controller_synthetic_input_apply(&context, held, profile, start); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 4); + require(output.state.button_west, "toggle macro lost phase over clock wrap"); + output = controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 3999999994u); + require(output.state.button_west, + "long-gap toggle macro overflowed or iterated missed cycles"); + output = controller_synthetic_input_apply( + &context, held, profile, 3999999995u); + require(!output.state.button_north && !output.state.button_west && + output.state.left_trigger == 0, + "matching rising trigger did not toggle active macro off"); + (void)controller_synthetic_input_apply( + &context, controller_neutral_state(), profile, 3999999996u); + (void)controller_synthetic_input_apply( + &context, held, profile, 3999999997u); + output = controller_synthetic_input_apply( + &context, state_with_buttons(1u << 9), profile, 3999999998u); + require(controller_profile_extract_button_mask(output.state) == 0, + "configured cancel did not kill looping macro output"); +} + } // namespace int main() { test_immediate_press_release_dpad_and_explicit_end(); + test_macro_trigger_chord_requires_every_button(); 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_multiple_macro_bindings_share_step_pool(); test_auto_burst_toggle_cancel_and_external_cancel(); test_four_contexts_are_isolated(); + test_parameterized_turbo_and_finite_burst(); + test_shift_maps_consumption_and_physical_bindings(); + test_macro_playback_modes_and_bounded_cycle_skips(); return 0; } diff --git a/tests/cyw43_packet_transport_native_stubs/cybt_shared_bus_driver.h b/tests/cyw43_packet_transport_native_stubs/cybt_shared_bus_driver.h new file mode 100644 index 0000000..502c6c2 --- /dev/null +++ b/tests/cyw43_packet_transport_native_stubs/cybt_shared_bus_driver.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +/* Subset of the Pico SDK's exposed shared-bus ABI used by this transport. */ +#define BTSDIO_FWBUF_SIZE 0x1000 + +typedef enum { + CYBT_SUCCESS = 0, + CYBT_ERR_BADARG = 0xB1, + CYBT_ERR_TIMEOUT = 0xB3, + CYBT_ERR_HCI_READ_FAILED = 0xB9, +} cybt_result_t; + +typedef enum { + H2B_BUF_ADDR_IDX = 0x10, + H2B_BUF_IN_ADDR_IDX, + H2B_BUF_OUT_ADDR_IDX, + B2H_BUF_ADDR_IDX, + B2H_BUF_IN_ADDR_IDX, + B2H_BUF_OUT_ADDR_IDX, +} cybt_addr_idx_t; + +typedef struct { + uint32_t host2bt_in_val; + uint32_t host2bt_out_val; + uint32_t bt2host_in_val; + uint32_t bt2host_out_val; +} cybt_fw_membuf_index_t; + +cybt_result_t cybt_set_bt_awake(int value); +int cybt_awake(void); +cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *indices); +cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset, + uint8_t *buffer, uint32_t length); +cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value); +cybt_result_t cybt_toggle_bt_intr(void); diff --git a/tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h b/tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h new file mode 100644 index 0000000..d544dfa --- /dev/null +++ b/tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +typedef struct _cyw43_ll_t { + int unused; +} cyw43_ll_t; + +int cyw43_btbus_init(cyw43_ll_t *self); +int cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size); diff --git a/tests/cyw43_packet_transport_native_stubs/cyw43_config.h b/tests/cyw43_packet_transport_native_stubs/cyw43_config.h new file mode 100644 index 0000000..9d9faa5 --- /dev/null +++ b/tests/cyw43_packet_transport_native_stubs/cyw43_config.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +void cyw43_thread_enter(void); +void cyw43_thread_exit(void); +void cyw43_delay_ms(uint32_t milliseconds); +_Noreturn void panic(const char *message, ...); + +#define CYW43_THREAD_ENTER cyw43_thread_enter(); +#define CYW43_THREAD_EXIT cyw43_thread_exit(); diff --git a/tests/cyw43_packet_transport_native_stubs/sdk.c b/tests/cyw43_packet_transport_native_stubs/sdk.c new file mode 100644 index 0000000..da864a0 --- /dev/null +++ b/tests/cyw43_packet_transport_native_stubs/sdk.c @@ -0,0 +1,8 @@ +#include "cyw43_btbus.h" + +int test_cyw43_btbus_init(cyw43_ll_t *self); + +/* Separate translation unit: GNU --wrap only redirects unresolved references. */ +int cyw43_btbus_init(cyw43_ll_t *self) { + return test_cyw43_btbus_init(self); +} diff --git a/tests/cyw43_packet_transport_test.c b/tests/cyw43_packet_transport_test.c new file mode 100644 index 0000000..c1504ed --- /dev/null +++ b/tests/cyw43_packet_transport_test.c @@ -0,0 +1,409 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "cyw43_btbus.h" +#include "cyw43_config.h" +#include "cybt_shared_bus_driver.h" + +static uint8_t ring[BTSDIO_FWBUF_SIZE]; +static cybt_fw_membuf_index_t indices; +static unsigned lock_depth; +static unsigned enters; +static unsigned exits; +static unsigned wake_polls; +static unsigned delays; +static unsigned snapshots; +static unsigned reads; +static unsigned publications; +static unsigned notifications; +static unsigned fail_read; +static bool fail_wake; +static bool fail_snapshot; +static bool fail_publication; +static bool fail_notification; +static int awake_value; +static bool append_on_header; +static bool expect_panic; +static jmp_buf panic_jump; + +void cyw43_thread_enter(void) { + ++lock_depth; + ++enters; +} + +void cyw43_thread_exit(void) { + assert(lock_depth != 0); + --lock_depth; + ++exits; +} + +void cyw43_delay_ms(uint32_t milliseconds) { + assert(lock_depth != 0); + assert(milliseconds == 1); + ++delays; +} + +_Noreturn void panic(const char *message, ...) { + if (!expect_panic) { + fprintf(stderr, "unexpected panic: %s\n", message); + abort(); + } + assert(strcmp(message, "cyw43 buffer overflow") == 0); + longjmp(panic_jump, 1); +} + +int test_cyw43_btbus_init(cyw43_ll_t *self) { + (void)self; + assert(lock_depth != 0); + memset(&indices, 0, sizeof(indices)); + return 0; +} + +cybt_result_t cybt_set_bt_awake(int value) { + assert(lock_depth != 0); + assert(value == 1); + return fail_wake ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS; +} + +int cybt_awake(void) { + assert(lock_depth != 0); + ++wake_polls; + return awake_value; +} + +cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *out) { + assert(lock_depth != 0); + ++snapshots; + if (fail_snapshot) { + return CYBT_ERR_HCI_READ_FAILED; + } + *out = indices; + return CYBT_SUCCESS; +} + +static uint32_t put_packet(uint32_t offset, uint32_t payload_length, uint8_t type) { + uint32_t wire_length = 4 + ((payload_length + 3) & ~3u); + assert(wire_length <= BTSDIO_FWBUF_SIZE); + for (uint32_t byte = 0; byte < wire_length; ++byte) { + uint8_t value = (uint8_t)(byte - 4); + if (byte < 3) { + value = (uint8_t)(payload_length >> (byte * 8)); + } else if (byte == 3) { + value = type; + } + ring[(offset + byte) & (BTSDIO_FWBUF_SIZE - 1)] = value; + } + return (offset + wire_length) & (BTSDIO_FWBUF_SIZE - 1); +} + +cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset, + uint8_t *buffer, uint32_t length) { + assert(lock_depth != 0); + assert(index == B2H_BUF_ADDR_IDX); + assert(length >= 4 && (length & 3) == 0); + assert((offset & 3) == 0 && offset + length <= BTSDIO_FWBUF_SIZE); + assert(((uintptr_t)buffer & 3) == 0); + ++reads; + if (reads == fail_read) { + /* Model a failed SPI transfer that has already modified its destination. */ + buffer[0] = 0xEF; + return CYBT_ERR_HCI_READ_FAILED; + } + memcpy(buffer, ring + offset, length); + if (append_on_header && reads == 1) { + indices.bt2host_in_val = put_packet(indices.bt2host_in_val, 4, 2); + } + return CYBT_SUCCESS; +} + +cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value) { + assert(lock_depth != 0); + assert(index == B2H_BUF_OUT_ADDR_IDX); + assert(value < BTSDIO_FWBUF_SIZE && (value & 3) == 0); + ++publications; + if (fail_publication) { + return CYBT_ERR_HCI_READ_FAILED; + } + indices.bt2host_out_val = value; + return CYBT_SUCCESS; +} + +cybt_result_t cybt_toggle_bt_intr(void) { + assert(lock_depth != 0); + assert(publications > notifications); + ++notifications; + return fail_notification ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS; +} + +static void reset(void) { + lock_depth = 0; + expect_panic = false; + assert(cyw43_btbus_init(NULL) == 0); + memset(ring, 0, sizeof(ring)); + enters = exits = wake_polls = delays = snapshots = reads = publications = notifications = 0; + fail_read = 0; + fail_wake = fail_snapshot = fail_publication = fail_notification = false; + append_on_header = false; + awake_value = 1; +} + +static void check_unconsumed(uint32_t original_out) { + assert(indices.bt2host_out_val == original_out); + assert(notifications == 0); + assert(lock_depth == 0 && enters == exits); +} + +static void check_packet(const uint8_t *buffer, uint32_t length, uint8_t type) { + assert(buffer[0] == (uint8_t)length); + assert(buffer[1] == (uint8_t)(length >> 8)); + assert(buffer[2] == (uint8_t)(length >> 16)); + assert(buffer[3] == type); + for (uint32_t byte = 0; byte < length; ++byte) { + assert(buffer[4 + byte] == (uint8_t)byte); + } +} + +static void test_empty_and_partial(void) { + _Alignas(4) uint8_t buffer[32]; + uint32_t size = 123; + reset(); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0); + assert(snapshots == 1 && reads == 0 && publications == 0); + check_unconsumed(0); + + uint32_t end = put_packet(0, 5, 4); + for (uint32_t published = 1; published <= 3; ++published) { + indices.bt2host_in_val = published; + size = 123; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0); + assert(reads == 0 && publications == 0); + check_unconsumed(0); + } + const uint32_t partial_lengths[] = {4, 8, 9, 11}; + for (unsigned i = 0; i < sizeof(partial_lengths) / sizeof(partial_lengths[0]); ++i) { + indices.bt2host_in_val = partial_lengths[i]; + size = 123; + unsigned before_reads = reads; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0); + assert(reads == before_reads + 1 && publications == 0); + check_unconsumed(0); + } + indices.bt2host_in_val = end; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9); + check_packet(buffer, 5, 4); + assert(indices.bt2host_out_val == end && publications == 1 && notifications == 1); +} + +static void test_complete_wrapped_and_full(void) { + _Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE]; + const uint32_t starts[] = {0, BTSDIO_FWBUF_SIZE - 4, BTSDIO_FWBUF_SIZE - 8}; + for (unsigned i = 0; i < sizeof(starts) / sizeof(starts[0]); ++i) { + reset(); + indices.bt2host_out_val = starts[i]; + indices.bt2host_in_val = put_packet(starts[i], 9, 2); + uint32_t size = 0; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13); + check_packet(buffer, 9, 2); + assert(indices.bt2host_out_val == indices.bt2host_in_val); + assert(snapshots == 1 && publications == 1 && notifications == 1); + assert(lock_depth == 0 && enters == exits); + } + + reset(); + indices.bt2host_in_val = put_packet(0, BTSDIO_FWBUF_SIZE - 8, 2); + uint32_t size = 0; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0); + assert(size == BTSDIO_FWBUF_SIZE - 4); + check_packet(buffer, BTSDIO_FWBUF_SIZE - 8, 2); + assert(indices.bt2host_out_val == indices.bt2host_in_val); + + reset(); + indices.bt2host_in_val = put_packet(0, 0, 0); + assert(cyw43_btbus_read(buffer, 4, &size) == 0 && size == 4); + assert(reads == 1 && publications == 1 && notifications == 1); +} + +static void test_exact_capacity_and_snapshot_growth(void) { + _Alignas(4) uint8_t buffer[32]; + uint32_t size = 0; + reset(); + memset(buffer, 0xA5, sizeof(buffer)); + indices.bt2host_in_val = put_packet(0, 5, 4); + assert(cyw43_btbus_read(buffer, 9, &size) == 0 && size == 9); + check_packet(buffer, 5, 4); + for (unsigned i = 9; i < sizeof(buffer); ++i) { + assert(buffer[i] == 0xA5); + } + + reset(); + indices.bt2host_in_val = put_packet(0, 5, 4); + uint32_t first_end = indices.bt2host_in_val; + append_on_header = true; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9); + check_packet(buffer, 5, 4); + assert(indices.bt2host_out_val == first_end && snapshots == 1); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 8); + check_packet(buffer, 4, 2); + assert(indices.bt2host_out_val == indices.bt2host_in_val); + assert(publications == 2 && notifications == 2); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0); + assert(publications == 2 && notifications == 2); +} + +static void test_oversized_and_invalid(void) { + _Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE * 2]; + uint32_t size = 123; + reset(); + indices.bt2host_in_val = put_packet(0, 5, 4); + assert(cyw43_btbus_read(buffer, 8, &size) != 0 && size == 0); + check_unconsumed(0); + assert(reads == 1 && publications == 0); + ring[0] = ring[1] = ring[2] = 0xFF; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(0); + + reset(); + /* This would occupy the reserved word and make full indistinguishable from empty. */ + put_packet(0, BTSDIO_FWBUF_SIZE - 4, 2); + indices.bt2host_in_val = 4; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(0); + + reset(); + assert(cyw43_btbus_read(NULL, sizeof(buffer), &size) != 0 && size == 0); + assert(cyw43_btbus_read(buffer, sizeof(buffer), NULL) != 0); + assert(cyw43_btbus_read(buffer + 1, sizeof(buffer) - 1, &size) != 0); + for (uint32_t capacity = 0; capacity < 4; ++capacity) { + assert(cyw43_btbus_read(buffer, capacity, &size) != 0 && size == 0); + } + assert(enters == 0 && snapshots == 0 && publications == 0); + check_unconsumed(0); + + for (unsigned field = 0; field < 4; ++field) { + reset(); + switch (field) { + case 0: indices.host2bt_in_val = BTSDIO_FWBUF_SIZE; break; + case 1: indices.host2bt_out_val = BTSDIO_FWBUF_SIZE; break; + case 2: indices.bt2host_in_val = BTSDIO_FWBUF_SIZE; break; + case 3: indices.bt2host_out_val = BTSDIO_FWBUF_SIZE; break; + } + uint32_t original_out = indices.bt2host_out_val; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(original_out); + assert(reads == 0 && publications == 0); + } + reset(); + indices.bt2host_out_val = 1; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(1); + reset(); + indices.bt2host_in_val = BTSDIO_FWBUF_SIZE - 1; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(0); +} + +static void test_status_failures_and_retry(void) { + _Alignas(4) uint8_t buffer[32]; + uint32_t size = 123; + reset(); + fail_wake = true; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + assert(snapshots == 0 && wake_polls == 0); + check_unconsumed(0); + reset(); + awake_value = -1; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + assert(snapshots == 0 && wake_polls == 1); + check_unconsumed(0); + reset(); + awake_value = 0; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + assert(snapshots == 0 && wake_polls == 301 && delays == 301); + check_unconsumed(0); + reset(); + fail_snapshot = true; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + assert(reads == 0 && publications == 0); + check_unconsumed(0); + + /* Fail header, first wrapped payload segment, then second payload segment. */ + for (unsigned failure = 1; failure <= 3; ++failure) { + reset(); + indices.bt2host_out_val = BTSDIO_FWBUF_SIZE - 8; + indices.bt2host_in_val = put_packet(indices.bt2host_out_val, 9, 2); + fail_read = failure; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + assert(publications == 0); + check_unconsumed(BTSDIO_FWBUF_SIZE - 8); + fail_read = 0; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13); + check_packet(buffer, 9, 2); + assert(indices.bt2host_out_val == indices.bt2host_in_val); + } + reset(); + indices.bt2host_in_val = put_packet(0, 5, 4); + fail_read = 3; /* Final padded word when the caller provides exact capacity. */ + assert(cyw43_btbus_read(buffer, 9, &size) != 0 && size == 0); + assert(publications == 0); + check_unconsumed(0); + + reset(); + indices.bt2host_in_val = put_packet(0, 5, 4); + fail_publication = true; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + check_unconsumed(0); + assert(publications == 1); + + reset(); + indices.bt2host_in_val = put_packet(0, 5, 4); + fail_notification = true; + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0); + /* Notification follows the irreversible commit; rollback would violate ownership. */ + assert(indices.bt2host_out_val == indices.bt2host_in_val); + assert(publications == 1 && notifications == 1); + assert(lock_depth == 0 && enters == exits); +} + +static void test_overflow_and_controller_reset(void) { + _Alignas(4) uint8_t buffer[32]; + uint32_t size = 0; + reset(); + uint32_t first_end = put_packet(0, 5, 4); + indices.bt2host_in_val = put_packet(first_end, 8, 2); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9); + assert(indices.bt2host_out_val == first_end); + indices.bt2host_in_val -= 4; /* Published unread data disappeared. */ + expect_panic = true; + if (setjmp(panic_jump) == 0) { + cyw43_btbus_read(buffer, sizeof(buffer), &size); + assert(false && "overflow must retain the SDK's fatal check"); + } + expect_panic = false; + assert(indices.bt2host_out_val == first_end); + assert(publications == 1 && notifications == 1); + lock_depth = 0; /* panic does not return on hardware. */ + + reset(); + first_end = put_packet(0, 5, 4); + indices.bt2host_in_val = put_packet(first_end, 8, 2); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9); + assert(cyw43_btbus_init(NULL) == 0); + assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0); + assert(indices.bt2host_out_val == 0 && lock_depth == 0); +} + +int main(void) { + test_empty_and_partial(); + test_complete_wrapped_and_full(); + test_exact_capacity_and_snapshot_growth(); + test_oversized_and_invalid(); + test_status_failures_and_retry(); + test_overflow_and_controller_reset(); + puts("cyw43 packet transport regression checks passed"); + return 0; +} diff --git a/tests/haptics_experiment_native_stubs/btstack.h b/tests/haptics_experiment_native_stubs/btstack.h new file mode 100644 index 0000000..3219074 --- /dev/null +++ b/tests/haptics_experiment_native_stubs/btstack.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +struct btstack_timer_source_t { + void (*process)(btstack_timer_source_t*) = nullptr; + void* context = nullptr; + uint64_t timeout_us = 0; +}; + +constexpr uint8_t ERROR_CODE_SUCCESS = 0; +enum gap_connection_type_t { + GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_LE, GAP_CONNECTION_SCO +}; +gap_connection_type_t gap_get_connection_type(uint16_t handle); + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)); +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms); +void btstack_run_loop_add_timer(btstack_timer_source_t* timer); +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid); +uint8_t l2cap_request_can_send_now_event(uint16_t cid); +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size); diff --git a/tests/haptics_experiment_native_stubs/pico/critical_section.h b/tests/haptics_experiment_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..835ac27 --- /dev/null +++ b/tests/haptics_experiment_native_stubs/pico/critical_section.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +struct critical_section_t { + bool initialized = false; +}; + +inline unsigned native_haptics_lock_depth = 0; + +inline void critical_section_init(critical_section_t* section) { + assert(!section->initialized); + section->initialized = true; +} +inline void critical_section_enter_blocking(critical_section_t* section) { + assert(section->initialized); + assert(native_haptics_lock_depth == 0); + ++native_haptics_lock_depth; +} +inline void critical_section_exit(critical_section_t*) { + assert(native_haptics_lock_depth == 1); + --native_haptics_lock_depth; +} diff --git a/tests/haptics_experiment_native_stubs/pico/stdlib.h b/tests/haptics_experiment_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..f54e23b --- /dev/null +++ b/tests/haptics_experiment_native_stubs/pico/stdlib.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +uint64_t time_us_64(); + +// Match SDK section spelling so the RAM build compiles the real annotations. +#define __not_in_flash(group) __attribute__((section(".time_critical." group))) +#define __time_critical_func(name) __not_in_flash(#name) name diff --git a/tests/haptics_experiment_native_stubs/uni.h b/tests/haptics_experiment_native_stubs/uni.h new file mode 100644 index 0000000..181ca3d --- /dev/null +++ b/tests/haptics_experiment_native_stubs/uni.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +struct uni_hid_device_s; +typedef struct uni_hid_device_s uni_hid_device_t; + +struct uni_report_parser_t { + void (*play_dual_rumble)(uni_hid_device_t*, uint16_t, uint16_t, + uint8_t, uint8_t) = nullptr; +}; + +enum uni_bt_conn_protocol_t { + UNI_BT_CONN_PROTOCOL_NONE, + UNI_BT_CONN_PROTOCOL_BR_EDR, + UNI_BT_CONN_PROTOCOL_BLE, +}; + +struct uni_bt_conn_t { + uint16_t handle = 0; + uint16_t control_cid = 0; + uint16_t interrupt_cid = 0; + bool connected = false; + uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE; +}; + +struct uni_circular_buffer_t { + unsigned queued = 0; +}; +uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer); + +struct uni_hid_device_s { + uint16_t vendor_id = 0; + uint16_t product_id = 0; + uni_report_parser_t report_parser; + uni_bt_conn_t conn; + uni_circular_buffer_t outgoing_buffer; + + // Fake parser/link state. The module only sees the real fields above. + uint16_t remote_mtu = 143; + gap_connection_type_t connection_type = GAP_CONNECTION_ACL; + bool parser_rumble_active = false; + bool parser_rumble_delayed = false; + btstack_timer_source_t parser_timer; + bool notification_pending = false; + bool credit = true; +}; diff --git a/tests/haptics_experiment_test.cpp b/tests/haptics_experiment_test.cpp new file mode 100644 index 0000000..e115fcb --- /dev/null +++ b/tests/haptics_experiment_test.cpp @@ -0,0 +1,899 @@ +#include "input/haptics_experiment.h" +#include "input/haptics_transport_probe.h" +#include "usb/switch/switch_haptics.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +enum class Delivery { kImmediate, kDeferred, kNever }; +enum class GenericKind { kCompatibility, kLed }; + +struct Pcm { + uint64_t at_us; + uint16_t cid; + std::array bytes; +}; +struct Generic { + uni_hid_device_t* device; + uint64_t at_us; + GenericKind kind; + uint8_t weak; + uint8_t strong; +}; + +uint64_t now_us = 10000123; +std::array devices; +std::vector timers; +std::vector pcm; +std::vector generic_sent; +std::vector generic_queue; +Delivery delivery = Delivery::kImmediate; +uni_hid_device_t* permission = nullptr; +unsigned request_depth = 0; +unsigned max_request_depth = 0; +unsigned request_calls = 0; +unsigned send_calls = 0; +unsigned timer_calls = 0; +unsigned fail_requests = 0; +unsigned fail_sends = 0; +uint32_t send_cost_us = 0; +uint32_t request_cost_us = 0; +bool reenter_send = false; + +void no_lock() { + assert(native_haptics_lock_depth == 0); +} + +HapticsExperimentDiagnostics snapshot() { + HapticsExperimentDiagnostics out; + haptics_experiment_snapshot(&out); + return out; +} + +uni_hid_device_t* device_for_cid(uint16_t cid) { + for (auto& device : devices) { + if (device.conn.interrupt_cid == cid) { + return &device; + } + } + assert(false && "stale or unknown L2CAP CID"); + return nullptr; +} + +void emit_generic(uni_hid_device_t* device, GenericKind kind, + uint8_t weak = 0, uint8_t strong = 0) { + no_lock(); + Generic report{device, now_us, kind, weak, strong}; + if (device->credit) { + generic_sent.push_back(report); + } else { + generic_queue.push_back(report); + ++device->outgoing_buffer.queued; + } +} + +void parser_off(btstack_timer_source_t* timer) { + auto* device = static_cast(timer->context); + assert(device->parser_rumble_active); + device->parser_rumble_active = false; + emit_generic(device, GenericKind::kCompatibility); +} + +void parser_delayed_on(btstack_timer_source_t* timer) { + auto* device = static_cast(timer->context); + assert(device->parser_rumble_delayed); + device->parser_rumble_delayed = false; + emit_generic(device, GenericKind::kCompatibility, 17, 23); +} + +// Behavioral fake of the relevant Bluepad32 DS5 parser contract: duration=0 +// does not emit anything if already disabled; duration>0 forces compatibility +// and installs a timer-off. This catches a "restore" which is actually a no-op. +void play_rumble(uni_hid_device_t* device, uint16_t delay_ms, + uint16_t duration_ms, uint8_t weak, uint8_t strong) { + no_lock(); + const bool was_active = device->parser_rumble_active || + device->parser_rumble_delayed; + if (was_active) { + btstack_run_loop_remove_timer(&device->parser_timer); + } + device->parser_rumble_active = false; + device->parser_rumble_delayed = false; + device->parser_timer.context = device; + if (delay_ms != 0) { + device->parser_rumble_delayed = true; + device->parser_timer.process = parser_delayed_on; + btstack_run_loop_set_timer(&device->parser_timer, delay_ms); + btstack_run_loop_add_timer(&device->parser_timer); + } else if (duration_ms != 0) { + emit_generic(device, GenericKind::kCompatibility, weak, strong); + device->parser_rumble_active = true; + device->parser_timer.process = parser_off; + btstack_run_loop_set_timer(&device->parser_timer, duration_ms); + btstack_run_loop_add_timer(&device->parser_timer); + } else if (was_active) { + emit_generic(device, GenericKind::kCompatibility); + } +} + +bool dispatch(uni_hid_device_t* device, uint16_t cid) { + no_lock(); + if (cid == device->conn.interrupt_cid) { + device->notification_pending = false; + } + permission = device->credit ? device : nullptr; + const bool consumed = haptics_experiment_on_can_send_now(device, cid); + if (!consumed && device->credit) { + const auto it = std::find_if(generic_queue.begin(), generic_queue.end(), + [device](const Generic& report) { + return report.device == device; + }); + if (it != generic_queue.end()) { + Generic report = *it; + report.at_us = now_us; + generic_sent.push_back(report); + generic_queue.erase(it); + --device->outgoing_buffer.queued; + } + } + permission = nullptr; + return consumed; +} + +void run_until(uint64_t target_us) { + assert(target_us >= now_us); + unsigned iterations = 0; + while (!timers.empty()) { + const auto it = std::min_element( + timers.begin(), timers.end(), + [](const auto* a, const auto* b) { return a->timeout_us < b->timeout_us; }); + btstack_timer_source_t* timer = *it; + if (timer->timeout_us > target_us) { + break; + } + now_us = std::max(now_us, timer->timeout_us); + timers.erase(it); + assert(++iterations < 3000 && "recursive or permanently polling timer"); + ++timer_calls; + timer->process(timer); + } + now_us = std::max(now_us, target_us); +} + +void reset(uint64_t at_us = 10000123) { + for (auto& device : devices) { + haptics_experiment_detach(&device); + } + if (snapshot().state == HapticsExperimentState::kPending) { + assert(haptics_experiment_request(0, snapshot().slot)); + haptics_experiment_poll(); + } + timers.clear(); + devices = {}; + pcm.clear(); + generic_sent.clear(); + generic_queue.clear(); + now_us = at_us; + delivery = Delivery::kImmediate; + permission = nullptr; + request_depth = max_request_depth = request_calls = send_calls = timer_calls = 0; + fail_requests = fail_sends = 0; + send_cost_us = request_cost_us = 0; + reenter_send = false; + for (unsigned slot = 0; slot < devices.size(); ++slot) { + auto& device = devices[slot]; + device.vendor_id = 0x054c; + device.product_id = 0x0ce6; + device.conn.handle = static_cast(slot); + // Bluepad32's Classic path does not initialize this cached field. + device.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE; + device.conn.connected = true; + device.conn.interrupt_cid = static_cast(0x40 + slot * 2); + device.conn.control_cid = device.conn.interrupt_cid + 1; + device.report_parser.play_dual_rumble = play_rumble; + haptics_experiment_attach(static_cast(slot), 100 + slot, &device); + } + assert(pcm.empty() && generic_sent.empty()); // No pairing/boot tone. +} + +uint64_t start(uint8_t slot = 0) { + const uint32_t previous_id = snapshot().run_id; + assert(haptics_experiment_request(1, slot)); + const auto pending = snapshot(); + assert(pending.run_id == previous_id + 1); + assert(pending.slot == slot && pending.state == HapticsExperimentState::kPending); + assert(!haptics_experiment_request(1, slot)); + assert(!haptics_experiment_request(1, static_cast((slot + 1) % 4))); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(haptics_experiment_owns(&devices[slot])); + assert(!haptics_experiment_owns(&devices[(slot + 1) % 4])); + return now_us; +} + +uint64_t due(uint64_t started, uint32_t packet, uint32_t frames = 64) { + return started + (static_cast(packet) * frames * 1000 + 2) / 3; +} + +void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) { + const auto& b = packet.bytes; + assert(b[0] == 0xa2 && b[1] == 0x32 && b[2] == 0); + unsigned sample_offset = 10; + unsigned frames = 64; + if (b[3] == 0x90) { + assert(b[4] == 63); + for (unsigned i = 5; i < 68; ++i) assert(b[i] == 0); + assert(b[68] == 0x92 && b[69] == 64); + sample_offset = 70; + frames = 32; + } else { + assert(b[3] == 0x91 && b[4] == 3 && b[5] == 0x62); + assert(b[6] == 16 && b[8] == 0xd2 && b[9] == 64); + } + for (unsigned i = sample_offset + frames * 2; i < 139; ++i) { + assert(b[i] == 0); + } + const bool pattern_tone = index >= 48 && index < 240 && + ((index - 48) / 12) % 2 == 0; + const bool tone = pattern_tone && !forced_silence; + const unsigned phase = tone ? ((index - 48) / 12) % 4 : 0; + const unsigned side = phase == 0 ? 0 : 1; + const double hz = phase == 0 ? 100.0 : 200.0; + for (unsigned frame = 0; frame < frames; ++frame) { + for (unsigned channel = 0; channel < 2; ++channel) { + const int value = static_cast(b[sample_offset + frame * 2 + channel]); + if (!tone || channel != side) { + assert(value == 0); + } else { + const unsigned sample = ((index - 48) % 12) * 64 + frame; + const int expected = static_cast(std::lround( + 32.0 * std::sin(2.0 * 3.14159265358979323846 * hz * sample / 3000.0))); + assert(std::abs(value - expected) <= 1); + assert(std::abs(value) <= 32); + } + } + } +} + +void nominal_run(const char* corpus_path) { + reset(); + const uint64_t started = start(); + // This is the BTstack synchronous reentry reproduction: the very first + // request sends one block before request_can_send_now() returns. + assert(pcm.size() == 1 && snapshot().synchronous_callbacks == 1); + assert(!devices[0].notification_pending); + const uint64_t early_tick = due(started, 1) / 1000 * 1000; + run_until(early_tick); + assert(pcm.size() == 1); // SDK early millisecond wake must not send early. + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets == 288 && done.generated_packets == 288); + assert(done.skipped_packets == 0 && done.send_failures == 0); + assert(done.can_send_requests == 288 && done.synchronous_callbacks == 288); + assert(max_request_depth == 1 && request_calls == 288); + assert(timer_calls < 1250); // No permanent 1 ms poll for this 6.144 s run. + assert(pcm.size() == 288); + assert(done.first_tone_due_us == static_cast(started + 1024000)); + assert(done.first_tone_sent_us == static_cast(pcm[48].at_us)); + assert(done.last_sent_us == static_cast(pcm.back().at_us)); + assert(done.max_send_gap_us <= 22000 && done.max_lateness_us < 1000); + assert(done.elapsed_us >= 6147000 && done.elapsed_us < 6148000); + assert(done.max_generate_us == 0); + assert(!haptics_experiment_owns(&devices[0]) && timers.empty()); + assert(generic_sent.size() == 2); // Forced compatibility, then parser off. + assert(generic_sent.front().at_us >= started + 6144000); + assert(!devices[0].parser_rumble_active); + for (const auto& report : generic_sent) { + assert(report.kind == GenericKind::kCompatibility); + assert(report.weak == 0 && report.strong == 0); + } + std::ofstream corpus(corpus_path, std::ios::binary); + assert(corpus.is_open()); + for (uint32_t i = 0; i < pcm.size(); ++i) { + assert(pcm[i].at_us >= due(started, i)); + assert(pcm[i].at_us - due(started, i) < 1000); + if (i != 0) assert(pcm[i].bytes[7] == static_cast(i * 2)); + verify_block(pcm[i], i); + corpus.write(reinterpret_cast(pcm[i].bytes.data()), 143); + } + // Reference-sized 0x10 native-mode state followed by a silent PCM block. + // Independent known answer computed with Python zlib over the A2 prefix. + assert(pcm[0].bytes[3] == 0x90); + assert(pcm[0].bytes[139] == 0x00 && pcm[0].bytes[140] == 0x41 && + pcm[0].bytes[141] == 0xfd && pcm[0].bytes[142] == 0x53); + corpus.close(); + run_until(now_us + 200000); + assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == 288); +} + +void stalled_deadlines() { + reset(); + const uint64_t started = start(); + run_until(due(started, 102) + 1000); + const unsigned before = static_cast(pcm.size()); + const uint32_t next = snapshot().sent_packets; + now_us += 250000; // The main loop did not run at all during this stall. + const uint32_t current = static_cast((now_us - started) * 3 / 64000); + run_until(now_us); + assert(pcm.size() == before + 1); // No catch-up replay burst. + assert(snapshot().skipped_packets == current - next); + verify_block(pcm.back(), current); + assert(snapshot().max_send_gap_us >= 250000); + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets + done.skipped_packets == 288); + assert(done.elapsed_us < 6148000); +} + +void deferred_and_missing_callbacks() { + reset(); + delivery = Delivery::kDeferred; + const uint64_t started = start(); + assert(pcm.empty() && devices[0].notification_pending); + run_until(started + 2300000); + assert(request_calls == 1 && timer_calls == 0); + assert(!dispatch(&devices[1], devices[1].conn.interrupt_cid)); + assert(dispatch(&devices[0], devices[0].conn.control_cid)); + assert(pcm.empty() && devices[0].notification_pending); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == 1); + assert(snapshot().max_request_wait_us == 2300000); + assert(snapshot().synchronous_callbacks == 0); + assert(snapshot().skipped_packets == 107); + verify_block(pcm.back(), 107); + delivery = Delivery::kImmediate; + run_until(started + 6148000); + assert(snapshot().state == HapticsExperimentState::kCompleted); + assert(snapshot().sent_packets + snapshot().skipped_packets == 288); + + reset(); + delivery = Delivery::kNever; + const uint64_t missing_start = start(); + run_until(missing_start + 6248000); + const auto missing = snapshot(); + assert(missing.state == HapticsExperimentState::kError); + assert(missing.last_error == 4 && missing.send_failures == 1); + assert(missing.sent_packets == 0 && missing.skipped_packets == 288); + assert(missing.max_request_wait_us >= 6244000); + assert(request_calls == 1 && timer_calls < 10 && timers.empty()); + assert(!haptics_experiment_owns(&devices[0])); + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.empty()); +} + +void stop_preemption_and_restore() { + reset(); + const uint64_t started = start(); + run_until(due(started, 98) + 1000); + delivery = Delivery::kDeferred; + run_until(due(started, 99) + 1000); + assert(devices[0].notification_pending); + const auto before = snapshot(); + assert(!haptics_experiment_request(0, 1)); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(now_us + 10000); + const unsigned sent_before = static_cast(pcm.size()); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == sent_before + 1); + verify_block(pcm.back(), 99, true); // Pending tone permission now sends silence. + assert(!haptics_experiment_request(1, 0)); // Compatibility is still settling. + run_until(now_us + 4000); + assert(snapshot().state == HapticsExperimentState::kStopped); + assert(snapshot().run_id == before.run_id); + assert(!devices[0].parser_rumble_active && timers.empty()); + assert(generic_sent.size() == 2 && generic_sent[0].at_us >= pcm.back().at_us); + const auto stopped_elapsed = snapshot().elapsed_us; + run_until(now_us + 7000000); + assert(pcm.size() == sent_before + 1 && snapshot().elapsed_us == stopped_elapsed); + + reset(); + const uint32_t previous = snapshot().run_id; + assert(haptics_experiment_request(1, 0)); + const uint32_t pending_generation = snapshot().connection_generation; + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kStopped); + assert(snapshot().run_id == previous + 1 && pcm.empty() && timers.empty()); + assert(snapshot().connection_generation == pending_generation); + + // Disconnect in the ownership-settling window, immediately after restore. + // Bluepad32 deletes the instance without removing private parser timers. + reset(); + start(); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + assert(haptics_experiment_owns(&devices[0])); + assert(!devices[0].parser_rumble_active); + haptics_experiment_detach(&devices[0]); + const auto compatibility_count = generic_sent.size(); + devices[0] = {}; // Simulate upstream zeroing/reusing the parser instance. + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(timers.empty() && generic_sent.size() == compatibility_count); + + reset(); + delivery = Delivery::kNever; + devices[0].credit = false; + start(); + const uint64_t stopped_at = now_us; + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + // Repeated stops must not perpetually extend the lifecycle watchdog. + run_until(stopped_at + 50000); + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(stopped_at + 202000); + assert(snapshot().state == HapticsExperimentState::kError); + assert(snapshot().last_error == 4 && snapshot().send_failures >= 1); + assert(snapshot().sent_packets == 0 && !haptics_experiment_owns(&devices[0])); + assert(timers.empty() && generic_sent.empty() && generic_queue.size() == 2); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(generic_queue.empty() && generic_sent.size() == 2 && pcm.empty()); +} + +void compatibility_queue_and_parser_timers() { + reset(); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 6); + assert(pcm.empty() && generic_queue.size() == 1 && !haptics_experiment_owns(&devices[0])); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.control_cid)); + assert(generic_sent.size() == 1 && generic_sent[0].kind == GenericKind::kLed); + start(); // Retry only after the unrelated report was delivered, not discarded. + assert(pcm.size() == 1); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + devices[0].credit = true; + const auto sent_count = pcm.size(); + assert(dispatch(&devices[0], devices[0].conn.control_cid)); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == sent_count && generic_queue.size() == 1); + + reset(); + play_rumble(&devices[0], 3000, 100, 17, 23); + const uint64_t started = start(); + run_until(started + 6148000); + assert(snapshot().state == HapticsExperimentState::kCompleted); + for (const auto& report : generic_sent) { + assert(report.weak == 0 && report.strong == 0); + assert(report.at_us <= started || report.at_us >= started + 6144000); + } + assert(!devices[0].parser_rumble_active && !devices[0].parser_rumble_delayed); + + // Canceling an already running parser can itself queue a stop report. + // Reject before the first native packet if that compatibility report blocks. + reset(); + play_rumble(&devices[0], 0, 3000, 17, 23); + devices[0].credit = false; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().last_error == 6 && pcm.empty()); + assert(!devices[0].parser_rumble_active && timers.empty()); +} + +void reconnect_and_pending_generation() { + reset(); + delivery = Delivery::kDeferred; + start(); + const uint16_t old_cid = devices[0].conn.interrupt_cid; + haptics_experiment_detach(&devices[0]); + const auto detached = snapshot(); + assert(detached.state == HapticsExperimentState::kDisconnected); + assert(detached.last_error == 3 && timers.empty() && generic_sent.empty()); + assert(!dispatch(&devices[0], old_cid)); + assert(pcm.empty()); + devices[0].conn.interrupt_cid = 0x70; + haptics_experiment_attach(0, 101, &devices[0]); + start(); + assert(snapshot().connection_generation == 101); + assert(dispatch(&devices[0], old_cid)); // Never permission for the new CID. + assert(pcm.empty()); + assert(dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == 1); + haptics_experiment_attach(0, 102, &devices[0]); + assert(snapshot().state == HapticsExperimentState::kDisconnected && timers.empty()); + + reset(); + assert(haptics_experiment_request(1, 0)); + const uint32_t requested_generation = snapshot().connection_generation; + haptics_experiment_detach(&devices[0]); + haptics_experiment_attach(0, requested_generation + 1, &devices[0]); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(snapshot().connection_generation == requested_generation && pcm.empty()); + // The rejected request did not reserve the slot permanently. + start(); + assert(snapshot().connection_generation == requested_generation + 1); +} + +void support_and_transport_errors() { + reset(); + assert(!haptics_experiment_request(3, 0)); + assert(!haptics_experiment_request(1, 4)); + devices[0].remote_mtu = 142; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kUnsupported); + assert(snapshot().last_error == 2 && pcm.empty() && !haptics_experiment_owns(&devices[0])); + devices[0].remote_mtu = 143; + devices[0].connection_type = GAP_CONNECTION_LE; + assert(haptics_experiment_request(1, 0)); + haptics_experiment_poll(); + assert(snapshot().last_error == 1 && pcm.empty()); + devices[0].connection_type = GAP_CONNECTION_ACL; + devices[0].product_id = 0x0df2; + start(); // Exact MTU boundary and DualSense Edge. + assert(pcm.size() == 1); + + reset(); + start(3); + assert(pcm.size() == 1 && pcm.front().cid == devices[3].conn.interrupt_cid); + assert(snapshot().connection_generation == 103); + + reset(); + fail_requests = 1; + start(); + assert(pcm.empty()); + run_until(now_us + 24000); + assert(pcm.size() == 1 && snapshot().send_failures == 1); + assert(snapshot().skipped_packets == 1); + fail_sends = 1; + run_until(now_us + 24000); + assert(snapshot().send_failures == 2); + run_until(static_cast(snapshot().start_us) + 6148000); + assert(snapshot().state == HapticsExperimentState::kError); + assert(snapshot().last_error == 5); + assert(snapshot().generated_packets == snapshot().sent_packets + 1); + assert(max_request_depth == 1 && timers.empty()); +} + +void timing_cost_reentrancy_and_wrap() { + reset(); + send_cost_us = 500; + request_cost_us = 200; + reenter_send = true; + start(); + const uint64_t started = snapshot().start_us; + run_until(started + 6148000); + const auto done = snapshot(); + assert(done.state == HapticsExperimentState::kCompleted); + assert(done.sent_packets == 288 && send_calls == 288); + assert(done.max_generate_us == 0); // Neither request nor send is generation. + assert(done.max_request_wait_us == 200 && max_request_depth == 1); + for (unsigned i = 0; i < pcm.size(); ++i) { + assert(pcm[i].at_us >= due(started, i)); + assert(pcm[i].at_us - due(started, i) < 1200); + } + + reset((uint64_t{1} << 32) - 1000123); + const uint64_t wrap_start = start(); + run_until(wrap_start + 6148000); + const auto wrapped = snapshot(); + assert(wrapped.state == HapticsExperimentState::kCompleted); + assert(wrapped.start_us == static_cast(wrap_start)); + assert(wrapped.first_tone_due_us == static_cast(wrap_start + 1024000)); + assert(wrapped.first_tone_sent_us == static_cast(pcm[48].at_us)); + assert(wrapped.last_sent_us == static_cast(pcm.back().at_us)); + assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000); + assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288); +} + +void gameplay_timeline_and_lifecycle() { + reset(); + SwitchHapticsDecoder decoder; + const auto feed = [&](bool left) { + const uint32_t active = (1u << 30) | (96u << 23) | (64u << 16) | (64u << 2); + const uint32_t words[] = {left ? active : 0x40400100u, + left ? 0x40400100u : active}; + uint8_t bytes[8]{}; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned byte = 0; byte < 4; ++byte) { + bytes[side * 4 + byte] = static_cast(words[side] >> (8 * byte)); + } + } + const auto decoded = decoder.decode(bytes); + assert(haptics_experiment_submit(0, 100, now_us, decoded.hd)); + }; + assert(haptics_experiment_request(2, 0)); + const uint64_t started = now_us; + feed(true); // A sole first command survives the Pending -> Running boundary. + haptics_experiment_poll(); + assert(snapshot().mode == 1 && snapshot().state == HapticsExperimentState::kRunning); + assert(haptics_experiment_gameplay_owns(&devices[0])); + now_us = started + 8000; + feed(false); + const uint32_t frames = snapshot().packet_frames; + run_until(due(started, 1, frames) + 1000); + assert(pcm.size() == 2 && snapshot().host_updates == 2); + assert(pcm[1].bytes[7] == frames / 32); + assert(pcm[1].bytes[8] == (frames == 32 ? 0x92 : 0xd2)); + for (unsigned byte = 10 + frames * 2; byte < 139; ++byte) + assert(pcm[1].bytes[byte] == 0); + unsigned left_nonzero = 0, right_nonzero = 0; + for (unsigned frame = 0; frame < frames; ++frame) { + const auto left = pcm[1].bytes[10 + frame * 2]; + const auto right = pcm[1].bytes[11 + frame * 2]; + if (frame < 24) { + assert(right == 0); + left_nonzero += left != 0; + } else { + assert(left == 0); + right_nonzero += right != 0; + } + } + assert(left_nonzero > 10 && right_nonzero > (frames - 24) / 2); + SwitchHapticsFrame stale{}; + stale.actuators[0].sample_count = 1; + stale.actuators[0].samples[0].low_amplitude_q15 = 16000; + assert(!haptics_experiment_submit(0, 101, now_us, stale)); + run_until(started + 6300000); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(snapshot().sent_packets > 288 && snapshot().skipped_packets == 0); + for (unsigned byte = 10; byte < 138; ++byte) assert(pcm.back().bytes[byte] == 0); + assert(snapshot().dropped_updates == 0 && generic_sent.empty()); + assert(haptics_experiment_feedback(&devices[0], 100, 60, 30)); + run_until(now_us + 22000); + assert(generic_sent.empty()); // Local confirmation must not leave PCM mode. + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kStopped && snapshot().mode == 1); + assert(!haptics_experiment_owns(&devices[0]) && generic_sent.size() == 2); +} + +void gameplay_missing_callback_is_bounded() { + reset(); + delivery = Delivery::kNever; + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + run_until(now_us + 105000); + assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 4); + assert(!haptics_experiment_owns(&devices[0]) && timers.empty()); +} + +void gameplay_queued_start_and_command_overflow() { + reset(); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kPending && pcm.empty()); + run_until(now_us + 10000); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.control_cid)); + run_until(now_us + 3000); + assert(snapshot().state == HapticsExperimentState::kRunning && pcm.size() == 1); + assert(generic_queue.empty() && generic_sent.size() == 1); + assert(generic_sent.front().kind == GenericKind::kLed); + + SwitchHapticsFrame frame{}; + frame.actuators[0].sample_count = 1; + frame.actuators[0].samples[0].low_amplitude_q15 = 20000; + const size_t sent_before = pcm.size(); + for (unsigned i = 0; i < 17; ++i) { + now_us += 8000; + assert(haptics_experiment_submit(0, 100, now_us, frame)); + } + run_until(now_us); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1); + assert(snapshot().skipped_packets != 0 && pcm.size() == sent_before + 1); +} + +void stateful_rumble_prepare_feedback_and_zero() { + reset(); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kPending); + assert(haptics_experiment_submit_rumble(0, 100, now_us, 180, 0)); + assert(!haptics_experiment_submit_rumble(1, 101, now_us, 255, 255)); + assert(!haptics_experiment_submit_rumble(0, 99, now_us, 255, 255)); + run_until(now_us + 70000); // Preparation must not age out held strengths. + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.control_cid)); + run_until(now_us + 3000); + assert(snapshot().state == HapticsExperimentState::kRunning); + const auto assert_channels = [](bool left, bool right) { + const uint32_t frames = snapshot().packet_frames; + unsigned active[2]{}; + for (uint32_t frame = 0; frame < frames; ++frame) { + active[0] += pcm.back().bytes[10 + frame * 2] != 0; + active[1] += pcm.back().bytes[11 + frame * 2] != 0; + } + assert(left ? active[0] > frames / 2 : active[0] == 0); + assert(right ? active[1] > frames / 2 : active[1] == 0); + }; + run_until(now_us + 300000); + assert_channels(true, false); + assert(haptics_experiment_feedback(&devices[0], 255, 255, 100)); + assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 170)); + run_until(now_us + 60000); + assert_channels(true, true); + run_until(now_us + 200000); + assert_channels(false, true); // Overlay reveals the newest held command. + assert(generic_sent.size() == 1 && + generic_sent.front().kind == GenericKind::kLed); + assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 0)); + run_until(now_us + 80000); + assert_channels(false, false); + assert(haptics_experiment_request(0, 0)); + assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255)); + haptics_experiment_poll(); + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kStopped); + + assert(haptics_experiment_request(1, 0)); + assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255)); + haptics_experiment_poll(); + assert(snapshot().mode == 0 && snapshot().packet_frames == 64); +} + +void stateful_rumble_generation_and_overflow() { + reset(); + assert(haptics_experiment_request(2, 0)); + assert(haptics_experiment_submit_rumble(0, 100, now_us, 255, 0)); + haptics_experiment_detach(&devices[0]); + haptics_experiment_attach(0, 101, &devices[0]); + assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + run_until(now_us + 100000); + for (unsigned byte = 10; byte < 138; ++byte) + assert(pcm.back().bytes[byte] == 0); + for (unsigned command = 0; command < 17; ++command) { + assert(haptics_experiment_submit_rumble( + 0, 101, now_us, command == 16 ? 0 : 255, 0)); + } + run_until(now_us + 80000); + assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1); + for (unsigned byte = 10; byte < 138; ++byte) + assert(pcm.back().bytes[byte] == 0); + assert(generic_sent.empty()); +} + +} // namespace + +// Transport attribution has its own native fixture; this fixture isolates PCM +// scheduling and packet content from the optional measurement backend. +void haptics_transport_probe_prepare() {} +void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {} +void haptics_transport_probe_end() {} +void haptics_transport_probe_timer(uint32_t) {} +void haptics_transport_probe_permission(uint32_t) {} +void haptics_transport_probe_send(uint32_t, uint32_t, bool) {} + +uint64_t time_us_64() { + return now_us; +} + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { + no_lock(); + timer->process = handler; +} + +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) { + no_lock(); + // Exactly pico_btstack/btstack_run_loop_async_context.c, including +1. + timer->timeout_us = (now_us / 1000 + timeout_ms + 1) * 1000; +} + +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + no_lock(); + assert(timer->process != nullptr); + assert(std::find(timers.begin(), timers.end(), timer) == timers.end()); + timers.push_back(timer); +} + +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + no_lock(); + const auto it = std::find(timers.begin(), timers.end(), timer); + if (it == timers.end()) { + return 0; + } + timers.erase(it); + return 1; +} + +gap_connection_type_t gap_get_connection_type(uint16_t handle) { + no_lock(); + return handle < devices.size() ? devices[handle].connection_type + : GAP_CONNECTION_INVALID; +} + +uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid) { + no_lock(); + return device_for_cid(cid)->remote_mtu; +} + +uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) { + return buffer->queued == 0; +} + +uint8_t l2cap_request_can_send_now_event(uint16_t cid) { + no_lock(); + ++request_calls; + if (fail_requests != 0) { + --fail_requests; + return 0x44; + } + auto* device = device_for_cid(cid); + assert(!device->notification_pending); + device->notification_pending = true; + ++request_depth; + max_request_depth = std::max(max_request_depth, request_depth); + now_us += request_cost_us; + if (delivery == Delivery::kImmediate && device->credit) { + dispatch(device, cid); // Real BTstack can call here, BEFORE return. + } + --request_depth; + return ERROR_CODE_SUCCESS; +} + +uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { + no_lock(); + auto* device = device_for_cid(cid); + assert(permission == device && device->credit); + assert(size == 143); + ++send_calls; + const uint64_t submitted_us = now_us; + now_us += send_cost_us; + if (reenter_send) { + assert(haptics_experiment_on_can_send_now(device, cid)); + const auto concurrent_snapshot = snapshot(); + assert(concurrent_snapshot.state == HapticsExperimentState::kPending || + concurrent_snapshot.state == HapticsExperimentState::kRunning); + } + if (fail_sends != 0) { + --fail_sends; + return 0x55; + } + Pcm packet{submitted_us, cid, {}}; + std::copy(data, data + size, packet.bytes.begin()); + pcm.push_back(packet); + return ERROR_CODE_SUCCESS; +} + +int main(int argc, char** argv) { + assert(argc == 2); + haptics_experiment_prepare(); + assert(snapshot().state == HapticsExperimentState::kIdle); + nominal_run(argv[1]); + stalled_deadlines(); + deferred_and_missing_callbacks(); + stop_preemption_and_restore(); + compatibility_queue_and_parser_timers(); + reconnect_and_pending_generation(); + support_and_transport_errors(); + timing_cost_reentrancy_and_wrap(); + gameplay_timeline_and_lifecycle(); + gameplay_queued_start_and_command_overflow(); + stateful_rumble_prepare_feedback_and_zero(); + stateful_rumble_generation_and_overflow(); + gameplay_missing_callback_is_bounded(); + std::cout << "haptics experiment behavioral regressions passed\n"; +} diff --git a/tests/haptics_transport_probe_native_stubs/btstack.h b/tests/haptics_transport_probe_native_stubs/btstack.h new file mode 100644 index 0000000..09b9505 --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/btstack.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +constexpr uint8_t HCI_EVENT_PACKET = 4; +constexpr uint8_t HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS = 0x13; +using hci_con_handle_t = uint16_t; +struct hci_connection_t { + uint8_t num_packets_sent = 0; +}; +struct btstack_packet_callback_registration_t { + void* item = nullptr; + void (*callback)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr; +}; +extern "C" { +void btstack_run_loop_base_poll_data_sources(); +void btstack_run_loop_poll_data_sources_from_irq(); +void hci_add_event_handler(btstack_packet_callback_registration_t* registration); +hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle); +int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle); +} diff --git a/tests/haptics_transport_probe_native_stubs/cyw43.h b/tests/haptics_transport_probe_native_stubs/cyw43.h new file mode 100644 index 0000000..aeeb140 --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/cyw43.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif +int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length); +int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, uint32_t* length); +#ifdef __cplusplus +} +#endif diff --git a/tests/haptics_transport_probe_native_stubs/pico/critical_section.h b/tests/haptics_transport_probe_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..b8e70ed --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/pico/critical_section.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +struct critical_section_t { + bool initialized = false; +}; +inline unsigned native_probe_lock_depth = 0; +inline void critical_section_init(critical_section_t* section) { + assert(!section->initialized); + section->initialized = true; +} +inline void critical_section_enter_blocking(critical_section_t* section) { + assert(section->initialized && native_probe_lock_depth == 0); + ++native_probe_lock_depth; +} +inline void critical_section_exit(critical_section_t*) { + assert(native_probe_lock_depth == 1); + --native_probe_lock_depth; +} diff --git a/tests/haptics_transport_probe_native_stubs/pico/stdlib.h b/tests/haptics_transport_probe_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..8aff95e --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/pico/stdlib.h @@ -0,0 +1,4 @@ +#pragma once + +#include +uint64_t time_us_64(); diff --git a/tests/haptics_transport_probe_native_stubs/sdk.cpp b/tests/haptics_transport_probe_native_stubs/sdk.cpp new file mode 100644 index 0000000..e4f55ef --- /dev/null +++ b/tests/haptics_transport_probe_native_stubs/sdk.cpp @@ -0,0 +1,19 @@ +#include +#include + +extern int (*native_write)(uint8_t*, size_t); +extern int (*native_read)(uint8_t*, uint32_t, uint32_t*); +extern void (*native_poll)(); + +// Keep definitions separate from test callers, just like the real SDK. Tests +// exercise GNU ld --wrap, not direct calls to the __wrap_ implementation. +extern "C" int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) { + return native_write(buffer, length); +} +extern "C" int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, + uint32_t* length) { + return native_read(buffer, capacity, length); +} +extern "C" void btstack_run_loop_base_poll_data_sources() { + native_poll(); +} diff --git a/tests/haptics_transport_probe_test.cpp b/tests/haptics_transport_probe_test.cpp new file mode 100644 index 0000000..a247352 --- /dev/null +++ b/tests/haptics_transport_probe_test.cpp @@ -0,0 +1,469 @@ +#include "input/haptics_transport_probe.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr uint16_t kHandle = 0x123; +uint64_t clock_us = 1000; +hci_connection_t connection{2}; +bool connected = true; +int free_slots = 7; +unsigned registrations = 0; +void (*event_handler)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr; +unsigned writes = 0, reads = 0, polls = 0; +uint32_t write_delay = 0, read_delay = 0, poll_delay = 0; +int write_result = 0, read_result = 0; +uint32_t read_length = 8; +uint8_t* last_buffer = nullptr; +size_t last_write_length = 0; +uint32_t last_capacity = 0; +uint32_t* last_length = nullptr; +void (*write_action)() = nullptr; +void (*read_action)() = nullptr; +void (*poll_action)() = nullptr; +uint8_t buffer[16]{}; +bool poll_requested = false; +unsigned queued_input = 0; +unsigned serviced_input = 0; + +HapticsTransportProbe snapshot() { + HapticsTransportProbe result; + haptics_transport_probe_snapshot(&result); + return result; +} + +void unchanged(const HapticsTransportProbe& expected) { + const auto actual = snapshot(); + assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0); +} + +void run_action(void (*&action)()) { + const auto callback = action; + action = nullptr; + if (callback != nullptr) callback(); +} + +int fake_write(uint8_t* data, size_t length) { + assert(native_probe_lock_depth == 0); + (void)snapshot(); // Real calls may synchronously reenter snapshot readers. + ++writes; + last_buffer = data; + last_write_length = length; + clock_us += write_delay; + run_action(write_action); + if (data != nullptr && length != 0) data[0] = 0xa5; + return write_result; +} + +int fake_read(uint8_t* data, uint32_t capacity, uint32_t* length) { + assert(native_probe_lock_depth == 0); + (void)snapshot(); + ++reads; + last_buffer = data; + last_capacity = capacity; + last_length = length; + clock_us += read_delay; + run_action(read_action); + if (data != nullptr && capacity != 0) data[0] = 0x5a; + if (read_result == 0 && length != nullptr) *length = read_length; + return read_result; +} + +void fake_poll() { + assert(native_probe_lock_depth == 0); + (void)snapshot(); + ++polls; + clock_us += poll_delay; + run_action(poll_action); +} + +void begin(uint32_t run = 1, uint16_t handle = kHandle) { + haptics_transport_probe_end(); + clock_us = 1000; + connected = true; + connection.num_packets_sent = 2; + free_slots = 7; + writes = reads = polls = 0; + write_delay = read_delay = poll_delay = 0; + write_result = read_result = 0; + read_length = 8; + write_action = read_action = poll_action = nullptr; + poll_requested = false; + haptics_transport_probe_begin(run, 9, handle); + assert(registrations == 1); +} + +void complete(uint16_t count) { + uint8_t event[] = {0x13, 5, 1, 0x23, 0x01, + static_cast(count), static_cast(count >> 8)}; + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); +} + +void test_inactive() { + haptics_transport_probe_prepare(); + haptics_transport_probe_prepare(); + const auto before = snapshot(); + write_result = -7; + read_result = 17; + uint32_t length = 99; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -7); + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 17); + assert(length == 99); + btstack_run_loop_base_poll_data_sources(); + haptics_transport_probe_timer(123); + haptics_transport_probe_permission(456); + haptics_transport_probe_send(789, 1000, true); + unchanged(before); + assert(writes == 1 && reads == 1 && polls == 1 && registrations == 0); +} + +void test_delay_attribution() { + begin(); + poll_action = [] { + clock_us += 50; // Generation is outside the caller-supplied send time. + const uint32_t send_start = static_cast(clock_us); + clock_us += 7; + write_delay = 3000; + write_action = [] { connection.num_packets_sent = 5; free_slots = 1; }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + clock_us += 11; + haptics_transport_probe_send(static_cast(clock_us) - send_start, + static_cast(clock_us), true); + clock_us += 9; + uint32_t length = 0; + read_delay = 80; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + read_delay = 90; + read_length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + read_delay = 70; + read_result = -9; + length = 99; // Stale output length on failure is not a packet. + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == -9); + clock_us += 30; + }; + btstack_run_loop_base_poll_data_sources(); + auto result = snapshot(); + assert(result.send_calls == 1 && result.total_send_us == 3018); + assert(result.max_send_us == 3018 && result.first_tone_send_return_us == 4068); + assert(result.write_calls == 1 && result.total_write_us == 3000); + assert(result.max_write_us == 3000); + assert(result.read_calls == 3 && result.read_packets == 1); + assert(result.total_read_us == 240 && result.max_read_us == 90); + assert(result.poll_calls == 1 && result.total_poll_us == 3347); + assert(result.max_poll_us == 3347 && result.max_poll_gap_us == 0); + assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1); + clock_us += 600; + poll_delay = 100; + btstack_run_loop_base_poll_data_sources(); + result = snapshot(); + assert(result.poll_calls == 2 && result.total_poll_us == 3447); + assert(result.max_poll_gap_us == 3947); +} + +void test_nested_boundaries() { + begin(); + write_result = -23; + write_delay = 10; + write_action = [] { + write_delay = 30; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + clock_us += 7; + }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + assert(writes == 2 && snapshot().write_calls == 1); + assert(snapshot().total_write_us == 47); + read_delay = 11; + read_action = [] { + read_delay = 19; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + clock_us += 3; + }; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + assert(reads == 2 && snapshot().read_calls == 1); + assert(snapshot().read_packets == 1 && snapshot().total_read_us == 33); + poll_delay = 5; + poll_action = [] { + poll_delay = 13; + btstack_run_loop_base_poll_data_sources(); + write_delay = 10; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23); + read_delay = 7; + read_length = 0; + uint32_t count = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &count) == 0); + clock_us += 2; + }; + btstack_run_loop_base_poll_data_sources(); + const auto result = snapshot(); + assert(polls == 2 && result.poll_calls == 1 && result.total_poll_us == 37); + assert(result.write_calls == 2 && result.total_write_us == 57); + assert(result.read_calls == 2 && result.total_read_us == 40 && result.read_packets == 1); +} + +void test_returns_and_outputs() { + begin(); + write_delay = 2; + read_delay = 3; + const int results[] = {0, -5, 17, INT_MIN, INT_MAX}; + for (int status : results) { + write_result = read_result = status; + buffer[0] = 0; + assert(cyw43_bluetooth_hci_write(buffer, SIZE_MAX) == status); + assert(last_buffer == buffer && last_write_length == SIZE_MAX && buffer[0] == 0xa5); + uint32_t length = 0xfeed; + assert(cyw43_bluetooth_hci_read(buffer, UINT32_MAX, &length) == status); + assert(last_buffer == buffer && last_capacity == UINT32_MAX && last_length == &length); + assert(buffer[0] == 0x5a && length == (status == 0 ? read_length : 0xfeed)); + } + auto result = snapshot(); + assert(writes == 5 && reads == 5 && result.write_calls == 5 && result.read_calls == 5); + assert(result.total_write_us == 10 && result.total_read_us == 15 && result.read_packets == 1); + haptics_transport_probe_end(); + result = snapshot(); + assert(cyw43_bluetooth_hci_write(nullptr, 0) == INT_MAX); + assert(cyw43_bluetooth_hci_read(nullptr, 0, nullptr) == INT_MAX); + btstack_run_loop_base_poll_data_sources(); + complete(8); + unchanged(result); +} + +void test_selected_completions() { + begin(); + uint8_t event[] = {0x13, 9, 2, 0x22, 0, 4, 0, 0x23, 0x01, 3, 0}; + clock_us = 6000; + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + assert(snapshot().completion_events == 1 && snapshot().completed_packets == 3); + assert(snapshot().max_completion_gap_us == 0); + clock_us = 16000; + uint8_t other[] = {0x13, 5, 1, 0x22, 0, 7, 0}; + event_handler(HCI_EVENT_PACKET, 0, other, sizeof(other)); + clock_us = 30000; + complete(2); + const auto before = snapshot(); + assert(before.completion_events == 2 && before.completed_packets == 5); + assert(before.max_completion_gap_us == 24000); + event_handler(HCI_EVENT_PACKET, 0, nullptr, 7); + event_handler(HCI_EVENT_PACKET, 0, event, 2); + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event) - 1); + event_handler(2, 0, event, sizeof(event)); + event[1] = 8; // Inconsistent event parameter length. + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + event[1] = 9; + event[2] = 255; // Handle count exceeds bounded packet storage. + event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event)); + unchanged(before); + + begin(2, 0xffff); + complete(3); + assert(snapshot().completion_events == 0); + assert(snapshot().max_outstanding_acl == 0 && snapshot().min_free_acl == 0); +} + +void test_delayed_completion_and_disconnect() { + begin(); + connection.num_packets_sent = 5; + free_slots = 1; + write_delay = 7; + write_action = [] { + clock_us += 80000; + connection.num_packets_sent = 0; + free_slots = 6; + complete(5); // Completion delivery reenters an in-flight write. + connected = false; + connection.num_packets_sent = 255; // The former object is now invalid. + }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + haptics_transport_probe_end(); + const auto result = snapshot(); + assert(result.write_calls == 1 && result.total_write_us == 80007); + assert(result.completion_events == 1 && result.completed_packets == 5); + assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1); + complete(7); + unchanged(result); +} + +void test_run_changes_during_calls() { + begin(7); + write_delay = 100; + write_action = [] { haptics_transport_probe_end(); }; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + assert(snapshot().active == 0 && snapshot().write_calls == 0); + + begin(7); + read_delay = 100; + read_action = [] { + haptics_transport_probe_end(); + haptics_transport_probe_begin(7, 9, kHandle); // Even identical public IDs. + haptics_transport_probe_permission(23); + }; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + auto result = snapshot(); + assert(result.active == 1 && result.run_id == 7 && result.connection_generation == 9); + assert(result.connection_handle == kHandle && result.read_calls == 0 && result.read_packets == 0); + assert(result.permission_callbacks == 1 && result.total_permission_wait_us == 23); + + poll_delay = 100; + poll_action = [] { + haptics_transport_probe_begin(8, 10, kHandle); + haptics_transport_probe_timer(17); + }; + btstack_run_loop_base_poll_data_sources(); + result = snapshot(); + assert(result.run_id == 8 && result.connection_generation == 10 && result.poll_calls == 0); + assert(result.timer_wakes == 1 && result.max_timer_lateness_us == 17); + assert(result.permission_callbacks == 0 && result.total_permission_wait_us == 0); + clock_us += 500; + poll_delay = 20; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().poll_calls == 1 && snapshot().total_poll_us == 20); + assert(snapshot().max_poll_gap_us == 0); + + haptics_transport_probe_end(); + poll_action = [] { haptics_transport_probe_begin(9, 11, kHandle); }; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().run_id == 9 && snapshot().poll_calls == 0); + assert(registrations == 1); +} + +void test_saturation_and_clock_wrap() { + begin(); + clock_us = uint64_t{UINT32_MAX} - 20; + write_delay = 40; + assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0); + assert(snapshot().total_write_us == 40); + haptics_transport_probe_timer(UINT32_MAX - 2); + haptics_transport_probe_timer(10); + haptics_transport_probe_permission(UINT32_MAX - 3); + haptics_transport_probe_permission(10); + haptics_transport_probe_send(UINT32_MAX - 5, 0, true); + haptics_transport_probe_send(10, 123, true); + const auto result = snapshot(); + assert(result.timer_wakes == 2 && result.total_timer_lateness_us == UINT32_MAX); + assert(result.max_timer_lateness_us == UINT32_MAX - 2); + assert(result.permission_callbacks == 2 && result.total_permission_wait_us == UINT32_MAX); + assert(result.max_permission_wait_us == UINT32_MAX - 3); + assert(result.send_calls == 2 && result.total_send_us == UINT32_MAX); + assert(result.max_send_us == UINT32_MAX - 5 && result.first_tone_send_return_us == 0); + clock_us = uint64_t{UINT32_MAX} - 5; + complete(1); + clock_us += 20; + complete(1); + assert(snapshot().max_completion_gap_us == 20); + clock_us = uint64_t{UINT32_MAX} - 5; + poll_delay = 5; + btstack_run_loop_base_poll_data_sources(); + clock_us += 40; + btstack_run_loop_base_poll_data_sources(); + assert(snapshot().max_poll_gap_us == 45); +} + +void receive_one() { + read_length = queued_input != 0 ? 8 : 0; + uint32_t length = 0; + assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0); + if (length != 0) { + --queued_input; + ++serviced_input; + } + poll_action = receive_one; +} + +void test_bounded_receive_progress() { + begin(); + haptics_transport_probe_end(); // Input must progress outside an active run. + const auto before = snapshot(); + queued_input = 3; + serviced_input = 0; + poll_action = receive_one; + poll_requested = true; + unsigned timer_opportunities = 0; + while (poll_requested && timer_opportunities < 10) { + poll_requested = false; + const unsigned handled_before = serviced_input; + btstack_run_loop_base_poll_data_sources(); + assert(serviced_input - handled_before <= 1); + ++timer_opportunities; // SDK services timers before the next poll. + } + assert(queued_input == 0 && serviced_input == 3); + assert(timer_opportunities == 4 && !poll_requested); + unchanged(before); +} + +void test_advertised_capacity_capture() { + begin(); + haptics_transport_probe_end(); + uint8_t response[] = { + 0, 0, 0, HCI_EVENT_PACKET, 0x0e, 11, 1, 0x05, 0x10, + 0, 0xfd, 3, 0, 10, 0, 0, 0, + }; + const auto before = snapshot(); + uint32_t length = 0; + read_length = sizeof(response) - 1; + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + unchanged(before); + read_length = sizeof(response); + response[9] = 1; // Failed command must not replace advertised capacity. + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + unchanged(before); + response[9] = 0; + assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0); + assert(snapshot().controller_acl_packet_bytes == 1021); + assert(snapshot().controller_acl_packet_count == 10); + begin(45); + assert(snapshot().controller_acl_packet_count == 10); + assert(snapshot().controller_acl_packet_bytes == 1021); +} + +} // namespace + +int (*native_write)(uint8_t*, size_t) = fake_write; +int (*native_read)(uint8_t*, uint32_t, uint32_t*) = fake_read; +void (*native_poll)() = fake_poll; + +uint64_t time_us_64() { return clock_us; } + +extern "C" void btstack_run_loop_poll_data_sources_from_irq() { + assert(native_probe_lock_depth == 0); + poll_requested = true; +} + +extern "C" void hci_add_event_handler(btstack_packet_callback_registration_t* registration) { + assert(native_probe_lock_depth == 0 && ++registrations == 1); + event_handler = registration->callback; +} + +extern "C" hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle) { + assert(native_probe_lock_depth == 0); + return connected && handle == kHandle ? &connection : nullptr; +} + +extern "C" int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle) { + assert(native_probe_lock_depth == 0 && connected && handle == kHandle); + return free_slots; +} + +int main() { + test_inactive(); + test_delay_attribution(); + test_nested_boundaries(); + test_returns_and_outputs(); + test_selected_completions(); + test_delayed_completion_and_disconnect(); + test_run_changes_during_calls(); + test_saturation_and_clock_wrap(); + test_bounded_receive_progress(); + test_advertised_capacity_capture(); + std::puts("haptics transport probe tests passed"); +} diff --git a/tests/native_stubs/tusb.h b/tests/native_stubs/tusb.h index 4e144e7..78bc4ef 100644 --- a/tests/native_stubs/tusb.h +++ b/tests/native_stubs/tusb.h @@ -89,6 +89,7 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance); void tud_mount_cb(void); void tud_umount_cb(void); +void tud_suspend_cb(bool remote_wakeup_en); uint8_t const* tud_descriptor_device_cb(void); uint8_t const* tud_descriptor_configuration_cb(uint8_t index); uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid); diff --git a/tests/profile_playtest_test.js b/tests/profile_playtest_test.js new file mode 100644 index 0000000..dd7bc49 --- /dev/null +++ b/tests/profile_playtest_test.js @@ -0,0 +1,65 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); + +require(path.join( + __dirname, + "..", + "src", + "switch_pico_bridge", + "web", + "profile_playtest.js" +)); + +const { + transformStick, + transformTrigger, + stickCoordinates, + triggerPercent, +} = globalThis.ProfilePlaytestMath; + +const defaultStick = { + center_x: 0, + center_y: 0, + inner_deadzone: 0, + outer_saturation: 32767, + curve_q8_8: 256, + invert_x: false, + invert_y: false, +}; +const defaultTrigger = { + lower_deadzone: 0, + upper_saturation: 65535, + curve_q8_8: 256, +}; + +assert.deepEqual( + transformStick({ x: -1234, y: 2345 }, defaultStick), + { x: -1234, y: 2345 } +); +assert.ok(stickCoordinates({ x: 0, y: 20000 }).top > 50, + "positive/down input must render below center"); +assert.ok(stickCoordinates({ x: 0, y: -20000 }).top < 50, + "negative/up input must render above center"); +assert.ok(stickCoordinates({ x: -20000, y: 0 }).left < 50, + "negative/left input must render left of center"); +assert.deepEqual( + transformStick( + { x: -1234, y: 2345 }, + { ...defaultStick, inner_deadzone: 5000 } + ), + { x: 0, y: 0 } +); +assert.deepEqual( + transformStick( + { x: -30000, y: 30000 }, + { ...defaultStick, curve_q8_8: 512 } + ), + { x: -27665, y: 27664 } +); +assert.equal(transformTrigger(65000, defaultTrigger), 65000); +assert.ok(triggerPercent(65000) > 99, + "nonzero trigger input must produce a visible bar"); +assert.equal(triggerPercent(0), 0); +assert.equal(triggerPercent(65535), 100); diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 13b5f9d..304b196 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -1,8 +1,9 @@ #include "core/controller_identity.h" -#include "profile/controller_profile.h" #include "platform/pico/pico_profile_storage.h" +#include "profile/controller_profile.h" #include "profile/profile_service.h" #include "profile/profile_storage.h" + #include #include #include @@ -10,428 +11,423 @@ namespace { struct FakeFlash { - uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE]; - int bank_replacements = 0; + uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]; }; FakeFlash flash{}; -void require(bool condition, const char* message) { - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } +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_read(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[arena][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; +bool fake_erase(void *context, uint8_t arena) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT) { + return false; + } + memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE); + return true; +} + +bool fake_program(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset + size > PROFILE_STORAGE_ARENA_SIZE) { + return false; + } + for (size_t index = 0; index < size; ++index) { + storage->bytes[arena][offset + index] &= page[index]; + } + return memcmp(&storage->bytes[arena][offset], page, size) == 0; } ProfileStorageIo fake_io() { - return { - &flash, - PROFILE_STORAGE_BANK_SIZE, - PROFILE_STORAGE_SECTOR_SIZE, - PROFILE_STORAGE_PAGE_SIZE, - fake_read, - fake_replace_bank, - }; + return { + &flash, + PROFILE_STORAGE_ARENA_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_erase, + fake_program, + }; +} + +ControllerIdentity stable_identity() { + ControllerIdentity identity{}; + identity.stable = true; + identity.transport = ControllerTransport::kClassic; + identity.address[5] = 7; + identity.vendor_id = 0x054c; + identity.product_id = 0x0ce6; + return identity; } ProfileServiceTransactionSnapshot transaction_snapshot() { - ProfileServiceTransactionSnapshot snapshot{}; - profile_service_transaction_snapshot(&snapshot); - return 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; +ProfileServiceActiveProfileSnapshot +active_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 write_profile(uint32_t transaction_id, const ControllerIdentity &identity, + uint8_t profile_index, const ControllerProfile &profile, + uint32_t now_ms) { + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "profile did not encode"); + require( + profile_service_begin(transaction_id, identity, profile_index, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving, + "profile transaction did not begin"); + require(profile_service_append(transaction_id, 0, encoded, 117) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(transaction_id, 117, encoded + 117, + sizeof(encoded) - 117) == + ConfigurationTransactionStatus::kReceiving, + "profile chunks were not accepted"); + require(profile_service_commit(transaction_id) == + ConfigurationTransactionStatus::kPending, + "profile transaction did not become pending"); + profile_service_task_on_storage_core(now_ms); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "profile transaction did not persist"); } -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"); +void test_eight_profile_transactions_and_active_cache() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + profile_service_prepare(); + profile_service_initialize_on_storage_core(); + const ControllerIdentity global = controller_identity_global(); + ProfileServiceActiveProfileSnapshot active = active_snapshot(global); + require(active.valid && active.profile_index == 0 && + active.metadata.state == ProfileServiceState::kReady, + "fallback active cache did not initialize"); - 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"); + ControllerProfile eighth = controller_profile_default(global, 7); + eighth.strong_rumble_scale = 37; + eighth.shortcuts.modifier = 4; + eighth.shortcuts.selectors[7] = 15; + eighth.shift.mode = ControllerProfileShiftMode::kHold; + eighth.shift.modifier = 17; + eighth.shift.button_map[0] = 2; + eighth.turbo_modes[0] = ControllerProfileTurboMode::kBurst; + eighth.turbo_defaults = {11, 37, 9}; + eighth.turbo_override_mask = 1u << 15; + eighth.turbo_overrides[15] = {30, 99, 255}; + write_profile(1, global, 7, eighth, 0); + require(profile_service_select(global, 7) == + ConfigurationTransactionStatus::kCommitted, + "profile eight was not selectable"); + ProfileServiceSelectedSnapshot selected{}; + profile_service_selected_snapshot(&selected); + require(selected.valid && selected.profile_index == 7 && + selected.profile.strong_rumble_scale == 37 && + selected.profile.shortcuts.selectors[7] == 15 && + selected.profile.shift.button_map[0] == 2 && + selected.profile.turbo_overrides[15].burst_count == 255, + "selected profile eight lost its schema6 extension"); - 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"); + require(profile_service_activate(2, global, 7) == + ConfigurationTransactionStatus::kPending, + "profile eight activation was not queued"); + profile_service_task_on_storage_core(1000); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "profile eight activation did not persist"); + active = active_snapshot(global); + require(active.valid && active.profile_index == 7 && + active.profile.strong_rumble_scale == 37 && + active.profile.shift.modifier == 17 && + active.profile.turbo_defaults.duty_percent == 37, + "active cache did not publish the complete schema6 profile"); - constexpr uint32_t kReservedInternalTransactionId = 0x80000019u; - require( - profile_service_begin( - kReservedInternalTransactionId, identity, kProfileIndex, - CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), - profile_storage_crc32(encoded, sizeof(encoded))) == - ConfigurationTransactionStatus::kMalformed && - profile_service_reset(kReservedInternalTransactionId, identity, - kProfileIndex) == - ConfigurationTransactionStatus::kMalformed && - profile_service_activate(kReservedInternalTransactionId, identity, - kProfileIndex) == - ConfigurationTransactionStatus::kMalformed, - "host profile mutations admitted the internal transaction namespace"); + const ControllerIdentity connected = stable_identity(); + require(profile_service_observe_identity_on_storage_core(connected), + "stable identity was not added to the catalog"); + ProfileServiceListSnapshot list{}; + profile_service_list_snapshot(&list); + require(list.count == 2 && + controller_identity_equal(list.rows[1].identity, connected), + "profile list did not publish the stable identity"); + active = active_snapshot(connected); + require(active.valid && active.profile_index == 0, + "new identity did not publish its default active profile"); - constexpr uint32_t kResetTransactionId = 0x25a55a5a; - 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"); + ControllerProfile seventh = controller_profile_default(connected, 6); + seventh.weak_rumble_scale = 61; + write_profile(3, connected, 6, seventh, 3000); + require(profile_service_activate_internal(0x80000007u, connected, 6) == + ConfigurationTransactionStatus::kPending, + "controller activation was not queued"); + const auto host_before_activation = transaction_snapshot(); + profile_service_task_on_storage_core(3999); + active = active_snapshot(connected); + require(active.valid && active.profile_index == 0, + "internal activation published before the one-second commit interval"); + profile_service_task_on_storage_core(4000); + active = active_snapshot(connected); + require(active.valid && active.profile_index == 6 && + active.profile.weak_rumble_scale == 61, + "controller activation did not refresh the active cache"); + require(transaction_snapshot().transaction.transaction_id == + host_before_activation.transaction.transaction_id && + transaction_snapshot().transaction.status == + host_before_activation.transaction.status, + "internal activation replaced the host-visible transaction snapshot"); + const uint32_t activated_generation = active.metadata.generation; + require(profile_service_activate_internal(0x80000008u, connected, 6) == + ConfigurationTransactionStatus::kPending, + "unchanged internal activation was not accepted"); + profile_service_task_on_storage_core(5000); + require(active_snapshot(connected).metadata.generation == activated_generation, + "unchanged internal activation published a fake commit"); - 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"); + require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) == + ConfigurationTransactionStatus::kPending, + "reset-all was not queued"); + profile_service_task_on_storage_core(5000); + active = active_snapshot(global); + require(active.valid && active.profile_index == 0 && + active.profile.strong_rumble_scale == UINT8_MAX, + "reset-all did not restore defaults and activation"); - 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"); + require(profile_service_set_metadata( + 5, connected, CONTROLLER_PROFILE_ALL, + "Desk pad", 8) == + ConfigurationTransactionStatus::kPending, + "controller alias was not queued"); + profile_service_task_on_storage_core(6000); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted, + "controller alias did not persist"); + require(profile_service_set_metadata( + 6, connected, 6, "Desktop", 7) == + ConfigurationTransactionStatus::kPending, + "profile name was not queued"); + profile_service_task_on_storage_core(7000); + require(profile_service_select(connected, 6) == + ConfigurationTransactionStatus::kCommitted, + "named profile was not selected"); + ProfileServiceMetadataSnapshot metadata{}; + profile_service_metadata_snapshot(&metadata); + require(metadata.valid && strcmp(metadata.alias, "Desk pad") == 0 && + strcmp(metadata.profile_names[6], "Desktop") == 0, + "profile metadata snapshot lost persisted values"); - 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"); + ProfileStorage reloaded; + ControllerProfile persisted{}; + char stored_metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require( + reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr && + reloaded.find(connected)->active_profile == 6 && + reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk && + persisted.weak_rumble_scale == 61 && + reloaded.get_alias(connected, stored_metadata, + sizeof(stored_metadata)) == + ProfileStorageResult::kOk && + strcmp(stored_metadata, "Desk pad") == 0 && + reloaded.get_profile_name(connected, 6, stored_metadata, + sizeof(stored_metadata)) == + ProfileStorageResult::kOk && + strcmp(stored_metadata, "Desktop") == 0, + "service mutations and metadata did not survive catalog reload"); } -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(kInternalTransactionId, 0, encoded, - sizeof(encoded)) == - ConfigurationTransactionStatus::kMalformed && - profile_service_commit(kInternalTransactionId) == - ConfigurationTransactionStatus::kMalformed && - transaction_snapshot().transaction.transaction_id == - kHostTransactionId && - transaction_snapshot().transaction.status == - ConfigurationTransactionStatus::kReceiving, - "reserved internal IDs corrupted a receiving 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_profile_bounds_and_transaction_namespace() { + const ControllerIdentity global = controller_identity_global(); + require(profile_service_select(global, 8) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate(10, global, 8) == + ConfigurationTransactionStatus::kMalformed && + profile_service_reset(11, global, 8) == + ConfigurationTransactionStatus::kMalformed, + "profile index beyond eight was admitted"); + require(profile_service_activate(0x80000001u, global, 0) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate_internal(12, global, 0) == + ConfigurationTransactionStatus::kMalformed, + "transaction namespaces were not enforced"); } -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"); +void test_schema6_validation_and_atomic_selection() { + const ControllerIdentity id = stable_identity(); + require(profile_service_select(id, 6) == + ConfigurationTransactionStatus::kCommitted, + "transaction validation baseline was not selected"); + ProfileServiceSelectedSnapshot old_selection{}; + profile_service_selected_snapshot(&old_selection); + ControllerProfile updated = old_selection.profile; + updated.shortcuts.modifier = 5; + updated.shortcuts.selectors[6] = 14; + updated.turbo_defaults = {30, 1, 255}; + updated.macros[0].trigger_mask = 1u << 10; + updated.macros[0].step_count = 1; + updated.macros[0].mode = ControllerProfileMacroMode::kRepeat; + updated.macros[0].repeat_count = 255; + updated.macro_step_count = 1; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) + updated.macros[index].first_step = 1; + updated.macro_steps[0].duration_ms = 7; + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(updated, encoded, sizeof(encoded)), + "extended service profile did not encode"); + require(profile_service_begin(20, id, 6, 5, 256, 0) == + ConfigurationTransactionStatus::kUnsupportedSchema && + profile_service_begin(21, id, 6, 6, 256, 0) == + ConfigurationTransactionStatus::kMalformed && + profile_service_begin(22, id, 6, 6, 385, 0) == + ConfigurationTransactionStatus::kTooLarge, + "service admitted old-schema or incorrectly-sized writes"); - 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"); + // A valid transport CRC cannot authorize an invalid extension. + encoded[283] = 0; + require(profile_service_begin( + 23, id, 6, 6, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(23, 0, encoded, sizeof(encoded)) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(23) == + ConfigurationTransactionStatus::kMalformed, + "service admitted invalid schema6 turbo settings"); + ProfileServiceSelectedSnapshot selected{}; + profile_service_selected_snapshot(&selected); + require(selected.valid && + selected.metadata.generation == old_selection.metadata.generation && + selected.profile.turbo_defaults.rate_hz == + old_selection.profile.turbo_defaults.rate_hz, + "rejected extension replaced the old selected snapshot"); - 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"); + require(controller_profile_encode(updated, encoded, sizeof(encoded)), + "valid replacement did not encode"); + require(profile_service_begin( + 24, id, 6, 6, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(24, 0, encoded, 256) == + ConfigurationTransactionStatus::kReceiving && + profile_service_append(24, 256, encoded + 256, 128) == + ConfigurationTransactionStatus::kReceiving && + profile_service_commit(24) == + ConfigurationTransactionStatus::kPending, + "service did not receive both parts of the schema6 payload"); + profile_service_selected_snapshot(&selected); + require(selected.metadata.generation == old_selection.metadata.generation && + active_snapshot(id).profile.turbo_defaults.rate_hz == + old_selection.profile.turbo_defaults.rate_hz, + "pending write replaced a selected or active profile before commit"); + profile_service_task_on_storage_core(8000); + profile_service_selected_snapshot(&selected); + require(transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kCommitted && + selected.valid && selected.profile.shortcuts.selectors[6] == 14 && + selected.profile.macros[0].repeat_count == 255 && + active_snapshot(id).profile.turbo_defaults.rate_hz == 30, + "committed schema6 profile did not atomically refresh snapshots"); } -} // namespace - -ProfileStorageIo pico_profile_storage_io() { - return fake_io(); +void test_catalog1_selected_and_active_snapshots_migrate() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + const ControllerIdentity id = controller_identity_global(); + ControllerProfile original = controller_profile_default(id, 7); + original.weak_rumble_scale = 73; + original.macros[0].trigger_mask = 1u << 10; + original.macros[0].step_count = 1; + original.macro_step_count = 1; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) + original.macros[index].first_step = 1; + original.macro_steps[0].duration_ms = 125; + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(original, payload, sizeof(payload)), + "old service snapshot fixture did not encode"); + payload[0] = 5; + payload[2] = 0; + payload[3] = 1; + const auto put_u32 = [](uint8_t *output, uint32_t value) { + for (uint8_t byte = 0; byte < 4; ++byte) { + output[byte] = static_cast(value >> (8 * byte)); + } + }; + for (uint8_t record_index = 0; record_index < 2; ++record_index) { + uint8_t *record = &flash.bytes[0][PROFILE_STORAGE_RECORDS_OFFSET + + record_index * PROFILE_STORAGE_RECORD_SIZE]; + memset(record, 0, PROFILE_STORAGE_RECORD_SIZE); + memcpy(record, "SPCR", 4); + record[4] = 1; + record[6] = record_index == 0 ? 1 : 4; + record[7] = 7; + put_u32(record + 8, 41 + record_index); + if (record_index == 0) { + record[13] = 1; + record[14] = 5; + memcpy(record + 256, payload, 256); + put_u32(record + 16, profile_storage_crc32(payload, 256)); + } + require(controller_identity_encode(id, record + 20, + CONTROLLER_IDENTITY_ENCODED_SIZE), + "old service identity did not encode"); + put_u32(record + 34, profile_storage_crc32(record, 34)); + } + uint8_t *superblock = flash.bytes[0]; + memset(superblock, 0, PROFILE_STORAGE_SUPERBLOCK_SIZE); + memcpy(superblock, "SPCA", 4); + superblock[4] = 1; + put_u32(superblock + 8, 9); + put_u32(superblock + 12, profile_storage_crc32(superblock, 12)); + profile_service_initialize_on_storage_core(); + require(profile_service_select(id, 7) == + ConfigurationTransactionStatus::kCommitted, + "migrated profile could not be selected"); + ProfileServiceSelectedSnapshot selected{}; + profile_service_selected_snapshot(&selected); + const auto active = active_snapshot(id); + require(selected.valid && active.valid && active.profile_index == 7 && + selected.profile.weak_rumble_scale == 73 && + active.profile.macro_steps[0].duration_ms == 125 && + selected.profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON && + selected.profile.shift.mode == ControllerProfileShiftMode::kOff && + selected.profile.macros[0].mode == ControllerProfileMacroMode::kOnce && + selected.metadata.generation > 42, + "old selection/activation snapshots lost migrated content or defaults"); } +} // 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; + test_eight_profile_transactions_and_active_cache(); + test_profile_bounds_and_transaction_namespace(); + test_schema6_validation_and_atomic_selection(); + test_catalog1_selected_and_active_snapshots_migrate(); + std::cout << "profile service tests passed\n"; + return 0; } diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index e0fdc9d..4e01713 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -1,658 +1,747 @@ #include "core/controller_identity.h" #include "profile/controller_profile.h" #include "profile/profile_storage.h" -#include "controller_profile_legacy_fixtures.h" #include #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; + uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]; + int programs = 0; + int erases = 0; + int fail_after_programs = -1; + int fail_after_erases = -1; + size_t torn_page_bytes = 0; + int corrupt_previous_after_programs = -1; + uint8_t corrupt_arena = 0; + size_t corrupt_offset = 0; + bool corrupt_next_program = false; }; 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 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; + flash = FakeFlash{}; + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + flash.fail_after_programs = -1; } -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_read(void *context, uint8_t arena, size_t offset, uint8_t *output, + size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) { + return false; + } + memcpy(output, &storage->bytes[arena][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; - } +bool fake_erase(void *context, uint8_t arena) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT) { + return false; + } + if (storage->fail_after_erases >= 0 && + storage->erases >= storage->fail_after_erases) { + memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE / 2); + return false; + } + memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE); + ++storage->erases; + return true; +} - ++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; +bool fake_program(void *context, uint8_t arena, size_t offset, + const uint8_t *page, size_t size) { + auto *storage = static_cast(context); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset + size > PROFILE_STORAGE_ARENA_SIZE) { + return false; + } + if (storage->fail_after_programs >= 0 && + storage->programs >= storage->fail_after_programs) { + for (size_t index = 0; index < storage->torn_page_bytes; ++index) { + storage->bytes[arena][offset + index] &= page[index]; } - 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; + return false; + } + for (size_t index = 0; index < size; ++index) { + storage->bytes[arena][offset + index] &= page[index]; + } + if (storage->corrupt_next_program) { + storage->bytes[arena][offset] ^= 1; + storage->corrupt_next_program = false; + } + ++storage->programs; + if (storage->programs == storage->corrupt_previous_after_programs) { + storage->bytes[storage->corrupt_arena][storage->corrupt_offset] ^= 1; + } + return memcmp(&storage->bytes[arena][offset], page, size) == 0; } ProfileStorageIo fake_io() { - return { - &flash, - PROFILE_STORAGE_BANK_SIZE, - PROFILE_STORAGE_SECTOR_SIZE, - PROFILE_STORAGE_PAGE_SIZE, - fake_read, - fake_replace_bank, - }; + return { + &flash, + PROFILE_STORAGE_ARENA_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, + fake_read, + fake_erase, + fake_program, + }; } -uint16_t fixture_read_u16(const uint8_t* input) { - return static_cast(input[0]) | - static_cast(input[1] << 8); +ControllerIdentity identity(uint8_t suffix) { + ControllerIdentity result{}; + result.stable = true; + result.transport = ControllerTransport::kClassic; + result.address[5] = suffix; + result.vendor_id = 0x1234; + result.product_id = 0x5678; + return result; } -void fixture_write_u16(uint8_t* output, uint16_t value) { - output[0] = static_cast(value); - output[1] = static_cast(value >> 8); +void 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 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, - }; +void encode_schema5(const ControllerProfile &profile, uint8_t *output) { + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)), + "profile fixture did not encode"); + memcpy(output, encoded, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE); + write_u16(output, CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION); + write_u16(output + 2, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE); +} - 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); +void install_legacy_database(uint32_t generation = 41) { + constexpr size_t kLegacyStart = + PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE; + constexpr uint8_t kArena = 1; + constexpr size_t kArenaBase = kLegacyStart - PROFILE_STORAGE_ARENA_SIZE; + constexpr uint8_t kBank = 1; + constexpr size_t kBankBase = + kArenaBase + kBank * PROFILE_STORAGE_LEGACY_BANK_SIZE; + uint8_t *header = flash.bytes[kArena] + kBankBase; + uint8_t *payload = header + PROFILE_STORAGE_LEGACY_HEADER_SIZE; + memset(header, 0, PROFILE_STORAGE_LEGACY_HEADER_SIZE); + memset(payload, 0, PROFILE_STORAGE_LEGACY_DATABASE_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, "SPDB", 4); + write_u16(&payload[4], 2); + write_u16(&payload[6], PROFILE_STORAGE_LEGACY_DATABASE_SIZE); + payload[8] = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + payload[9] = PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + payload[10] = 3; + payload[11] = 1; + + uint8_t encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{}; + const ControllerIdentity global = controller_identity_global(); + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + ControllerProfile value = controller_profile_default(global, profile); + value.weak_rumble_scale = static_cast(20 + profile); + encode_schema5(value, encoded); + memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded)); + } + + uint8_t *entry = + payload + 32 + + PROFILE_STORAGE_LEGACY_PROFILE_COUNT * + CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; + const ControllerIdentity stable = identity(7); + require(controller_identity_encode(stable, entry, + CONTROLLER_IDENTITY_ENCODED_SIZE), + "legacy identity did not encode"); + entry[14] = 2; + entry[15] = 1; + for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; + ++profile) { + ControllerProfile value = controller_profile_default(stable, profile); + value.strong_rumble_scale = static_cast(40 + profile); + encode_schema5(value, encoded); + memcpy(entry + 16 + profile * sizeof(encoded), encoded, sizeof(encoded)); + } + + memcpy(header, "SPPF", 4); + write_u16(&header[4], 1); + write_u16(&header[6], 2); + write_u32(&header[8], generation); + write_u32(&header[12], PROFILE_STORAGE_LEGACY_DATABASE_SIZE); + write_u32(&header[16], profile_storage_crc32( + payload, PROFILE_STORAGE_LEGACY_DATABASE_SIZE)); + write_u32(&header[20], profile_storage_crc32(header, 20)); +} + +ControllerIdentity catalog_identity(uint8_t index) { + return index == 0 ? controller_identity_global() : identity(index); +} + +ControllerProfile catalog_profile(uint8_t index, uint8_t slot, bool extended) { + ControllerProfile profile = + controller_profile_default(catalog_identity(index), slot); + profile.weak_rumble_scale = static_cast(10 + index); + profile.strong_rumble_scale = static_cast(30 + slot); + profile.macros[0].trigger_mask = 1u << 10; + profile.macros[0].step_count = 8; + profile.macros[1].trigger_mask = 1u << 11; + profile.macros[1].first_step = 8; + profile.macros[1].step_count = 8; + profile.macros[2].first_step = 16; + profile.macros[3].first_step = 16; + profile.macro_step_count = 16; + // Fill 132 of the old 136 stream bytes, including the old page tail. + for (uint8_t step = 0; step < profile.macro_step_count; ++step) { + auto &value = profile.macro_steps[step]; + value.duration_ms = static_cast(1 + step); + if (step < 14) { + value.override_flags = kControllerProfileOverrideButtons | + kControllerProfileOverrideLeftStick; + value.output_button_mask = static_cast(1u << (step % 16)); + value.left_stick_x = static_cast(100 * step - 500); + value.left_stick_y = static_cast(100 * slot + index); } - - 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); + } + if (extended) { + profile.shortcuts.modifier = 4; + const uint8_t selectors[8] = {0, 1, 2, 3, 12, 13, 14, 15}; + memcpy(profile.shortcuts.selectors, selectors, sizeof(selectors)); + profile.shift.mode = ControllerProfileShiftMode::kToggle; + profile.shift.modifier = 17; + profile.shift.button_map[0] = 15; + profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst; + profile.turbo_defaults = {7, 33, 5}; + profile.turbo_override_mask = UINT16_MAX; + for (uint8_t button = 0; button < 16; ++button) { + profile.turbo_overrides[button] = { + static_cast(button + 1), + static_cast(button + 2), + static_cast(button + 3)}; } - - 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)); + profile.macros[0].mode = ControllerProfileMacroMode::kRepeat; + profile.macros[0].repeat_count = 255; + } + return profile; } -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 install_catalog_record(uint16_t version, size_t offset, uint8_t type, + const ControllerIdentity &identity_value, + uint8_t slot, uint32_t generation, + const uint8_t *payload, size_t size) { + uint8_t *record = &flash.bytes[0][offset]; + memset(record, 0, PROFILE_STORAGE_RECORD_SIZE); + memcpy(record, "SPCR", 4); + write_u16(record + 4, version); + record[6] = type; + record[7] = slot; + write_u32(record + 8, generation); + write_u16(record + 12, static_cast(size)); + write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0); + write_u32(record + 16, profile_storage_crc32(payload, size)); + require(controller_identity_encode(identity_value, record + 20, + CONTROLLER_IDENTITY_ENCODED_SIZE), + "catalog fixture identity did not encode"); + write_u32(record + 34, profile_storage_crc32(record, 34)); + if (size != 0) { + memcpy(record + (version == 1 ? 256 : 128), payload, size); + } } -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"); +void install_populated_catalog(uint16_t version, bool fill_arena = false) { + size_t offset = PROFILE_STORAGE_RECORDS_OFFSET; + uint32_t generation = 1000; + for (uint8_t index = 0; + index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) { + const ControllerIdentity id = catalog_identity(index); + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + const ControllerProfile profile = catalog_profile(index, slot, version == 2); + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (version == 1) { + encode_schema5(profile, payload); + } else { + require(controller_profile_encode(profile, payload, sizeof(payload)), + "catalog2 fixture profile did not encode"); + } + install_catalog_record( + version, offset, 1, id, slot, ++generation, payload, + version == 1 ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE + : CONTROLLER_PROFILE_ENCODED_SIZE); + offset += PROFILE_STORAGE_RECORD_SIZE; } - 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"); + uint8_t alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + alias[0] = 1; + alias[1] = static_cast('A' + index); + install_catalog_record(version, offset, 5, id, CONTROLLER_PROFILE_ALL, + ++generation, alias, sizeof(alias)); + offset += PROFILE_STORAGE_RECORD_SIZE; + uint8_t names[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{}; + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + const size_t base = slot * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; + names[base] = 2; + names[base + 1] = static_cast('A' + index); + names[base + 2] = static_cast('0' + slot); + } + install_catalog_record(version, offset, 6, id, CONTROLLER_PROFILE_ALL, + ++generation, names, sizeof(names)); + offset += PROFILE_STORAGE_RECORD_SIZE; + install_catalog_record(version, offset, 4, id, index % 8, + ++generation, nullptr, 0); + offset += PROFILE_STORAGE_RECORD_SIZE; + } + while (fill_arena && offset < PROFILE_STORAGE_ARENA_SIZE) { + install_catalog_record(version, offset, 4, catalog_identity(0), 0, + ++generation, nullptr, 0); + offset += PROFILE_STORAGE_RECORD_SIZE; + } + uint8_t *superblock = flash.bytes[0]; + memset(superblock, 0, PROFILE_STORAGE_SUPERBLOCK_SIZE); + memcpy(superblock, "SPCA", 4); + write_u16(superblock + 4, version); + write_u32(superblock + 8, 20); + write_u32(superblock + 12, profile_storage_crc32(superblock, 12)); } -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"); +void require_populated_catalog(const ProfileStorage &storage, bool extended) { + require(storage.identity_count() == + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1, + "catalog lost a populated identity"); + for (uint8_t index = 0; + index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) { + const ControllerIdentity id = catalog_identity(index); + const auto *entry = storage.find(id); + require(entry != nullptr && entry->active_profile == index % 8, + "catalog lost an active profile index"); + char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(storage.get_alias(id, value, sizeof(value)) == + ProfileStorageResult::kOk && + value[0] == 'A' + index && value[1] == '\0', + "catalog lost a controller alias"); + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + ControllerProfile actual{}; + const ControllerProfile expected = catalog_profile(index, slot, extended); + uint8_t actual_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + uint8_t expected_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(storage.get(id, slot, &actual) == ProfileStorageResult::kOk && + controller_profile_encode(actual, actual_bytes, + sizeof(actual_bytes)) && + controller_profile_encode(expected, expected_bytes, + sizeof(expected_bytes)) && + memcmp(actual_bytes, expected_bytes, sizeof(actual_bytes)) == 0, + "catalog lost profile content or a macro at the old page tail"); + require(storage.get_profile_name(id, slot, value, sizeof(value)) == + ProfileStorageResult::kOk && + value[0] == 'A' + index && value[1] == '0' + slot && + value[2] == '\0', + "catalog lost a profile name"); } - 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"); +void test_empty_catalog_and_eight_profiles() { + erase_all(); + ProfileStorage storage; + require(storage.initialize(fake_io()) && storage.snapshot().valid && + storage.identity_count() == 1, + "erased flash did not initialize an empty catalog"); + const ControllerIdentity global = controller_identity_global(); + ControllerProfile profile = controller_profile_default(global, 7); + profile.strong_rumble_scale = 77; + require(storage.set(global, 7, profile) == ProfileStorageResult::kOk, + "profile eight did not append"); + require(storage.activate(global, 7) == ProfileStorageResult::kOk, + "profile eight did not activate"); - 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 reloaded; + ControllerProfile recovered{}; + require(reloaded.initialize(fake_io()) && reloaded.find(global) != nullptr && + reloaded.find(global)->active_profile == 7 && + reloaded.get(global, 7, &recovered) == + ProfileStorageResult::kOk && + recovered.strong_rumble_scale == 77, + "profile eight did not survive reload"); +} - 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)) == +void test_identity_capacity_and_defaults() { + erase_all(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + for (uint8_t index = 1; index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; + ++index) { + require(storage.ensure_identity(identity(index)) == 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"); + "stable identity was not indexed"); + } + require(storage.ensure_identity(identity(99)) == ProfileStorageResult::kFull, + "identity catalog accepted a seventeenth stable identity"); + ControllerProfile profile{}; + require(storage.get(identity(1), 7, &profile) == ProfileStorageResult::kOk && + controller_profile_validate(profile), + "unstored profile eight did not resolve to its default"); +} + +void test_interrupted_and_corrupt_append_recovery() { + erase_all(); + const ControllerIdentity global = controller_identity_global(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + ControllerProfile first = controller_profile_default(global, 0); + first.weak_rumble_scale = 11; + require(storage.set(global, 0, first) == ProfileStorageResult::kOk, + "baseline profile did not append"); + + ControllerProfile second = first; + second.weak_rumble_scale = 22; + flash.fail_after_programs = flash.programs + 1; + require(storage.set(global, 0, second) == ProfileStorageResult::kIoError, + "interrupted header publish reported success"); + flash.fail_after_programs = -1; + ProfileStorage recovered; + ControllerProfile value{}; + require(recovered.initialize(fake_io()) && + recovered.get(global, 0, &value) == ProfileStorageResult::kOk && + value.weak_rumble_scale == 11, + "interrupted append displaced the previous record"); + + flash.corrupt_next_program = true; + second.weak_rumble_scale = 33; + require(recovered.set(global, 0, second) == ProfileStorageResult::kIoError, + "corrupt payload program reported success"); + ProfileStorage after_corruption; + require(after_corruption.initialize(fake_io()) && + after_corruption.get(global, 0, &value) == + ProfileStorageResult::kOk && + value.weak_rumble_scale == 11, + "corrupt newest record displaced the previous record"); + second.weak_rumble_scale = 44; + require(after_corruption.set(global, 0, second) == ProfileStorageResult::kOk, + "interrupted second-page slot was reused instead of skipped"); + ProfileStorage after_retry; + require(after_retry.initialize(fake_io()) && + after_retry.get(global, 0, &value) == ProfileStorageResult::kOk && + value.weak_rumble_scale == 44, + "append after interrupted and corrupt slots did not survive reload"); +} + +void test_profile_names_and_aliases_recover() { + erase_all(); + const ControllerIdentity stable = identity(7); + ProfileStorage storage; + require(storage.initialize(fake_io()), "metadata catalog did not initialize"); + require(storage.set_alias(stable, "Player one", 10) == + ProfileStorageResult::kOk && + storage.set_profile_name(stable, 0, "Zelda", 5) == + ProfileStorageResult::kOk && + storage.set_profile_name(stable, 7, "Desktop", 7) == + ProfileStorageResult::kOk, + "profile metadata did not append"); + char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(storage.get_alias(stable, value, sizeof(value)) == + ProfileStorageResult::kOk && + strcmp(value, "Player one") == 0, + "controller alias did not read back"); + + flash.fail_after_programs = flash.programs + 1; + require(storage.set_alias(stable, "Interrupted", 11) == + ProfileStorageResult::kIoError, + "interrupted alias append reported success"); + flash.fail_after_programs = -1; + ProfileStorage reloaded; + require(reloaded.initialize(fake_io()) && + reloaded.get_alias(stable, value, sizeof(value)) == + ProfileStorageResult::kOk && + strcmp(value, "Player one") == 0, + "interrupted alias displaced the previous value"); + require(reloaded.get_profile_name(stable, 0, value, sizeof(value)) == + ProfileStorageResult::kOk && + strcmp(value, "Zelda") == 0 && + reloaded.get_profile_name(stable, 7, value, sizeof(value)) == + ProfileStorageResult::kOk && + strcmp(value, "Desktop") == 0, + "profile names did not survive reload"); +} + +void test_compaction_preserves_latest_records() { + erase_all(); + const ControllerIdentity global = controller_identity_global(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + ControllerProfile profile = controller_profile_default(global, 0); + require(storage.set_alias(global, "Fallback", 8) == + ProfileStorageResult::kOk && + storage.set_profile_name(global, 0, "Compacted", 9) == + ProfileStorageResult::kOk, + "compaction metadata did not append"); + for (uint16_t write = 1; write <= 260; ++write) { + profile.weak_rumble_scale = static_cast(write); + require(storage.set(global, 0, profile) == ProfileStorageResult::kOk, + "catalog update failed while forcing compaction"); + } + require(storage.snapshot().active_bank == 1 && flash.erases >= 2, + "full arena did not compact into its peer"); + ProfileStorage reloaded; + ControllerProfile recovered{}; + char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(reloaded.initialize(fake_io()) && + reloaded.get(global, 0, &recovered) == + ProfileStorageResult::kOk && + recovered.weak_rumble_scale == static_cast(260) && + reloaded.get_alias(global, metadata, sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "Fallback") == 0 && + reloaded.get_profile_name(global, 0, metadata, + sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "Compacted") == 0, + "compaction did not preserve profiles and metadata"); +} + +void test_legacy_migration_is_atomic_and_complete() { + erase_all(); + install_legacy_database(); + ProfileStorage storage; + require(storage.initialize(fake_io()) && storage.snapshot().valid && + storage.identity_count() == 2, + "legacy database did not migrate"); + const ControllerIdentity global = controller_identity_global(); + ControllerProfile profile{}; + require(storage.find(global)->active_profile == 3 && + storage.get(global, 3, &profile) == ProfileStorageResult::kOk && + profile.weak_rumble_scale == 23, + "legacy fallback profiles were not preserved"); + require(storage.get(global, 7, &profile) == ProfileStorageResult::kOk && + profile.weak_rumble_scale == UINT8_MAX, + "new profile slots were not defaulted during migration"); + const ControllerIdentity stable = identity(7); + require(storage.find(stable) != nullptr && + storage.find(stable)->active_profile == 2 && + storage.get(stable, 2, &profile) == ProfileStorageResult::kOk && + profile.strong_rumble_scale == 42, + "legacy stable identity was not preserved"); + + ProfileStorage reloaded; + require(reloaded.initialize(fake_io()) && reloaded.find(stable) != nullptr && + reloaded.find(stable)->active_profile == 2, + "migrated catalog did not survive reload"); +} + +void test_populated_catalog_publication_power_loss() { + static FakeFlash baseline; + for (uint16_t version = 1; version <= 2; ++version) { + erase_all(); + install_populated_catalog(version, true); + baseline = flash; + ProfileStorage completed; + require(completed.initialize(fake_io()), "populated catalog did not load"); + require_populated_catalog(completed, version == 2); + if (version == 2) { + require(completed.set_alias(catalog_identity(0), "New", 3) == + ProfileStorageResult::kOk, + "populated catalog could not compact all 187 live records"); } + const int publication_programs = flash.programs; + for (size_t torn_bytes : {size_t{0}, size_t{129}}) { + // Include interruption during arena erase and before every program page, + // including both pages of every record and the final superblock. + for (int cut = -1; cut < publication_programs; ++cut) { + flash = baseline; + flash.torn_page_bytes = torn_bytes; + ProfileStorage interrupted; + if (version == 2) { + require(interrupted.initialize(fake_io()), + "catalog2 power-loss baseline did not load"); + } + if (cut < 0) { + flash.fail_after_erases = 0; + } else { + flash.fail_after_programs = cut; + } + if (version == 1) { + require(!interrupted.initialize(fake_io()), + "incomplete catalog1 migration silently initialized empty"); + } else { + require(interrupted.set_alias(catalog_identity(0), "New", 3) == + ProfileStorageResult::kIoError, + "incomplete compaction or append reported success"); + } + require(memcmp(flash.bytes[0], baseline.bytes[0], + PROFILE_STORAGE_ARENA_SIZE) == 0, + "interrupted publication modified the old published arena"); + flash.fail_after_erases = -1; + flash.fail_after_programs = -1; + flash.torn_page_bytes = 0; + ProfileStorage recovered; + require(recovered.initialize(fake_io()), + "catalog did not recover after an interrupted publication"); + require_populated_catalog(recovered, version == 2); + require(recovered.snapshot().generation >= 1248, + "catalog publication regressed the stored generation"); + } + } + } +} + +void test_migration_verifies_all_records_before_publication() { + erase_all(); + install_populated_catalog(1); + static FakeFlash baseline; + baseline = flash; + // Corrupt an already-verified first record while the last record is being + // copied. A per-write readback alone would publish an incomplete catalog. + flash.corrupt_previous_after_programs = + 2 * PROFILE_STORAGE_MAX_LIVE_RECORDS; + flash.corrupt_arena = 1; + flash.corrupt_offset = PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283; + ProfileStorage interrupted; + require(!interrupted.initialize(fake_io()) && + flash.bytes[1][0] == 0xff && + memcmp(flash.bytes[0], baseline.bytes[0], + PROFILE_STORAGE_ARENA_SIZE) == 0, + "migration published before fully verifying the copied arena"); + flash.corrupt_previous_after_programs = -1; + ProfileStorage recovered; + require(recovered.initialize(fake_io()), + "migration did not retry after copy verification failed"); + require_populated_catalog(recovered, false); +} + +void test_retired_bank_migration_power_loss() { + erase_all(); + install_legacy_database(); + static FakeFlash baseline; + baseline = flash; + ProfileStorage complete; + require(complete.initialize(fake_io()), "retired bank did not migrate"); + const int programs = flash.programs; + for (int cut = -1; cut < programs; ++cut) { + flash = baseline; + flash.torn_page_bytes = 129; + if (cut < 0) { + flash.fail_after_erases = 0; + } else { + flash.fail_after_programs = cut; + } + ProfileStorage interrupted; + require(!interrupted.initialize(fake_io()) && + memcmp(flash.bytes[1], baseline.bytes[1], + PROFILE_STORAGE_ARENA_SIZE) == 0, + "interrupted retired-bank migration lost the old database"); + flash.fail_after_erases = -1; + flash.fail_after_programs = -1; + flash.torn_page_bytes = 0; 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)) == + ControllerProfile profile{}; + require(recovered.initialize(fake_io()) && + recovered.find(identity(7)) != nullptr && + recovered.find(identity(7))->active_profile == 2 && + recovered.get(identity(7), 2, &profile) == 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"); + profile.strong_rumble_scale == 42 && + recovered.get(catalog_identity(0), 3, &profile) == + ProfileStorageResult::kOk && + profile.weak_rumble_scale == 23 && + recovered.snapshot().generation > 41, + "retired-bank migration did not recover its profiles/generation"); + } } -} // namespace +void test_late_second_page_program_is_not_reused() { + erase_all(); + ProfileStorage storage; + require(storage.initialize(fake_io()), "catalog did not initialize"); + // The first page and first bytes of page two are erased; only a late + // default-rate byte was programmed when power was lost. + flash.bytes[0][PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283] = 0; + ProfileStorage recovered; + require(recovered.initialize(fake_io()), "partial record did not recover"); + const auto id = catalog_identity(0); + ControllerProfile profile = catalog_profile(0, 7, true); + require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk, + "scanner reused a partially programmed second page"); + profile.turbo_defaults.rate_hz = 23; + require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk, + "newest profile did not append"); + const uint32_t newest = recovered.find(id)->profile_record[7]; + flash.bytes[0][newest + 128 + 283] ^= 1; + ProfileStorage fallback; + ControllerProfile value{}; + require(fallback.initialize(fake_io()) && + fallback.get(id, 7, &value) == ProfileStorageResult::kOk && + value.turbo_defaults.rate_hz == 7 && + value.macros[0].repeat_count == 255, + "corrupted schema6 newest record did not fall back to its predecessor"); +} + +void test_unreadable_legacy_data_is_not_erased() { + erase_all(); + install_legacy_database(); + constexpr size_t base = PROFILE_STORAGE_ARENA_SIZE - + PROFILE_STORAGE_LEGACY_BANK_SIZE; + flash.bytes[1][base + PROFILE_STORAGE_LEGACY_HEADER_SIZE + 32] ^= 1; + static FakeFlash baseline; + baseline = flash; + ProfileStorage storage; + require(!storage.initialize(fake_io()) && + memcmp(flash.bytes, baseline.bytes, sizeof(flash.bytes)) == 0, + "unreadable old data was silently replaced with an empty catalog"); +} + +void test_legacy_high_generation_remains_mutable() { + erase_all(); + install_legacy_database(0x80000000u); + ProfileStorage storage; + require(storage.initialize(fake_io()), + "high-generation legacy bank could not migrate"); + const auto global = controller_identity_global(); + auto changed = controller_profile_default(global, 0); + changed.weak_rumble_scale = 77; + require(storage.set(global, 0, changed) == ProfileStorageResult::kOk, + "high-generation migrated profile could not be updated"); + ProfileStorage reloaded; + ControllerProfile restored{}; + require(reloaded.initialize(fake_io()) && + reloaded.get(global, 0, &restored) == ProfileStorageResult::kOk && + restored.weak_rumble_scale == 77, + "high-generation update was lost after reload"); +} + +} // 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; + test_empty_catalog_and_eight_profiles(); + test_identity_capacity_and_defaults(); + test_interrupted_and_corrupt_append_recovery(); + test_profile_names_and_aliases_recover(); + test_compaction_preserves_latest_records(); + test_legacy_migration_is_atomic_and_complete(); + test_legacy_high_generation_remains_mutable(); + test_populated_catalog_publication_power_loss(); + test_migration_verifies_all_records_before_publication(); + test_retired_bank_migration_power_loss(); + test_late_second_page_program_is_not_reused(); + test_unreadable_legacy_data_is_not_erased(); + std::cout << "profile storage tests passed\n"; + return 0; } diff --git a/tests/switch2_wake_native_stubs/btstack.h b/tests/switch2_wake_native_stubs/btstack.h new file mode 100644 index 0000000..a65ec2a --- /dev/null +++ b/tests/switch2_wake_native_stubs/btstack.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +using bd_addr_t = uint8_t[6]; +using btstack_packet_handler_t = void (*)(uint8_t, uint16_t, uint8_t*, uint16_t); +struct btstack_packet_callback_registration_t { + void* item; + btstack_packet_handler_t callback; +}; +struct btstack_timer_source_t { + void (*handler)(btstack_timer_source_t*); + uint32_t timeout_ms; + uint32_t add_count; +}; +struct hci_cmd_t { + uint16_t opcode; + const char* format; +}; + +enum { + ERROR_CODE_SUCCESS = 0, + HCI_EVENT_PACKET = 4, + HCI_EVENT_COMMAND_COMPLETE = 0x0e, + BD_ADDR_TYPE_LE_PUBLIC = 0, +}; + +extern const hci_cmd_t hci_read_bd_addr; +extern const hci_cmd_t hci_le_set_advertising_parameters; +extern const hci_cmd_t hci_le_set_advertising_data; +extern const hci_cmd_t hci_le_set_advertise_enable; + +bool hci_can_send_command_packet_now(); +uint8_t hci_send_cmd(const hci_cmd_t* command, ...); +void hci_add_event_handler(btstack_packet_callback_registration_t* registration); +void gap_local_bd_addr(bd_addr_t output); +uint32_t btstack_run_loop_get_time_ms(); +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, + void (*handler)(btstack_timer_source_t*)); +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms); +void btstack_run_loop_add_timer(btstack_timer_source_t* timer); +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); + +inline uint8_t hci_event_packet_get_type(const uint8_t* event) { + return event[0]; +} +inline uint16_t hci_event_command_complete_get_command_opcode( + const uint8_t* event) { + return static_cast(event[3]) | + static_cast(event[4] << 8); +} +inline const uint8_t* hci_event_command_complete_get_return_parameters( + const uint8_t* event) { + return &event[5]; +} diff --git a/tests/switch2_wake_test.cpp b/tests/switch2_wake_test.cpp new file mode 100644 index 0000000..22ddb0e --- /dev/null +++ b/tests/switch2_wake_test.cpp @@ -0,0 +1,249 @@ +#include +#include +#include +#include + +#include + +namespace { + +struct SubmittedCommand { + uint16_t opcode; + uint8_t address[6]; + uint16_t interval_min; + uint16_t interval_max; + uint8_t advertising_type; + uint8_t own_address_type; + uint8_t channel_map; + uint8_t filter_policy; + uint8_t data[31]; + uint8_t data_length; + uint8_t enabled; +}; + +uint32_t now_ms; +bd_addr_t original_address = {0xA8, 0x59, 0x5F, 0x9E, 0x37, 0x65}; +btstack_packet_handler_t event_handler; +btstack_timer_source_t* installed_timer; +SubmittedCommand submitted[16]{}; +size_t submitted_count; +bool command_available = true; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +} // namespace + +const hci_cmd_t hci_read_bd_addr = {0x1009, "read-address"}; +const hci_cmd_t hci_le_set_advertising_parameters = {0x2006, "params"}; +const hci_cmd_t hci_le_set_advertising_data = {0x2008, "data"}; +const hci_cmd_t hci_le_set_advertise_enable = {0x200a, "enable"}; + +bool hci_can_send_command_packet_now() { + return command_available; +} + +uint8_t hci_send_cmd(const hci_cmd_t* command, ...) { + require(command_available, "submitted without an HCI command credit"); + require(submitted_count < 16, "command capture overflow"); + command_available = false; + SubmittedCommand& output = submitted[submitted_count++]; + output.opcode = command->opcode; + va_list arguments; + va_start(arguments, command); + if (command->opcode == 0xfc01) { + const uint8_t* address = va_arg(arguments, const uint8_t*); + memcpy(output.address, address, sizeof(output.address)); + } else if (command->opcode == 0x2006) { + output.interval_min = static_cast(va_arg(arguments, int)); + output.interval_max = static_cast(va_arg(arguments, int)); + output.advertising_type = static_cast(va_arg(arguments, int)); + output.own_address_type = static_cast(va_arg(arguments, int)); + (void)va_arg(arguments, int); + (void)va_arg(arguments, const uint8_t*); + output.channel_map = static_cast(va_arg(arguments, int)); + output.filter_policy = static_cast(va_arg(arguments, int)); + } else if (command->opcode == 0x2008) { + output.data_length = static_cast(va_arg(arguments, int)); + const uint8_t* data = va_arg(arguments, const uint8_t*); + memcpy(output.data, data, output.data_length); + } else if (command->opcode == 0x200a) { + output.enabled = static_cast(va_arg(arguments, int)); + } + va_end(arguments); + return ERROR_CODE_SUCCESS; +} + +void hci_add_event_handler( + btstack_packet_callback_registration_t* registration) { + event_handler = registration->callback; +} + +void gap_local_bd_addr(bd_addr_t output) { + memcpy(output, original_address, sizeof(original_address)); +} + +uint32_t btstack_run_loop_get_time_ms() { + return now_ms; +} + +void btstack_run_loop_set_timer_handler( + btstack_timer_source_t* timer, + void (*handler)(btstack_timer_source_t*)) { + timer->handler = handler; + installed_timer = timer; +} + +void btstack_run_loop_set_timer( + btstack_timer_source_t* timer, uint32_t timeout_ms) { + timer->timeout_ms = timeout_ms; +} + +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + ++timer->add_count; +} +int btstack_run_loop_remove_timer(btstack_timer_source_t*) { + return 1; +} + + +#define SWITCH2_WAKE_CONFIGURED 1 +#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \ + {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00} +#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \ + {0x02, 0x01, 0x06, 0x1B, 0xFF, 0x53, 0x05, 0x01, 0x00, 0x03, 0x7E, \ + 0x05, 0x66, 0x20, 0x00, 0x01, 0x81, 0xBD, 0xD6, 0xF7, 0xEB, 0xF1, \ + 0x48, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} +#include "input/switch2_wake.cpp" + +namespace { + +void complete(uint16_t opcode, uint8_t status = 0, + const uint8_t* address = nullptr) { + uint8_t event[12] = { + HCI_EVENT_COMMAND_COMPLETE, + static_cast(address == nullptr ? 4 : 10), + 1, + static_cast(opcode), static_cast(opcode >> 8), + status, + }; + if (address != nullptr) { + for (size_t index = 0; index < 6; ++index) { + event[6 + index] = address[5 - index]; + } + } + command_available = true; + event_handler( + HCI_EVENT_PACKET, 0, event, + static_cast(address == nullptr ? 6 : 12)); +} + +void run_task() { + require(installed_timer != nullptr && installed_timer->handler != nullptr, + "wake timer was not installed"); + installed_timer->handler(installed_timer); +} + +void require_opcode(size_t index, uint16_t opcode) { + require(index < submitted_count && submitted[index].opcode == opcode, + "unexpected HCI command sequence"); +} + +void test_stable_identity_wake() { + switch2_wake_initialize(); + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + require(diagnostics.configured && diagnostics.busy && + !switch2_wake_ready_for_connections() && + !switch2_wake_request(), + "controller connections or wake escaped startup identity setup"); + + run_task(); + require_opcode(0, 0xfc01); + const uint8_t wake_address[] = {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00}; + require(memcmp(submitted[0].address, wake_address, 6) == 0, + "stable wake address did not reach the radio command"); + complete(0xfc01); + run_task(); + require_opcode(1, 0x1009); + complete(0x1009, 0, wake_address); + require(switch2_wake_ready_for_connections() && !g_timer_armed, + "verified stable identity did not admit connections and go idle"); + + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && switch2_wake_request() && + !switch2_wake_request(), + "wake requests were not bounded while busy"); + run_task(); + require_opcode(2, 0x2006); + require(submitted[2].interval_min == 0x20 && + submitted[2].interval_max == 0x20 && + submitted[2].advertising_type == 3 && + submitted[2].own_address_type == 0 && + submitted[2].channel_map == 7 && + submitted[2].filter_policy == 0, + "known-working advertising parameters changed"); + complete(0x2006); + + run_task(); + require_opcode(3, 0x2008); + require(submitted[3].data_length == 31 && + submitted[3].data[16] == 0x81, + "captured wake payload was not submitted intact"); + complete(0x2008); + + run_task(); + require_opcode(4, 0x200a); + require(submitted[4].enabled == 1, + "wake advertising was not enabled"); + complete(0x200a); + require(g_timer_armed && installed_timer->timeout_ms == 2000, + "wake burst did not schedule one exact stop deadline"); + now_ms = 1999; + run_task(); + require(submitted_count == 5, + "wake burst stopped before two seconds"); + now_ms = 2000; + run_task(); + require_opcode(5, 0x200a); + require(submitted[5].enabled == 0, + "wake advertising was not disabled"); + complete(0x200a); + + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && diagnostics.accepted_requests == 1 && + diagnostics.completed_bursts == 1 && + diagnostics.failures == 0 && !g_timer_armed, + "completed wake did not return to a dormant idle state"); + require(submitted_count == 6, + "wake burst changed the public identity after startup"); +} + +void test_failed_wake_keeps_stable_identity() { + require(switch2_wake_request(), + "idle module rejected a second wake request"); + run_task(); + require_opcode(6, 0x2006); + complete(0x2006, 0x12); + run_task(); + require(submitted_count == 7, + "wake setup failure issued an address reset"); + + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + require(!diagnostics.busy && diagnostics.failures == 1 && + switch2_wake_ready_for_connections(), + "wake failure disrupted the stable controller identity"); +} + +} // namespace + +int main() { + test_stable_identity_wake(); + test_failed_wake_keeps_stable_identity(); + return 0; +} diff --git a/tests/switch_haptics_test.cpp b/tests/switch_haptics_test.cpp index 6db7ec4..18f1fba 100644 --- a/tests/switch_haptics_test.cpp +++ b/tests/switch_haptics_test.cpp @@ -197,6 +197,58 @@ void test_output_report_normalization() { } } +void test_full_fidelity_states() { + const auto check = [](bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } + }; + SwitchHapticsDecoder decoder; + auto bytes = payload(type_2(80, 100, 32, 127), type_2(16, 16, 100, 32)); + auto decoded = decoder.decode(bytes.data()); + const auto& left = decoded.hd.actuators[0]; + const auto& right = decoded.hd.actuators[1]; + check(left.sample_count == 1 && right.sample_count == 1, + "full state must retain both independent actuators"); + check(left.samples[0].low_frequency_index == 32 && + left.samples[0].high_frequency_index == 80 && + right.samples[0].low_frequency_index == 100 && + right.samples[0].high_frequency_index == 16, + "per-side frequencies were collapsed"); + check(left.samples[0].low_amplitude_q15 == 32066 && + right.samples[0].low_amplitude_q15 == 4096, + "per-side linear amplitudes were collapsed or incorrectly decoded"); + + decoder.reset(); + bytes = payload(type_2(64, 16, 64, 16), 0x40400100u); + decoder.decode(bytes.data()); + bytes = payload(type_1_three_samples(17, 17, 29, 29, 24, 24), 0x40400100u); + decoded = decoder.decode(bytes.data()); + const auto sequence = decoded.hd.actuators[0]; + check(sequence.sample_count == 3 && + sequence.samples[0].low_frequency_index == 65 && + sequence.samples[1].low_frequency_index == 66 && + sequence.samples[2].low_frequency_index == 66, + "ordered frequency substeps were lost"); + check(sequence.samples[0].low_amplitude_q15 > + sequence.samples[1].low_amplitude_q15 && + sequence.samples[1].low_amplitude_q15 == + sequence.samples[2].low_amplitude_q15, + "amplitude substeps were replaced by their peak or final value"); + decoded = decoder.decode(bytes.data()); + check(decoded.hd.actuators[0].sample_count == 1 && + decoded.hd.actuators[0].samples[0].low_frequency_index == 66 && + decoded.hd.actuators[0].samples[0].low_amplitude_q15 == + sequence.samples[2].low_amplitude_q15, + "repeated compressed commands replayed an old substep sequence"); + bytes = payload(0, type_2(64, 16, 64, 16)); + decoded = decoder.decode(bytes.data()); + check(decoded.hd.actuators[0].samples[0].low_amplitude_q15 == 0 && + decoded.hd.actuators[1].samples[0].low_amplitude_q15 != 0, + "neutral on one side stopped the other side"); +} + } // namespace int main() { @@ -207,6 +259,7 @@ int main() { test_left_right_peak_combination(); test_type_3_and_type_4_frames(); test_malformed_and_reserved_words_preserve_state(); + test_full_fidelity_states(); test_output_report_normalization(); if (failures != 0) { diff --git a/tests/switch_hd_rumble_synth_test.cpp b/tests/switch_hd_rumble_synth_test.cpp new file mode 100644 index 0000000..ecf9e29 --- /dev/null +++ b/tests/switch_hd_rumble_synth_test.cpp @@ -0,0 +1,582 @@ +#include "input/switch_hd_rumble_synth.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr double kTau = 6.2831853071795864769; +int failures = 0; + +void expect(bool condition, const char* scenario) { + if (!condition) { + std::cerr << scenario << '\n'; + ++failures; + } +} + +int signed_byte(uint8_t value) { + return value < 128 ? value : static_cast(value) - 256; +} + +SwitchHapticsSample state(uint8_t low_index = 64, uint16_t low = 32768, + uint8_t high_index = 64, uint16_t high = 0) { + return SwitchHapticsSample{low_index, high_index, low, high}; +} + +SwitchHapticsFrame one_side(unsigned side, SwitchHapticsSample sample = state()) { + SwitchHapticsFrame frame; + frame.actuators[side].sample_count = 1; + frame.actuators[side].samples[0] = sample; + return frame; +} + +std::vector render(SwitchHdRumbleSynth& synth, uint64_t first, + uint32_t frames) { + std::vector pcm(static_cast(frames) * 2, 0xcc); + synth.render(first, frames, pcm.data()); + return pcm; +} + +double wave(double cycles, uint16_t amplitude = 32768) { + return 127.0 * std::sin(kTau * cycles) * std::pow(amplitude / 32768.0, 0.8); +} + +double feedback_wave(double cycles, uint16_t amplitude = 32768) { + return 63.5 * std::sin(kTau * cycles) * amplitude / 32768; +} + +template +void expect_wave(const std::vector& pcm, unsigned side, + Function expected, const char* scenario) { + for (size_t sample = 0; sample < pcm.size() / 2; ++sample) { + const double wanted = expected(sample); + const int actual = signed_byte(pcm[sample * 2 + side]); + if (std::abs(actual - wanted) > 0.65) { + std::cerr << scenario << ": sample " << sample << " side " << side + << " expected " << wanted << ", got " << actual << '\n'; + ++failures; + return; + } + } +} + +void expect_silent(const std::vector& pcm, const char* scenario) { + expect(std::all_of(pcm.begin(), pcm.end(), [](uint8_t v) { return v == 0; }), + scenario); +} + +double spectral_amplitude(const std::vector& pcm, unsigned side, + double frequency) { + double real = 0; + double imaginary = 0; + const size_t frames = pcm.size() / 2; + for (size_t n = 0; n < frames; ++n) { + const double angle = kTau * frequency * n / 3000; + const int value = signed_byte(pcm[2 * n + side]); + real += value * std::cos(angle); + imaginary += value * std::sin(angle); + } + return 2 * std::hypot(real, imaginary) / frames; +} + +void test_physical_frequency_and_channels() { + for (unsigned side = 0; side < 2; ++side) { + for (unsigned band = 0; band < 2; ++band) { + for (uint8_t index : {0, 32, 64, 96, 127}) { + SwitchHdRumbleSynth synth; + synth.reset(123456); + const auto tone = one_side(side, state(index, band ? 0 : 32768, + index, band ? 32768 : 0)); + std::vector pcm(2400); + for (unsigned first = 0; first < 1200; first += 60) { + expect(synth.push(tone, 123456 + first * 1000 / 3), + "periodic host refresh accepted"); + synth.render(first, 60, pcm.data() + first * 2); + } + const double frequency = (band ? 80 : 40) * std::exp2(index / 32.0); + expect_wave(pcm, side, [frequency](size_t n) { + return wave(frequency * n / 3000); + }, "physical frequency and free-running phase"); + expect_wave(pcm, 1 - side, [](size_t) { return 0; }, + "opposite actuator remains silent"); + double peak_frequency = 0; + double peak_amplitude = 0; + for (int offset = -12; offset <= 12; ++offset) { + const double candidate = frequency + offset * 0.25; + const double amplitude = spectral_amplitude(pcm, side, candidate); + if (amplitude > peak_amplitude) { + peak_amplitude = amplitude; + peak_frequency = candidate; + } + } + expect(std::abs(peak_frequency - frequency) <= 0.5 && + peak_amplitude > 123 && peak_amplitude < 132, + "DFT peak matches physical frequency including extreme indices"); + } + } + } +} + +void test_linear_mix_headroom() { + SwitchHdRumbleSynth synth; + synth.reset(0); + auto frame = one_side(0, state(64, 32768, 32, 32768)); // Both bands 160 Hz. + frame.actuators[1] = one_side(1, state(64, 32768, 64, 32768)).actuators[1]; + synth.push(frame, 0); + const auto pcm = render(synth, 0, 150); + expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); }, + "coherent full-scale bands use headroom without waveform clipping"); + expect_wave(pcm, 1, [](size_t n) { + return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 2; + }, "full-scale two-band balance is preserved by the joint gain ceiling"); + int sum = 0; + for (size_t n = 0; n < pcm.size() / 2; ++n) { + sum += signed_byte(pcm[n * 2]); + expect(signed_byte(pcm[n * 2]) != -128, "PCM never overflows signed headroom"); + } + expect(std::abs(sum) <= 1, "symmetric rounding does not add DC bias"); + + synth.reset(0); + synth.push(one_side(0, state(64, 32768, 64, 16384)), 0); + expect_wave(render(synth, 0, 150), 0, [](size_t n) { + return 127.0 * (2 * std::sin(kTau * 160.0 * n / 3000) + + std::sin(kTau * 320.0 * n / 3000)) / 3; + }, "joint limiting preserves the two-band amplitude ratio"); + + synth.reset(0); + synth.push(one_side(0, state(64, 8192, 64, 4096)), 0); + expect_wave(render(synth, 0, 150), 0, [](size_t n) { + const double peak = 127.0 * std::pow(0.375, 0.8); + return peak * (2 * std::sin(kTau * 160.0 * n / 3000) + + std::sin(kTau * 320.0 * n / 3000)) / 3; + }, "quiet-effect curve preserves band balance instead of independently boosting voices"); + + synth.reset(0); + synth.push(one_side(0, state(127, 0, 127, 0)), 0); + expect_silent(render(synth, 0, 180), "profile-zero amplitudes are never boosted"); +} + +void test_substeps_and_preemption() { + SwitchHdRumbleSynth synth; + synth.reset(0); + SwitchHapticsFrame frame; + frame.actuators[0] = {3, {state(), state(64, 0), state(64, 16384)}}; + frame.actuators[1] = {2, {state(64, 0), state(64, 0, 64, 32768)}}; + synth.push(frame, 0); + auto pcm = render(synth, 0, 32); + expect_wave(pcm, 0, [](size_t n) { + return wave(160.0 * n / 3000, n < 8 ? 32768 : n < 16 ? 0 : 16384); + }, "three left substeps occupy 8/8/8 samples then hold"); + expect_wave(pcm, 1, [](size_t n) { + return n < 12 ? 0 : wave(320.0 * n / 3000); + }, "two right substeps independently occupy 12/12 samples"); + + synth.reset(0); + synth.push(frame, 0); + synth.push(one_side(0, state(64, 0)), 3000); // Sample 9 cancels old step 3. + pcm = render(synth, 0, 32); + expect_wave(pcm, 0, [](size_t n) { + return n < 8 ? wave(160.0 * n / 3000) : 0; + }, "new batch preempts future old substeps, not already elapsed samples"); + expect_wave(pcm, 1, [](size_t n) { + return n < 12 ? 0 : wave(320.0 * n / 3000); + }, "zero-count side preserves pending substeps on the other actuator"); +} + +void test_multiple_usb_updates_and_watchdogs() { + SwitchHdRumbleSynth synth; + synth.reset(1000); + synth.push(one_side(0), 1000); + synth.push(one_side(0, state(64, 0)), 6001); // Ceil to sample 16. + synth.push(one_side(0, state(64, 16384)), 12000); // Sample 33. + auto pcm = render(synth, 0, 64); + expect_wave(pcm, 0, [](size_t n) { + return wave(160.0 * n / 3000, n < 16 ? 32768 : n < 33 ? 0 : 16384); + }, "all USB updates within one 21.333 ms PCM interval are rendered"); + + synth.reset(0); + auto both = one_side(0); + both.actuators[1] = both.actuators[0]; + synth.push(both, 0); + synth.push(one_side(0), 20000); + synth.push(SwitchHapticsFrame{}, 40000); // Must not refresh either side. + pcm = render(synth, 0, 230); + expect_wave(pcm, 0, [](size_t n) { + return n < 210 ? wave(160.0 * n / 3000) : 0; + }, "left watchdog expires exactly 50 ms after its own update"); + expect_wave(pcm, 1, [](size_t n) { + return n < 150 ? wave(160.0 * n / 3000) : 0; + }, "zero-count right side does not refresh its watchdog"); +} + +void test_phase_continuity_and_partitioning() { + SwitchHdRumbleSynth whole; + SwitchHdRumbleSynth partitioned; + whole.reset(0); + partitioned.reset(0); + for (SwitchHdRumbleSynth* synth : {&whole, &partitioned}) { + synth->push(one_side(0), 0); + synth->push(one_side(0, state(96)), 5000); // Change frequency at sample 15. + synth->push(one_side(0, state(96)), 9000); // Identical state must not reset phase. + synth->push(one_side(0, state(96, 8192)), 12000); + } + const auto pcm = render(whole, 0, 80); + expect_wave(pcm, 0, [](size_t n) { + const double cycles = n < 15 ? n * 160.0 / 3000 + : (15 * 160.0 + (n - 15) * 320.0) / 3000; + return wave(cycles, n < 36 ? 32768 : 8192); + }, "frequency and amplitude transitions preserve accumulated phase"); + std::vector split(160); + for (unsigned n = 0; n < 80; ++n) { + partitioned.render(n, 1, split.data() + n * 2); + } + expect(split == pcm, "PCM is independent of render block partitioning"); +} + +void test_feedback_returns_to_live_host() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push(one_side(0), 0); + // Feedback may be delivered before an older USB frame drains on Core 1. + synth.feedback(5001, 4999, 0, 255); // Samples [16,30), not a whole PCM block. + synth.push(one_side(0, state(96, 16384)), 8000); // Sample 24, underneath overlay. + auto pcm = render(synth, 0, 64); + expect_wave(pcm, 0, [](size_t n) { + if (n >= 16 && n < 30) { + return feedback_wave(320.0 * n / 3000); + } + const double cycles = n < 24 ? n * 160.0 / 3000 + : (24 * 160.0 + (n - 24) * 320.0) / 3000; + return wave(cycles, n < 24 ? 32768 : 16384); + }, "partial feedback expiry returns to live host state and host phase"); + expect_wave(pcm, 1, [](size_t n) { + return n >= 16 && n < 30 ? feedback_wave(320.0 * n / 3000) : 0; + }, "feedback overrides both sides only for its actual duration"); + + synth.reset(0); + synth.push(one_side(0), 0); + synth.feedback(0, 100000, 128, 0); + synth.feedback(4000, 100000, 0, 0); + synth.feedback(8000, 100000, 0, 255); + synth.feedback(12000, 0, 255, 255); + pcm = render(synth, 0, 60); + expect_wave(pcm, 0, [](size_t n) { + if (n < 12) { + return feedback_wave(160.0 * n / 3000, static_cast((128u * 32768 + 127) / 255)); + } + const bool feedback = n >= 24 && n < 36; + return feedback ? feedback_wave(320.0 * n / 3000) : wave(160.0 * n / 3000); + }, "zero magnitudes and zero duration cancel override without cancelling host"); + + synth.reset(0); + synth.push(one_side(0), 0); + synth.feedback(0, 80000, 0, 255); + pcm = render(synth, 0, 270); + expect_wave(pcm, 0, [](size_t n) { + return n < 240 ? feedback_wave(320.0 * n / 3000) : 0; + }, "feedback expiry cannot resurrect an expired host effect"); +} + +void test_late_commands_and_clock_rollover() { + SwitchHdRumbleSynth synth; + synth.reset(10000); + SwitchHapticsFrame steps; + steps.actuators[0] = {3, {state(64, 32768), state(64, 16384), state(64, 8192)}}; + expect(synth.push(steps, 4000), "recent pre-epoch effect is accepted"); + auto pcm = render(synth, 0, 150); + expect_wave(pcm, 0, [](size_t n) { + return n < 132 ? wave(160.0 * n / 3000, 8192) : 0; + }, "pre-epoch effect starts at current substep and keeps original expiry"); + synth.reset(100000); + expect(!synth.push(steps, 50000) && synth.dropped_updates() == 1, + "already expired pre-epoch effect is rejected"); + expect_silent(render(synth, 0, 64), "expired pre-epoch effect never replays"); + + synth.reset(0); + render(synth, 0, 40); + expect(synth.push(steps, 0), "late but ordered frame is accepted"); + pcm = render(synth, 40, 130); + expect_wave(pcm, 0, [](size_t n) { + return n + 40 < 150 ? wave(160.0 * (n + 40) / 3000, 8192) : 0; + }, "late frame skips old substeps and does not restart watchdog"); + expect(!synth.push(one_side(0), UINT64_MAX), "out-of-order timestamp is rejected"); + + const uint64_t epoch = UINT64_MAX - 1000; + synth.reset(epoch); + synth.push(one_side(0), epoch); + synth.push(one_side(0, state(64, 16384)), epoch + 3000); + synth.feedback(epoch + 4000, 1000, 0, 255); + pcm = render(synth, 0, 30); + expect_wave(pcm, 0, [](size_t n) { + if (n >= 12 && n < 15) { + return feedback_wave(320.0 * n / 3000); + } + return wave(160.0 * n / 3000, n < 9 ? 32768 : 16384); + }, "64-bit microsecond clock rollover preserves order and duration"); + expect(synth.dropped_updates() == 0, "clock rollover is not an out-of-order update"); +} + +void test_stall_and_overflow() { + SwitchHdRumbleSynth skipped; + SwitchHdRumbleSynth rendered; + skipped.reset(0); + rendered.reset(0); + SwitchHapticsFrame steps; + steps.actuators[0] = {3, {state(32), state(96), state(127)}}; + for (SwitchHdRumbleSynth* synth : {&skipped, &rendered}) { + synth->push(steps, 0); + synth->push(one_side(0, state(64)), 12000); + synth->push(one_side(0, state(32)), 40000); + } + render(rendered, 0, 180); + expect(render(skipped, 180, 80) == render(rendered, 180, 80), + "forward gap analytically integrates every queued frequency transition"); + + constexpr uint64_t far = 3000000000ull; + expect_silent(render(skipped, far, 64), "giant stall skips stale sound without a PCM backlog"); + expect(skipped.push(one_side(0), (far + 64) * 1000 / 3), + "fresh effect after giant stall is accepted"); + const auto fresh = render(skipped, far + 64, 150); + const double fresh_amplitude = spectral_amplitude(fresh, 0, 160); + expect(fresh_amplitude > 124 && fresh_amplitude < 131, + "fresh 160 Hz effect resumes at full band amplitude after giant stall"); + expect_wave(fresh, 1, [](size_t) { return 0; }, + "resuming after stall does not activate the other actuator"); + expect_silent(render(skipped, far, 64), "already consumed PCM is not replayable"); + + SwitchHdRumbleSynth overflowing; + SwitchHdRumbleSynth reference; + overflowing.reset(0); + reference.reset(0); + for (unsigned n = 0; n < 40; ++n) { + const auto frame = one_side(n % 2, state(static_cast(32 + n % 4 * 16))); + overflowing.push(frame, n * 1000); + reference.push(frame, n * 1000); + render(reference, n * 3, 3); + } + expect(overflowing.dropped_updates() > 0, "bounded command ring reports discarded history"); + expect_silent(render(overflowing, 0, 30), "overflow watermark silences discarded past"); + expect(render(overflowing, 120, 120) == render(reference, 120, 120), + "overflow preserves complete per-side baseline and accumulated phase"); +} + +void test_stateful_rumble_hold_channels_and_stop() { + for (unsigned side = 0; side < 2; ++side) { + SwitchHdRumbleSynth synth; + synth.reset(0); + expect(synth.push_rumble(side == 0 ? 128 : 0, side == 1 ? 128 : 0, 0), + "single-motor persistent command is accepted"); + const auto pcm = render(synth, 0, 1200); + expect_wave(pcm, side, [side](size_t n) { + constexpr uint16_t amplitude = (128u * 32768 + 127) / 255; + return wave((side == 0 ? 160.0 : 320.0) * n / 3000, amplitude); + }, "stateful motor keeps its isolated frequency and host gain beyond 50 ms"); + expect_wave(pcm, 1 - side, [](size_t) { return 0; }, + "zero-scaled opposite motor and both unused bands stay silent"); + } + + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push_rumble(255, 255, 0); + synth.push_rumble(0, 0, 150001); // Ceil to sample 451. + const auto pcm = render(synth, 0, 600); + for (unsigned side = 0; side < 2; ++side) { + expect_wave(pcm, side, [side](size_t n) { + return n < 451 ? wave((side == 0 ? 160.0 : 320.0) * n / 3000) : 0; + }, "explicit zero stops both held motors at its timestamp without gain lift"); + } + expect_silent(render(synth, 3000, 150), "stopped persistent state cannot reappear after a gap"); +} + +void test_stateful_rumble_pre_epoch_and_late() { + SwitchHdRumbleSynth synth; + synth.reset(1000000); + expect(synth.push_rumble(128, 255, 1000), + "persistent command predating the epoch by more than 50 ms is accepted"); + auto pcm = render(synth, 0, 300); + expect_wave(pcm, 0, [](size_t n) { + return wave(160.0 * n / 3000, (128u * 32768 + 127) / 255); + }, "pre-epoch held low motor starts at stream phase zero without expiry"); + expect_wave(pcm, 1, [](size_t n) { return wave(320.0 * n / 3000); }, + "pre-epoch held high motor retains its independent frequency"); + + expect(synth.push_rumble(255, 128, 2000), "late ordered persistent command is accepted"); + pcm = render(synth, 300, 300); + expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * (n + 300) / 3000); }, + "late held command applies at the cursor without resetting host phase"); + expect_wave(pcm, 1, [](size_t n) { + return wave(320.0 * (n + 300) / 3000, (128u * 32768 + 127) / 255); + }, "late held command changes amplitude without replaying elapsed history"); + expect_silent(render(synth, 0, 150), "late persistent updates do not make consumed PCM replayable"); + + const uint64_t epoch = UINT64_MAX - 1000; + synth.reset(epoch); + synth.push_rumble(255, 0, epoch - 100000); + synth.push_rumble(0, 255, epoch + 3000); + pcm = render(synth, 0, 300); + expect_wave(pcm, 0, [](size_t n) { return n < 9 ? wave(160.0 * n / 3000) : 0; }, + "held low motor stops chronologically across clock rollover"); + expect_wave(pcm, 1, [](size_t n) { return n < 9 ? 0 : wave(320.0 * n / 3000); }, + "held high motor starts across clock rollover without phase reset"); +} + +void test_stateful_rumble_hd_order_and_watchdogs() { + SwitchHdRumbleSynth synth; + synth.reset(0); + auto initial = one_side(0, state(32)); + initial.actuators[1] = one_side(1, state(64, 0, 32, 32768)).actuators[1]; + synth.push(initial, 0); + synth.push_rumble(255, 255, 5000); // Sample 15. + expect(!synth.push(one_side(0, state(64, 0)), 4999), + "HD cannot overtake a newer stateful host command"); + synth.push(one_side(0, state(96)), 20000); // Sample 60; left expires at 210. + expect(!synth.push_rumble(0, 0, 19999), + "stateful command cannot overtake a newer HD command"); + synth.push(SwitchHapticsFrame{}, 80000); + expect(synth.dropped_updates() == 2, "both host APIs share chronological rejection accounting"); + auto pcm = render(synth, 0, 300); + expect_wave(pcm, 0, [](size_t n) { + const double cycles = n < 15 ? n * 80.0 / 3000 : + n < 60 ? (15 * 80.0 + (n - 15) * 160.0) / 3000 : + (15 * 80.0 + 45 * 160.0 + (n - 60) * 320.0) / 3000; + return n < 210 ? wave(cycles) : 0; + }, "HD-stateful-HD transitions preserve phase and restore the updated side watchdog"); + expect_wave(pcm, 1, [](size_t n) { + const double cycles = n < 15 ? n * 160.0 / 3000 : + (15 * 160.0 + (n - 15) * 320.0) / 3000; + return wave(cycles); + }, "zero-count HD sides leave the other motor persistent with continuous phase"); + + synth.reset(0); + synth.push_rumble(255, 255, 0); + synth.push(one_side(0, state(64, 0)), 0); + synth.push(one_side(1, state(64, 0, 64, 0)), 1000); + synth.push_rumble(64, 128, 1000); + pcm = render(synth, 0, 300); + expect_wave(pcm, 0, [](size_t n) { + return n < 3 ? 0 : wave(160.0 * n / 3000, (64u * 32768 + 127) / 255); + }, "same-timestamp HD supersedes persistent state in call order"); + expect_wave(pcm, 1, [](size_t n) { + return wave(320.0 * n / 3000, n < 3 ? 32768 : (128u * 32768 + 127) / 255); + }, "same-timestamp persistent command supersedes HD and disables its watchdog"); +} + +void test_stateful_rumble_feedback_resume() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push_rumble(255, 0, 0); + synth.feedback(5001, 74999, 0, 255); // Samples [16,240). + synth.push_rumble(128, 255, 20000); // Update underneath the overlay. + auto pcm = render(synth, 0, 300); + expect_wave(pcm, 0, [](size_t n) { + if (n >= 16 && n < 240) return feedback_wave(320.0 * n / 3000); + return wave(160.0 * n / 3000, n < 16 ? 32768 : (128u * 32768 + 127) / 255); + }, "feedback expiry reveals the current persistent low motor and original host phase"); + expect_wave(pcm, 1, [](size_t n) { + if (n < 16) return 0.0; + return n < 240 ? feedback_wave(320.0 * n / 3000) : wave(320.0 * n / 3000); + }, "feedback remains a both-side overlay and resumes the new persistent high motor"); + + synth.reset(0); + synth.push_rumble(255, 255, 0); + synth.feedback(10000, 70000, 255, 0); + synth.push_rumble(0, 0, 40000); + pcm = render(synth, 0, 300); + for (unsigned side = 0; side < 2; ++side) { + expect_wave(pcm, side, [side](size_t n) { + if (n < 30) return wave((side == 0 ? 160.0 : 320.0) * n / 3000); + return n < 240 ? feedback_wave(160.0 * n / 3000) : 0; + }, "host zero cannot cancel priority feedback or resurrect held state after its expiry"); + } +} + +void test_stateful_rumble_overflow_and_reset() { + SwitchHdRumbleSynth overflowing; + SwitchHdRumbleSynth reference; + overflowing.reset(0); + reference.reset(0); + overflowing.push_rumble(128, 255, 0); + reference.push_rumble(128, 255, 0); + render(reference, 0, 3); + for (unsigned n = 1; n <= 40; ++n) { + const auto frame = one_side(0, state(static_cast(32 + n % 4 * 16))); + overflowing.push(frame, n * 1000); + reference.push(frame, n * 1000); + render(reference, n * 3, 3); + } + expect(overflowing.dropped_updates() > 0, "mixed host command overflow reports discarded history"); + expect_silent(render(overflowing, 0, 30), "mixed command overflow preserves the discard watermark"); + const auto pcm = render(overflowing, 123, 180); + expect(pcm == render(reference, 123, 180), + "evicted stateful command preserves per-side expiry mode and phase through HD updates"); + expect_wave(pcm, 1, [](size_t n) { return wave(320.0 * (n + 123) / 3000); }, + "overflow cannot discard the untouched persistent motor state"); + + overflowing.push_rumble(255, 255, 200000); + overflowing.feedback(200000, 1000000, 255, 255); + overflowing.reset(1000000); + expect_silent(render(overflowing, 0, 600), + "reset clears live persistence and queued host and feedback commands"); + overflowing.reset(1000000); + expect(overflowing.push_rumble(0, 0, 0) && overflowing.dropped_updates() == 0, + "reset clears shared ordering and accepts an old profile-zero state"); + expect_silent(render(overflowing, 0, 300), "profile-zero state is silent even with persistent gain"); + overflowing.reset(1000000); + overflowing.push_rumble(255, 255, 0); + const auto restarted = render(overflowing, 0, 300); + for (unsigned side = 0; side < 2; ++side) { + expect_wave(restarted, side, [side](size_t n) { + return wave((side == 0 ? 160.0 : 320.0) * n / 3000); + }, "reset restores zero source phases for both persistent motors"); + } +} + +void test_duplicate_order_and_invalid_frames() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push(one_side(0), 1000); + synth.push(one_side(0, state(64, 8192)), 1000); + expect(!synth.push(one_side(0, state(64, 0)), 999), + "older timestamp cannot override newest accepted state"); + auto invalid = one_side(0); + invalid.actuators[0].sample_count = 4; + expect(!synth.push(invalid, 2000), "too many substeps rejects whole batch"); + invalid = one_side(0, state(128)); + expect(!synth.push(invalid, 2000), "out-of-range frequency rejects whole batch"); + invalid = one_side(0, state(64, 32769)); + expect(!synth.push(invalid, 2000), "out-of-range linear amplitude rejects whole batch"); + expect(synth.dropped_updates() == 4, "rejected batches are counted"); + const auto pcm = render(synth, 0, 30); + expect_wave(pcm, 0, [](size_t n) { + return n < 3 ? 0 : wave(160.0 * n / 3000, 8192); + }, "duplicate timestamp last-wins without phase reset or malformed-state mutation"); +} + +} // namespace + +int main() { + test_physical_frequency_and_channels(); + test_linear_mix_headroom(); + test_substeps_and_preemption(); + test_multiple_usb_updates_and_watchdogs(); + test_phase_continuity_and_partitioning(); + test_feedback_returns_to_live_host(); + test_late_commands_and_clock_rollover(); + test_stall_and_overflow(); + test_duplicate_order_and_invalid_frames(); + test_stateful_rumble_hold_channels_and_stop(); + test_stateful_rumble_pre_epoch_and_late(); + test_stateful_rumble_hd_order_and_watchdogs(); + test_stateful_rumble_feedback_resume(); + test_stateful_rumble_overflow_and_reset(); + if (failures) { + std::cerr << failures << " synthesis scenarios failed\n"; + return 1; + } + std::cout << "Switch HD rumble synthesis scenarios passed\n"; + return 0; +} diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index ecee2d1..cfd7ef1 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -714,6 +714,102 @@ void test_uart_parser_is_pure() { "failed UART parse modified its output reference"); } +void test_motion_backpressure_retries_without_advancing_state() { + for (bool rejected_transfer : {false, true}) { + initialize_contexts(); + send_feature(0, TOGGLE_IMU, 2); + send_feature(1, TOGGLE_IMU, 2); + now_ms = 6; + switch_pro_task(0); + switch_pro_task(1); + ControllerState moving{}; + moving.motion_sample_count = 1; + moving.motion_samples[0] = {100, 200, 300, 20000, 0, 0}; + switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + switch_pro_set_input(1, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + const auto before = get_current_report(0, "missing pre-send report"); + hid_ready[0] = rejected_transfer; + hid_report_succeeds[0] = !rejected_transfer; + now_ms = 21; + expect(!switch_pro_task(0), "blocked motion transfer was counted as sent"); + const auto blocked = get_current_report(0, "missing blocked report"); + expect(blocked.timestamp == before.timestamp && + std::memcmp(blocked.imuData, before.imuData, sizeof(blocked.imuData)) == 0, + "blocked motion advanced timestamp or quaternion payload"); + hid_ready[0] = true; + hid_report_succeeds[0] = true; + now_ms = 22; + expect(switch_pro_task(0), "overdue motion waited another 15 ms after USB became ready"); + expect(switch_pro_task(1), "reference motion did not send"); + const auto recovered = copy_switch_report(latest_regular_report(0)); + const auto reference = copy_switch_report(latest_regular_report(1)); + expect(recovered.timestamp == reference.timestamp && + std::memcmp(recovered.imuData, reference.imuData, sizeof(reference.imuData)) == 0, + "retry integrated an unsent quaternion sample twice"); + now_ms = 23; + expect(!switch_pro_task(0), "retry recovery emitted motion faster than its 15 ms cadence"); + } +} + +void test_control_replies_do_not_postpone_motion() { + initialize_contexts(); + send_feature(0, TOGGLE_IMU, 1); + now_ms = 6; + switch_pro_task(0); + ControllerState moving{}; + moving.motion_sample_count = 1; + moving.motion_samples[0].gyro_z = 1000; + switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + now_ms = 21; + expect(switch_pro_task(0), "initial motion report missing"); + send_feature(0, GET_CONTROLLER_STATE, 0); + now_ms = 35; + expect(!switch_pro_task(0), "control response counted as motion"); + moving.motion_samples[0].gyro_z = 2000; + switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + now_ms = 36; + expect(switch_pro_task(0), "control response postponed the independent motion deadline"); + const auto current = copy_switch_report(latest_regular_report(0)); + expect(read_int16_le(current.imuData + 10) == 2000 && current.timestamp == 6, + "overdue motion did not use the freshest input"); + + initialize_contexts(); + now_ms = 15; + send_feature(0, GET_CONTROLLER_STATE, 0); + switch_pro_task(0); + now_ms = 16; + send_feature(0, GET_CONTROLLER_STATE, 0); + expect(switch_pro_task(0), "successive control replies starved overdue motion"); + now_ms = 22; + expect(!switch_pro_task(0), "pending control response counted as motion"); + expect(sent_reports[sent_report_count - 1].data[0] == REPORT_OUTPUT_21, + "motion fairness discarded the pending control reply"); +} + +void test_motion_cadence_survives_usb_poll_quantization() { + initialize_contexts(); + for (now_ms = 1; now_ms <= 120; ++now_ms) { + hid_ready[0] = now_ms % 8 == 0; + switch_pro_task(0); + } + expect(reports_for_instance(0) == 8, + "8 ms USB polling stretched the 15 ms motion clock"); + const auto report = copy_switch_report(latest_regular_report(0)); + expect(report.timestamp == 24, "motion clock did not represent 24 samples in 120 ms"); + hid_ready[0] = true; + now_ms = 121; + expect(!switch_pro_task(0), "motion sent an extra unscheduled sample group"); + now_ms = 300; + expect(switch_pro_task(0), "motion did not recover after a long USB stall"); + const auto recovered = copy_switch_report(latest_regular_report(0)); + expect(recovered.timestamp == 60, "motion timer did not skip missing periods"); + expect(!switch_pro_task(0), "motion replayed a stale catch-up burst"); +} + } // namespace extern "C" absolute_time_t get_absolute_time(void) { @@ -765,6 +861,9 @@ int main() { test_protocol_neutral_trigger_threshold(); test_custom_trigger_thresholds_are_isolated(); test_uart_parser_is_pure(); + test_motion_backpressure_retries_without_advancing_state(); + test_control_replies_do_not_postpone_motion(); + test_motion_cadence_survives_usb_poll_quantization(); if (failures != 0) { std::cerr << failures << " driver context test(s) failed\n"; return 1; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 0bfea37..6af8a0d 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -10,8 +10,21 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" - for adapter_feasibility in (False, True): - suffix = "_adapter" if adapter_feasibility else "" + for adapter_feasibility, native, short_packets in ( + (False, False, False), + (True, False, False), + (True, True, False), + (True, True, True), + ): + suffix = ( + "_native32" + if short_packets + else "_native64" + if native + else "_adapter" + if adapter_feasibility + else "" + ) executable = tmp_path / f"bluepad32_backend_lifecycle_test{suffix}" command = [ compiler, @@ -24,16 +37,58 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: ] if adapter_feasibility: command.append("-DSWITCH_PICO_USB_OUTPUT_MODES=1") + if native: + command.extend( + [ + "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", + "-DSWITCH_PICO_HD_RUMBLE=1", + "-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0", + str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), + str( + root + / "src" + / "firmware" + / "input" + / "switch_hd_rumble_synth.cpp" + ), + str( + root + / "src" + / "firmware" + / "usb" + / "switch" + / "switch_haptics.cpp" + ), + ] + ) + if short_packets: + command.extend( + [ + "-DSWITCH_PICO_CYW43_PACKET_READ=1", + "-DSWITCH_PICO_HCI_CREDIT_BATCH=1", + "-DSWITCH_PICO_SYS_CLOCK_MHZ=300", + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", + ] + ) command.extend( [ f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{root / 'src' / 'firmware'}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), + str( + root / "src" / "firmware" / "input" / "controller_macro_capture.cpp" + ), "-o", str(executable), ] ) subprocess.run(command, check=True, cwd=root) + if native: + subprocess.run([str(executable), "native-stateful"], check=True, cwd=root) + subprocess.run( + [str(executable), "native-second-slot"], check=True, cwd=root + ) + continue for scenario in ( "ready-forward", @@ -51,6 +106,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "clear-pairings", "configuration-timer", "flash-core-start", + "wake-identity-gate", + "system-wake", "flash-core-failure", ): subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_btstack_credit_batch_native.py b/tests/test_btstack_credit_batch_native.py new file mode 100644 index 0000000..c47df17 --- /dev/null +++ b/tests/test_btstack_credit_batch_native.py @@ -0,0 +1,74 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("batching", [False, True], ids=["default", "batched"]) +def test_btstack_credit_batch_native(tmp_path: Path, batching: bool) -> None: + root = Path(__file__).resolve().parents[1] + sdk = Path( + os.environ.get("PICO_SDK_PATH", root / "build" / "_deps" / "pico_sdk-src") + ) + btstack = sdk / "lib" / "btstack" + source = btstack / "src" + if not (source / "hci.c").is_file(): + pytest.skip("requires a populated Pico SDK; set PICO_SDK_PATH") + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + patch = shutil.which("patch") + assert patch is not None, "the patch utility is required" + + # Patch only a temporary copy: neither SDK originals nor build/_deps are modified. + patched_source = tmp_path / "lib" / "btstack" / "src" + patched_source.mkdir(parents=True) + shutil.copyfile(source / "hci.c", patched_source / "hci.c") + subprocess.run( + [ + patch, + "--batch", + "--forward", + "-p1", + "-i", + str(root / "patches" / "btstack-credit-batch.patch"), + ], + check=True, + cwd=tmp_path, + ) + executable = tmp_path / "btstack_credit_batch_test" + units = [ + "ad_parser.c", + "btstack_linked_list.c", + "btstack_memory.c", + "btstack_memory_pool.c", + "btstack_run_loop.c", + "btstack_util.c", + "hci_cmd.c", + "hci_dump.c", + ] + subprocess.run( + [ + compiler, + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-ffunction-sections", + "-fdata-sections", + *(["-DSWITCH_PICO_HCI_CREDIT_BATCH=1"] if batching else []), + f"-I{root / 'tests' / 'btstack_credit_batch_native_stubs'}", + f"-I{patched_source}", + f"-I{source}", + f"-I{btstack / 'platform' / 'embedded'}", + str(root / "tests" / "btstack_credit_batch_test.c"), + *(str(source / unit) for unit in units), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 72588bd..2508f5e 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -import switch_pico_bridge.config_manager as config_manager +from switch_pico_bridge import config_manager def make_response( @@ -19,18 +19,21 @@ def make_response( schema: int = 0, generation: int = 0, ) -> bytes: - return struct.pack( - "<4sBBBBHHII", - b"SPMG", - config_manager.PROTOCOL_VERSION, - operation, - status, - flags, - len(payload), - schema, - generation, - zlib.crc32(payload) & 0xFFFFFFFF, - ) + payload + return ( + struct.pack( + "<4sBBBBHHII", + b"SPMG", + config_manager.PROTOCOL_VERSION, + operation, + status, + flags, + len(payload), + schema, + generation, + zlib.crc32(payload) & 0xFFFFFFFF, + ) + + payload + ) class FakeDevice: @@ -95,6 +98,15 @@ class FakeDevice: for identity in self.profile_identities for index in range(config_manager.PROFILE_CAPACITY) } + self.profile_aliases = { + identity.to_bytes(): "" for identity in self.profile_identities + } + self.profile_names = { + (identity.to_bytes(), index): "" + for identity in self.profile_identities + for index in range(config_manager.PROFILE_CAPACITY) + } + self.identified_identities: list[bytes] = [] self.selected_profile = (self.global_identity.to_bytes(), 0) self.profile_generation = 7 self.profile_transaction_id = 0 @@ -111,7 +123,17 @@ class FakeDevice: self.profile_chunk_sizes: list[int] = [] self.pending_profile_mutation: tuple[int, bytes, int] | None = None self.profile_transaction_pending_reads = 0 + self.playtest_battery = 251 + self.playtest_capabilities = 0x0F self.profile_status_responses: list[tuple[int, int]] = [] + self.playtest_connected = True + self.playtest_slot = 1 + self.playtest_connection_generation = 17 + self.playtest_state_generation = 93 + self.playtest_button_mask = 0x9001 + self.playtest_sticks = (-1234, 2345, -30000, 30000) + self.playtest_triggers = (123, 65000) + self.playtest_motion = (1, -2, 3, -4, 5, -6) def _pairing_payload(self) -> bytes: payload = bytearray([len(self.records), 0, 0, 0]) @@ -135,8 +157,33 @@ class FakeDevice: 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)) + identity_bytes = identity.to_bytes() + alias = self.profile_aliases.get(identity_bytes, "").encode("utf-8") + payload.extend(identity_bytes) + payload.extend((self.active_profiles[identity_bytes], 0, len(alias))) + payload.extend(alias) + payload.extend( + bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(alias)) + ) + return bytes(payload) + + def _profile_metadata_payload(self) -> bytes: + identity, _ = self.selected_profile + values = [ + self.profile_aliases.get(identity, ""), + *( + self.profile_names.get((identity, index), "") + for index in range(config_manager.PROFILE_CAPACITY) + ), + ] + payload = bytearray() + for value in values: + encoded = value.encode("utf-8") + payload.extend((len(encoded),)) + payload.extend(encoded) + payload.extend( + bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(encoded)) + ) return bytes(payload) def _profile_transaction_payload(self) -> bytes: @@ -157,6 +204,33 @@ class FakeDevice: zlib.crc32(stored) & 0xFFFFFFFF, ) + def _profile_playtest_payload(self) -> tuple[bytes, int]: + payload = bytearray(config_manager.PROFILE_PLAYTEST_SIZE) + payload[1] = 0xFF + if not self.playtest_connected: + return bytes(payload), 0 + flags = 0x03 if self.playtest_motion is not None else 0x01 + payload[0] = flags + payload[1] = self.playtest_slot + struct.pack_into( + " None: assert len(payload) == 19 self.profile_transaction_id = struct.unpack_from(" config_manager.ControllerProfile: return config_manager.ControllerProfile( button_map=( - 1, + config_manager.LOGICAL_CONTROLS.index("left_trigger"), 0, 2, 3, @@ -500,39 +584,88 @@ def custom_profile() -> config_manager.ControllerProfile: 15, config_manager.PROFILE_NONE_BUTTON, ), - left_stick=config_manager.StickConfig( - -123, 456, 1000, 30000, 384, True, False + 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, + config_manager.LOGICAL_CONTROLS.index("right_trigger"), ), - right_stick=config_manager.StickConfig( - 789, -321, 500, 31000, 192, False, True + right_trigger=config_manager.TriggerConfig( + 200, + 64000, + 224, + 33000, + config_manager.LOGICAL_BUTTONS.index("north"), ), - 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, + switching_chord=(1 << 6) | (1 << 16), + motion_toggle_chord=(1 << 5) | (1 << 17), + macros=( + config_manager.ControllerMacro( + (1 << 0) | (1 << 16) | (1 << 17), + config_manager.LOGICAL_CONTROLS.index("right_trigger"), + ( + config_manager.MacroStep( + 0, + config_manager.MACRO_OVERRIDE_MASK, + config_manager.PROFILE_MAXIMUM_WAIT_MS, + (1 << 0) | (1 << 12), + -32768, + 32767, + -1000, + 1000, + 12345, + 54321, + ), + ), ), - config_manager.MacroStep.end(), + config_manager.ControllerMacro( + (1 << 1) | (1 << 2), + config_manager.PROFILE_NONE_BUTTON, + (config_manager.MacroStep(0, 1, 25, 1 << 3, 0, 0, 0, 0, 0, 0),), + ), + config_manager.ControllerMacro.empty(), + config_manager.ControllerMacro.empty(), ), turbo_modes=(0, 1, 2) + (0,) * 13, ) +def legacy_profile_wire( + schema_version: int, + macro_trigger: int = config_manager.PROFILE_NONE_BUTTON, + macro_cancel: int = config_manager.PROFILE_NONE_BUTTON, +) -> bytearray: + profile = config_manager.ControllerProfile.default() + payload = bytearray(config_manager.PROFILE_LEGACY_SIZE) + struct.pack_into( + "= config_manager.PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + payload[81] = macro_cancel + payload[79] = 0 + for index in range(config_manager.PROFILE_LEGACY_MACRO_STEP_CAPACITY): + payload[100 + index * config_manager.PROFILE_MACRO_STEP_SIZE] = 1 + return payload + + def test_response_validation() -> None: payload = make_response(config_manager.OP_INFO, b"12345678") envelope = config_manager.parse_response(payload, config_manager.OP_INFO) @@ -578,10 +711,7 @@ def test_configuration_transaction_and_reset() -> None: assert reset.stored_generation == 5 reset_configuration = config_manager.read_configuration(device) assert reset_configuration.pairing_window_seconds == 60 - assert ( - reset_configuration.requested_mode - == config_manager.REQUESTED_MODE_AUTO - ) + assert reset_configuration.requested_mode == config_manager.REQUESTED_MODE_AUTO def test_configuration_transaction_ids_stay_in_host_range( @@ -609,9 +739,7 @@ def test_configuration_transaction_ids_stay_in_host_range( 1.0, ) config_manager.reset_configuration(device, 1.0) - config_manager.set_mode( - device, config_manager.REQUESTED_MODE_SWITCH, 1.0 - ) + config_manager.set_mode(device, config_manager.REQUESTED_MODE_SWITCH, 1.0) transaction_ids = [ struct.unpack_from(" None: device = FakeDevice() monkeypatch.setattr(config_manager.time, "sleep", lambda _seconds: None) - monkeypatch.setattr( - config_manager.secrets, "randbits", lambda _bits: 0x12345678 - ) + monkeypatch.setattr(config_manager.secrets, "randbits", lambda _bits: 0x12345678) - status = config_manager.set_mode( - device, config_manager.REQUESTED_MODE_XINPUT, 1.0 - ) + status = config_manager.set_mode(device, config_manager.REQUESTED_MODE_XINPUT, 1.0) - mode_payload = struct.pack( - " None: device = FakeDevice() - monkeypatch.setattr( - config_manager, "_candidate_devices", lambda: (device,) - ) - with pytest.raises( - config_manager.ConfigManagerError, match="did not disappear" - ): + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) + with pytest.raises(config_manager.ConfigManagerError, match="did not disappear"): snapshot = config_manager._capture_reenumeration_snapshot(device) config_manager._wait_for_reenumeration(snapshot, 0) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: ()) - with pytest.raises( - config_manager.ConfigManagerError, match="did not re-enumerate" - ): + with pytest.raises(config_manager.ConfigManagerError, match="did not re-enumerate"): snapshot = config_manager._capture_reenumeration_snapshot(device) config_manager._wait_for_reenumeration(snapshot, 0) @@ -1050,8 +1150,7 @@ def test_requested_and_active_mode_response_validation() -> None: ): config_manager.read_configuration(device) device.configuration = ( - struct.pack(" None: ): config_manager.read_info(device) device.capabilities = config_manager.CAPABILITY_RUMBLE - with pytest.raises( - config_manager.ConfigManagerError, match="omit required input" - ): + with pytest.raises(config_manager.ConfigManagerError, match="omit required input"): config_manager.read_info(device) @@ -1086,13 +1183,10 @@ def test_identity_and_profile_binary_json_round_trip() -> None: 0xABCD, ) encoded_identity = identity.to_bytes() - assert encoded_identity == bytes.fromhex( - "01020300A1B2C3D4E5F63412CDAB" - ) + 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) + assert config_manager.ControllerIdentity.global_fallback().to_bytes() == bytes( + config_manager.CONTROLLER_IDENTITY_SIZE ) malformed_identity = bytearray(encoded_identity) malformed_identity[3] = 1 @@ -1113,24 +1207,26 @@ def test_identity_and_profile_binary_json_round_trip() -> None: config_manager.PROFILE_SCHEMA_VERSION, config_manager.PROFILE_SIZE, ) - assert encoded[75] == encoded[81] == 0 - assert encoded[98:100] == b"\x00\x00" - assert encoded[252:] == bytes(4) + assert encoded[75] == 0x21 + assert encoded[60:62] == bytes((17, 0)) + assert encoded[70:72] == bytes((3, 0)) + assert struct.unpack_from(" None: 256, config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD, ) - migrated_default = config_manager.ControllerProfile.from_bytes( - legacy_default_wire - ) + migrated_default = config_manager.ControllerProfile.from_bytes(legacy_default_wire) assert ( migrated_default.left_trigger.digital_threshold == config_manager.PROFILE_DEFAULT_DIGITAL_THRESHOLD @@ -1180,22 +1274,33 @@ def test_identity_and_profile_binary_json_round_trip() -> None: == config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD ) - legacy_custom_wire = bytearray(encoded) - struct.pack_into( - " None: assert migrated_json.right_trigger.digital_threshold == 33000 assert migrated_json.left_trigger.lower_deadzone == 30000 assert migrated_json.left_trigger.upper_saturation == 40000 + assert migrated_json.left_trigger.output == 16 + assert migrated_json.right_trigger.output == 17 + assert migrated_json.macros[0].trigger_mask == 1 + assert migrated_json.motion_toggle_chord == 0 + + +def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None: + obj = config_manager.ControllerProfile.default().to_json_object() + obj["macros"][0]["trigger"] = ["south"] + obj["macros"][0]["steps"] = [ + custom_profile().macros[0].steps[0].to_json_object() + ] * 8 + expected = config_manager.ControllerProfile.from_json_object(obj) + legacy_wire = bytearray(expected.to_bytes()[:256]) + struct.pack_into(" None: + obj = custom_profile().to_json_object() + obj["shortcuts"] = { + "modifier": "left_trigger", + "profiles": ["south", None, None, None, None, None, None, "dpad_right"], + } + obj["shift"]["mode"] = "toggle" + obj["shift"]["modifier"] = "right_trigger" + obj["shift"]["button_map"]["south"] = None + obj["turbo"]["dpad_right"] = "burst" + obj["turbo_settings"] = { + "defaults": {"rate_hz": 30, "duty_percent": 99, "burst_count": 255}, + "overrides": { + "dpad_right": {"rate_hz": 23, "duty_percent": 37, "burst_count": 17}, + "west": {"rate_hz": 1, "duty_percent": 1, "burst_count": 1}, + }, + } + for macro, playback in zip( + obj["macros"], ("repeat", "once", "while_held", "toggle") + ): + macro["playback"] = playback + obj["macros"][0]["repeat_count"] = 255 + profile = config_manager.ControllerProfile.from_json_object(obj) + encoded = profile.to_bytes() + assert encoded[256:267] == bytes((16, 0, 255, 255, 255, 255, 255, 255, 15, 2, 17)) + assert encoded[283:294] == bytes((30, 99, 255, 4, 128, 1, 1, 1, 23, 37, 17)) + assert encoded[294:336] == bytes(42) + assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1)) + assert encoded[344:] == bytes(40) + assert config_manager.ControllerProfile.from_bytes(encoded) == profile + assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("shortcuts", "modifier"), None), + (("shortcuts", "modifier"), "south"), + (("shortcuts", "profiles", 1), "south"), + (("shortcuts", "profiles", 0), "left_shoulder"), + (("shift", "modifier"), None), + (("shift", "button_map", "south"), "left_trigger"), + (("turbo_settings", "defaults", "rate_hz"), 0), + (("turbo_settings", "defaults", "rate_hz"), 31), + (("turbo_settings", "defaults", "duty_percent"), 0), + (("turbo_settings", "defaults", "duty_percent"), 100), + (("turbo_settings", "defaults", "burst_count"), 0), + (("turbo_settings", "defaults", "burst_count"), 256), + (("macros", 0, "repeat_count"), 0), + (("macros", 0, "repeat_count"), 256), + (("macros", 0, "playback"), "forever"), + ], +) +def test_set_b_rejects_invalid_json_settings(path: tuple, value: object) -> None: + obj = config_manager.ControllerProfile.default().to_json_object() + obj["shortcuts"]["modifier"] = "left_trigger" + obj["shortcuts"]["profiles"][0] = "south" + obj["shift"]["mode"] = "hold" + obj["shift"]["modifier"] = "right_trigger" + parent = obj + for key in path[:-1]: + parent = parent[key] + parent[path[-1]] = value + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_json_object(obj) + + +@pytest.mark.parametrize( + ("offset", "value"), + [ + (288, 1), + (383, 1), + (265, 3), + (267, 16), + (283, 31), + (284, 100), + (285, 0), + (336, 4), + (337, 0), + ], +) +def test_set_b_rejects_corrupt_extension_bytes(offset: int, value: int) -> None: + payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + payload[offset] = value + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_bytes(payload) + + +def test_looping_macro_requires_positive_total_duration() -> None: + obj = custom_profile().to_json_object() + obj["macros"][0]["steps"][0]["duration_ms"] = 0 + once = config_manager.ControllerProfile.from_json_object(obj) + assert config_manager.ControllerProfile.from_bytes(once.to_bytes()) == once + for playback in ("while_held", "toggle", "repeat"): + obj["macros"][0]["playback"] = playback + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_json_object(obj) + obj["macros"][0]["trigger"] = [] + disabled = config_manager.ControllerProfile.from_json_object(obj) + assert config_manager.ControllerProfile.from_bytes(disabled.to_bytes()) == disabled + + +def test_sparse_macro_capacity_boundaries() -> None: + full_step = custom_profile().macros[0].steps[0].to_json_object() + button_step = custom_profile().macros[1].steps[0].to_json_object() + + exact = config_manager.ControllerProfile.default().to_json_object() + exact["macros"][0]["trigger"] = ["south"] + exact["macros"][0]["steps"] = [full_step] * 8 + exact_profile = config_manager.ControllerProfile.from_json_object(exact) + assert ( + sum( + len(step.to_sparse_bytes()) + for macro in exact_profile.macros + for step in macro.steps + ) + == config_manager.PROFILE_MACRO_STREAM_SIZE + ) + assert ( + config_manager.ControllerProfile.from_bytes(exact_profile.to_bytes()) + == exact_profile + ) + + sixteen = config_manager.ControllerProfile.default().to_json_object() + for index, macro in enumerate(sixteen["macros"]): + macro["trigger"] = [config_manager.LOGICAL_CONTROLS[index]] + macro["steps"] = [button_step] * 4 + sixteen_profile = config_manager.ControllerProfile.from_json_object(sixteen) + assert sum(len(macro.steps) for macro in sixteen_profile.macros) == 16 + assert len(sixteen_profile.to_bytes()) == config_manager.PROFILE_SIZE + + overflow = config_manager.ControllerProfile.default().to_json_object() + overflow["macros"][0]["trigger"] = ["south"] + overflow["macros"][0]["steps"] = [full_step] * 8 + overflow["macros"][1]["trigger"] = ["east"] + overflow["macros"][1]["steps"] = [full_step] + with pytest.raises( + config_manager.ConfigManagerError, + match="136-byte sparse stream", + ): + config_manager.ControllerProfile.from_json_object(overflow) def test_trigger_threshold_uses_transformed_output_domain() -> None: for threshold in (0, 0xFFFF): - trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold) - assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger + trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold, 16) + assert ( + config_manager.TriggerConfig.from_bytes( + trigger.to_bytes(), + schema_version=config_manager.PROFILE_SCHEMA_VERSION, + source_index=0, + ) + == trigger + ) assert ( config_manager.TriggerConfig.from_json_object( - trigger.to_json_object(), "trigger" + trigger.to_json_object(), + "trigger", + schema_version=config_manager.PROFILE_SCHEMA_VERSION, + source_index=0, ) == trigger ) - current_wire = bytearray( - config_manager.ControllerProfile.default().to_bytes() - ) - struct.pack_into( - " None: config_manager.ConfigManagerError, match="trigger digital_threshold", ): - config_manager.TriggerConfig(30000, 40000, 256, threshold) + config_manager.TriggerConfig(30000, 40000, 256, threshold, 16) for lower_deadzone, upper_saturation in ( (40000, 40000), @@ -1252,18 +1549,12 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None: config_manager.ConfigManagerError, match="lower_deadzone must be below upper_saturation", ): - config_manager.TriggerConfig( - lower_deadzone, upper_saturation, 256, 0 - ) + config_manager.TriggerConfig(lower_deadzone, upper_saturation, 256, 0, 16) def test_default_timeout_covers_batched_profile_commit() -> None: args = config_manager.build_parser().parse_args(["profiles", "list"]) - assert ( - args.timeout - == config_manager.DEFAULT_OPERATION_TIMEOUT_SECONDS - == 15.0 - ) + assert args.timeout == config_manager.DEFAULT_OPERATION_TIMEOUT_SECONDS == 15.0 def test_profile_list_select_read_and_chunked_commit() -> None: @@ -1288,12 +1579,102 @@ def test_profile_list_select_read_and_chunked_commit() -> None: ) 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 max(device.profile_chunk_sizes) <= config_manager.MAXIMUM_CHUNK_SIZE + assert sum(device.profile_chunk_sizes) == config_manager.PROFILE_SIZE assert config_manager.OP_PROFILE_TRANSACTION_STATUS in device.requests - assert ( - config_manager.read_profile(device, device.stable_identity, 2) - == profile + assert config_manager.read_profile(device, device.stable_identity, 2) == profile + + +def test_profile_metadata_and_identify_round_trip() -> None: + device = FakeDevice() + identity = device.stable_identity + alias_status = config_manager.set_profile_metadata( + device, + identity, + config_manager.PROFILE_NONE_BUTTON, + "Desk pad", + 1.0, ) + name_status = config_manager.set_profile_metadata( + device, + identity, + 7, + "Desktop", + 1.0, + ) + metadata = config_manager.read_profile_metadata(device, identity, 7) + assert alias_status.status == config_manager.STATUS_OK + assert name_status.stored_generation == alias_status.stored_generation + 1 + assert metadata.alias == "Desk pad" + assert metadata.profile_names[7] == "Desktop" + assert config_manager.list_profiles(device)[1].alias == "Desk pad" + + config_manager.identify_controller(device, identity) + assert device.identified_identities == [identity.to_bytes()] + with pytest.raises( + config_manager.ConfigManagerError, + match="no controller to identify", + ): + config_manager.identify_controller(device, device.global_identity) + + +def test_profile_playtest_decodes_raw_controller_state() -> None: + device = FakeDevice() + playtest = config_manager.read_profile_playtest(device) + assert playtest == config_manager.ProfilePlaytest( + connected=True, + slot_index=1, + connection_generation=17, + state_generation=93, + identity=device.stable_identity, + button_mask=0x9001, + left_stick=(-1234, 2345), + right_stick=(-30000, 30000), + triggers=(123, 65000), + battery=251, + capabilities=0x0F, + motion=(1, -2, 3, -4, 5, -6), + ) + assert playtest.to_json_object()["buttons"] == [ + "south", + "dpad_up", + "dpad_right", + ] + + device.playtest_connected = False + disconnected = config_manager.read_profile_playtest(device) + assert disconnected == config_manager.ProfilePlaytest( + connected=False, + slot_index=None, + connection_generation=0, + state_generation=0, + identity=None, + button_mask=0, + left_stick=(0, 0), + right_stick=(0, 0), + triggers=(0, 0), + battery=0, + capabilities=0, + motion=None, + ) + device.playtest_connected = True + payload, flags = device._profile_playtest_payload() + malformed = bytearray(payload) + malformed[38] = 0 + envelope = config_manager.parse_response( + make_response( + config_manager.OP_PROFILE_PLAYTEST, + malformed, + flags=flags, + schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION, + ), + config_manager.OP_PROFILE_PLAYTEST, + ) + with pytest.raises( + config_manager.ConfigManagerError, + match="invalid connected playtest payload", + ): + config_manager.parse_profile_playtest(envelope) def test_profile_reset_and_activate_wait_for_correlated_transactions( @@ -1310,7 +1691,6 @@ def test_profile_reset_and_activate_wait_for_correlated_transactions( 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 @@ -1349,26 +1729,66 @@ def test_profile_cli_surfaces_late_storage_failure( 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 - ) + 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.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_edit_cli_starts_local_web_editor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from switch_pico_bridge import profile_web + + calls: list[dict[str, object]] = [] + monkeypatch.setattr( + profile_web, + "run_profile_editor", + lambda **kwargs: calls.append(kwargs), + ) + monkeypatch.setattr( + config_manager, + "_candidate_devices", + lambda: (_ for _ in ()).throw( + AssertionError("editor startup must not require a connected Pico") + ), + ) + + assert ( + config_manager.main( + [ + "--bus", + "3", + "--address", + "7", + "--timeout", + "8", + "profiles", + "edit", + "--port", + "9000", + "--no-browser", + ] + ) + == 0 + ) + assert calls == [ + { + "bus": 3, + "address": 7, + "timeout": 8.0, + "port": 9000, + "open_browser": False, + } + ] + + def test_profile_cli_json_round_trip_activate_and_reset( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1427,22 +1847,12 @@ def test_profile_cli_json_round_trip_activate_and_reset( ) _ = capsys.readouterr() - assert ( - config_manager.main( - ["profiles", "activate", "4", "--identity", "1"] - ) - == 0 - ) - assert device.active_profiles[device.stable_identity.to_bytes()] == 3 + assert config_manager.main(["profiles", "activate", "8", "--identity", "1"]) == 0 + assert device.active_profiles[device.stable_identity.to_bytes()] == 7 _ = capsys.readouterr() before_reset_requests = len(device.requests) - assert ( - config_manager.main( - ["profiles", "reset", "2", "--identity", "1"] - ) - == 2 - ) + assert config_manager.main(["profiles", "reset", "2", "--identity", "1"]) == 2 assert "requires --yes" in capsys.readouterr().err assert len(device.requests) == before_reset_requests @@ -1485,10 +1895,8 @@ def test_malformed_profiles_are_rejected_before_usb( 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" - ): + malformed_binary[75] = 0xC0 + with pytest.raises(config_manager.ConfigManagerError, match="action flags"): config_manager.ControllerProfile.from_bytes(malformed_binary) profile_object = config_manager.ControllerProfile.default().to_json_object() @@ -1520,9 +1928,7 @@ def test_malformed_profiles_are_rejected_before_usb( 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 config_manager.main(["profiles", "import", "1", str(path)]) == 1 assert "error:" in capsys.readouterr().err assert usb_lookups == 0 @@ -1551,9 +1957,7 @@ def test_profile_crc_status_failures_and_identity_bounds( ) bounded_device = FakeDevice() - monkeypatch.setattr( - config_manager, "_candidate_devices", lambda: [bounded_device] - ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [bounded_device]) assert ( config_manager.main( [ @@ -1617,16 +2021,9 @@ def test_config_cli_preserves_requested_mode( " None: device = FakeDevice() - monkeypatch.setattr( - config_manager, "_candidate_devices", lambda: (device,) - ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) assert config_manager.main(["reboot", "bootsel"]) == 0 assert capsys.readouterr().out == "Rebooting into USB BOOTSEL mode.\n" @@ -1753,12 +2141,1120 @@ def test_find_requires_selector_for_multiple_picos( first = FakeDevice() second = FakeDevice() second.address = 8 - monkeypatch.setattr( - config_manager, "_candidate_devices", lambda: [first, second] - ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [first, second]) with pytest.raises( config_manager.ConfigManagerError, match="multiple switch-pico devices", ): config_manager.find_pico(None, None) assert config_manager.find_pico(1, 8) is second + + +def haptics_response( + state: int = 0, + *, + run_id: int = 0, + slot: int = 0xFF, + last_error: int = 0, + sent_packets: int = 0, + first_tone_due_us: int = 0, + first_tone_sent_us: int = 0, + elapsed_us: int = 0, + connection_generation: int = 9, + mode: int = 0, + host_updates: int = 0, + dropped_updates: int = 0, + packet_frames: int = 64, + last_packet_nonzero: bool = False, +) -> bytes: + return make_response( + config_manager.OP_HAPTICS_EXPERIMENT, + struct.pack( + "<17I8B2I", + run_id, + connection_generation, + 100, + 105, + sent_packets, + 2, + 3, + 109, + 4, + 123, + 22000, + 11001, + 9876, + first_tone_due_us, + first_tone_sent_us, + 0x76543210, + elapsed_us, + state, + slot, + last_error, + 0, + mode, + packet_frames, + int(last_packet_nonzero), + 0, + host_updates, + dropped_updates, + ), + schema=5, + generation=run_id, + ) + + +class HapticsDevice(FakeDevice): + def __init__( + self, + responses: list[bytes | Exception], + *, + transport_response: bytes | Exception | None = None, + ) -> None: + super().__init__() + self.haptics_responses = responses + self.transport_response = transport_response + + def ctrl_transfer( + self, + bm_request_type: int, + request: int, + value: int, + index: int, + data_or_w_length: object, + timeout: int, + ) -> bytes | int: + if request == config_manager.OP_HAPTICS_TRANSPORT_PROBE: + assert bm_request_type == 0xC0 + assert value == config_manager.REQUEST_VALUE + assert index == config_manager.REQUEST_INDEX + self.requests.append(request) + assert self.transport_response is not None + if isinstance(self.transport_response, Exception): + raise self.transport_response + return self.transport_response + if request != config_manager.OP_HAPTICS_EXPERIMENT: + return super().ctrl_transfer( + bm_request_type, request, value, index, data_or_w_length, timeout + ) + assert value == config_manager.REQUEST_VALUE + assert index == config_manager.REQUEST_INDEX + self.requests.append(request) + if bm_request_type == 0xC0: + response = self.haptics_responses[0] + if len(self.haptics_responses) > 1: + self.haptics_responses.pop(0) + if isinstance(response, Exception): + raise response + return response + assert bm_request_type == 0x40 + encoded = bytes(data_or_w_length) + magic, version, operation, flags, reserved, size, schema, crc = ( + struct.unpack_from("<4sBBBBHHI", encoded) + ) + payload = encoded[16:] + assert (magic, version, operation, flags, reserved, size, schema) == ( + b"SPMG", + 1, + 0x40, + 0, + 0, + 2, + 0, + ) + assert crc == zlib.crc32(payload) & 0xFFFFFFFF + self.out_requests.append((request, payload, encoded)) + return len(encoded) + + +@pytest.fixture +def haptics_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + monkeypatch.setattr(config_manager.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(config_manager.time, "sleep", sleep) + return clock + + +def test_haptics_schema_timing_and_wraparound() -> None: + device = HapticsDevice( + [ + haptics_response( + 2, + run_id=17, + slot=2, + sent_packets=101, + first_tone_due_us=0xFFFFFFF0, + first_tone_sent_us=0x30, + elapsed_us=1100000, + mode=1, + host_updates=0x89ABCDEF, + dropped_updates=0x12345678, + ), + ] + ) + snapshot = config_manager.read_haptics_experiment(device) + assert snapshot.state_name == "running" + assert snapshot.run_id == 17 and snapshot.slot == 2 + assert snapshot.sent_packets == 101 + assert snapshot.generated_packets == 105 and snapshot.skipped_packets == 2 + assert snapshot.send_failures == 3 and snapshot.can_send_requests == 109 + assert snapshot.synchronous_callbacks == 4 + assert snapshot.max_generate_us == 123 + assert snapshot.max_send_gap_us == 22000 + assert snapshot.max_lateness_us == 11001 + assert snapshot.max_request_wait_us == 9876 + assert snapshot.first_tone_submission_delay_us == 64 + assert snapshot.last_sent_us == 0x76543210 + assert snapshot.elapsed_us == 1100000 + assert snapshot.to_json_object()["first_tone_submission_delay_us"] == 64 + assert snapshot.mode_name == "gameplay" + assert snapshot.host_updates == 0x89ABCDEF + assert snapshot.dropped_updates == 0x12345678 + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("schema", "unsupported haptics experiment schema"), + ("short", "payload size"), + ("long", "payload size"), + ("old_size", "payload size"), + ("mode_only_size", "payload size"), + ("state", "state"), + ("slot", "slot"), + ("active_without_slot", "slot"), + ("mode", "mode"), + ("reserved71", "reserved"), + ("packet_size", "packet size"), + ("nonzero_flag", "nonzero flag"), + ("reserved75", "reserved"), + ("flags", "reserved"), + ], +) +def test_haptics_rejects_malformed_diagnostics(mutation: str, message: str) -> None: + payload = bytearray(haptics_response(2, slot=0)[20:]) + schema, flags = 5, 0 + if mutation == "schema": + schema = 2 + del payload[72:] + elif mutation == "short": + payload.pop() + elif mutation == "long": + payload.append(0) + elif mutation == "old_size": + del payload[72:] + elif mutation == "mode_only_size": + del payload[76:] + elif mutation == "state": + payload[68] = 8 + elif mutation == "slot": + payload[69] = 4 + elif mutation == "active_without_slot": + payload[69] = 0xFF + elif mutation == "mode": + payload[72] = 2 + elif mutation == "packet_size": + payload[73] = 1 + elif mutation == "nonzero_flag": + payload[74] = 2 + elif mutation.startswith("reserved"): + payload[int(mutation.removeprefix("reserved"))] = 1 + else: + flags = 1 + response = make_response( + 0x40, + bytes(payload), + schema=schema, + flags=flags, + ) + with pytest.raises(config_manager.ConfigManagerError, match=message): + config_manager.read_haptics_experiment(HapticsDevice([response])) + + +def test_haptics_disabled_firmware_is_readable_but_cannot_start( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([haptics_response(6)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0 + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["state_name"] == "unsupported" + assert status["firmware_supported"] is False + assert status["mode"] == 0 + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "unsupported" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "gameplay"]) == 1 + assert "unsupported" in capsys.readouterr().err + assert device.out_requests == [] + + +def test_haptics_old_firmware_stall_is_actionable_without_hiding_disconnect( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice( + [ + config_manager.usb.core.USBError("Pipe error", error_code=-9, errno=32), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in capsys.readouterr().err + assert device.out_requests == [] + disconnected = config_manager.usb.core.USBError( + "No such device", + error_code=-4, + errno=19, + ) + with pytest.raises(config_manager.usb.core.USBError) as raised: + config_manager.read_haptics_experiment(HapticsDevice([disconnected])) + assert raised.value is disconnected + + +@pytest.mark.parametrize(("action", "mode"), [("start", 0), ("gameplay", 1)]) +def test_haptics_arming_waits_for_firmware_not_usb_ack( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], + action: str, + mode: int, +) -> None: + device = HapticsDevice( + [ + haptics_response(3, run_id=40, slot=0), + haptics_response(1, run_id=41, slot=0, mode=mode), + haptics_response(2, run_id=41, slot=0, sent_packets=1, mode=mode), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", action, "--json"]) == 0 + captured = capsys.readouterr() + row = json.loads(captured.out) + assert row["state_name"] == "running" + assert (row["run_id"], row["slot"], row["mode"]) == (41, 0, mode) + assert device.out_requests[0][1] == bytes((1 if action == "start" else 2, 0)) + assert haptics_clock[0] >= 0.1 + if mode == 1: + assert "pattern" not in row + assert row["gameplay"] == { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "lookback_us": pytest.approx(21333.333333333), + "switch_command_window_us": 8000, + "switch_watchdog_us": 50000, + "xinput_command_policy": "held_until_changed_or_stopped", + "xinput_carrier_hz": {"left_low": 160, "right_high": 320}, + "band_gains": {"low": 2.0, "high": 2.0}, + "response_exponent": 0.8, + } + assert row["first_tone_submission_delay_us"] is None + else: + assert row["pattern"]["duration_us"] == 6144000 + assert "gameplay" not in row + + +def test_haptics_start_watch_captures_correlated_measurement_series( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice( + [ + haptics_response(), + haptics_response(1, run_id=1, slot=3), + haptics_response( + 2, + run_id=1, + slot=3, + sent_packets=101, + first_tone_due_us=0xFFFFFFF0, + first_tone_sent_us=0x30, + elapsed_us=1100000, + ), + haptics_response(3, run_id=1, slot=3, sent_packets=574, elapsed_us=6144000), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "haptics-experiment", + "start", + "--slot", + "3", + "--watch", + "--json", + ] + ) + == 0 + ) + captured = capsys.readouterr() + series = [json.loads(line) for line in captured.out.splitlines()] + assert [row["state_name"] for row in series] == [ + "pending", + "running", + "completed", + ] + assert [row["sent_packets"] for row in series] == [0, 101, 574] + assert series[1]["first_tone_submission_delay_us"] == 64 + assert series[-1]["elapsed_us"] == 6144000 + assert series[-1]["pattern"]["duration_us"] == 6144000 + assert series[0]["host_monotonic_s"] < series[-1]["host_monotonic_s"] + assert "pending firmware confirmation" in captured.err + + +@pytest.mark.parametrize( + ("state", "error", "description"), + [ + (6, 1, "unsupported"), + (6, 2, "MTU"), + (5, 3, "connection"), + (7, 4, "timed out"), + (7, 5, "send failed"), + (7, 6, "wait for prior output to drain"), + ], +) +def test_haptics_gameplay_reports_asynchronous_rejection( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], + state: int, + error: int, + description: str, +) -> None: + device = HapticsDevice( + [ + haptics_response(), + haptics_response(1, run_id=1, slot=0, mode=1), + haptics_response(state, run_id=1, slot=0, last_error=error, mode=1), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "gameplay", "--json"]) == 1 + captured = capsys.readouterr() + assert description in captured.err + assert f"last_error={error}" in captured.err + row = json.loads(captured.out) + assert (row["run_id"], row["slot"], row["mode"]) == (1, 0, 1) + assert row["state"] == state and row["last_error"] == error + assert "pattern" not in row + + +def test_haptics_status_watch_reports_connection_loss_after_running( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice( + [ + haptics_response(2, run_id=7, slot=0, sent_packets=11), + haptics_response(5, run_id=7, slot=0, sent_packets=13, last_error=3), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "haptics-experiment", + "status", + "--watch", + "--json", + ] + ) + == 1 + ) + captured = capsys.readouterr() + assert [json.loads(line)["state_name"] for line in captured.out.splitlines()] == [ + "running", + "disconnected", + ] + assert "connection missing or lost" in captured.err + + +def test_haptics_start_does_not_mistake_stale_completion_for_new_run( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([haptics_response(3, run_id=8, slot=0)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "--timeout", + "0.2", + "haptics-experiment", + "start", + ] + ) + == 1 + ) + assert "not confirmed before --timeout" in capsys.readouterr().err + assert haptics_clock[0] == pytest.approx(0.2) + + +def test_haptics_watch_is_bounded_and_rejects_run_replacement( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([haptics_response(2, run_id=1, slot=0)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "--timeout", + "0.2", + "haptics-experiment", + "status", + "--watch", + ] + ) + == 1 + ) + assert "did not reach a terminal state" in capsys.readouterr().err + device.haptics_responses = [ + haptics_response(2, run_id=1, slot=0), + haptics_response(3, run_id=2, slot=0), + ] + assert ( + config_manager.main( + [ + "haptics-experiment", + "status", + "--watch", + ] + ) + == 1 + ) + assert "run changed" in capsys.readouterr().err + + +def test_haptics_busy_start_and_wrong_slot_stop_do_not_mutate_active_run( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice([haptics_response(2, run_id=1, slot=3, mode=1)]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "already running" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "gameplay"]) == 1 + assert "already running" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "stop"]) == 1 + assert "not requested slot 0" in capsys.readouterr().err + assert device.out_requests == [] + + +@pytest.mark.parametrize("mode", [0, 1]) +def test_haptics_stop_waits_for_service_completion( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], + mode: int, +) -> None: + device = HapticsDevice( + [ + haptics_response(2, run_id=1, slot=2, mode=mode), + haptics_response(2, run_id=1, slot=2, mode=mode), + haptics_response(4, run_id=1, slot=2, mode=mode), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "haptics-experiment", + "stop", + "--slot", + "2", + "--json", + ] + ) + == 0 + ) + captured = capsys.readouterr() + row = json.loads(captured.out) + assert row["state_name"] == "stopped" + assert (row["run_id"], row["slot"], row["mode"]) == (1, 2, mode) + assert device.out_requests[0][1] == b"\x00\x02" + assert haptics_clock[0] >= 0.1 + + +def test_haptics_invalid_slot_is_rejected_before_discovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_discovery(*args: object) -> None: + pytest.fail("invalid haptics slot reached USB discovery") + + monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery) + with pytest.raises(SystemExit) as raised: + config_manager.main(["haptics-experiment", "start", "--slot", "4"]) + assert raised.value.code == 2 + + +def test_haptics_retry_after_unsupported_controller_is_not_disabled_firmware( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice( + [ + haptics_response(6, run_id=7, slot=0, last_error=1), + haptics_response(2, run_id=8, slot=0), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 8 + assert len(device.out_requests) == 1 + + +def test_haptics_start_correlates_rollover_and_rejects_superseded_run( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice( + [ + haptics_response(3, run_id=0xFFFFFFFF, slot=0), + haptics_response(2, run_id=0, slot=0), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 0 + device.haptics_responses = [ + haptics_response(3, run_id=10, slot=0), + haptics_response(3, run_id=12, slot=0), + ] + assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert "run changed" in capsys.readouterr().err + + +@pytest.mark.parametrize("timeout", ["nan", "inf"]) +def test_haptics_timeout_must_be_bounded_before_discovery( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + timeout: str, +) -> None: + def unexpected_discovery(*args: object) -> None: + pytest.fail("unbounded timeout reached USB discovery") + + monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery) + assert ( + config_manager.main( + [ + "--timeout", + timeout, + "haptics-experiment", + "status", + "--watch", + ] + ) + == 2 + ) + assert "must be finite" in capsys.readouterr().err + + +def transport_response( + *, + run_id: int = 17, + connection_generation: int = 9, + connection_handle: int = 0x1234, + active: int = 0, +) -> bytes: + return make_response( + config_manager.OP_HAPTICS_TRANSPORT_PROBE, + struct.pack( + "<38Ii5I", + run_id, + connection_generation, + connection_handle, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 0xFFFFFFF0, + active, + 27, + 0xFFFFFFFF, + 29, + 30, + 1021, + 10, + 400000, + 399998, + 48000, + 1300, + 6, + 1366, + -1250, + 100, + 20, + 3, + 0, + 1, + ), + schema=3, + generation=run_id, + ) + + +def test_haptics_profile_decodes_exact_wire_order_and_correlates_live_run( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = HapticsDevice( + [ + haptics_response(2, run_id=17, slot=0, sent_packets=10, mode=1), + haptics_response(2, run_id=17, slot=0, sent_packets=11, mode=1), + ], + transport_response=transport_response(active=1), + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 + row = json.loads(capsys.readouterr().out) + assert row["run_id"] == 17 and row["connection_generation"] == 9 + assert row["sent_packets"] == 11 + assert row["mode_name"] == "gameplay" and "pattern" not in row + transport = row["transport"] + transport.pop("evidence_note") + assert transport == { + "schema_version": 3, + "run_id": 17, + "connection_generation": 9, + "connection_handle": 0x1234, + "timer_wakes": 4, + "max_timer_lateness_us": 5, + "total_timer_lateness_us": 6, + "send_calls": 7, + "max_send_us": 8, + "total_send_us": 9, + "write_calls": 10, + "max_write_us": 11, + "total_write_us": 12, + "read_calls": 13, + "read_packets": 14, + "max_read_us": 15, + "total_read_us": 16, + "poll_calls": 17, + "max_poll_us": 18, + "total_poll_us": 19, + "completion_events": 20, + "completed_packets": 21, + "max_completion_gap_us": 22, + "max_outstanding_acl": 23, + "min_free_acl": 24, + "first_tone_send_return_us": 0xFFFFFFF0, + "active": True, + "max_permission_wait_us": 27, + "total_permission_wait_us": 0xFFFFFFFF, + "permission_callbacks": 29, + "max_poll_gap_us": 30, + "controller_acl_packet_bytes": 1021, + "controller_acl_packet_count": 10, + "requested_sys_khz": 400000, + "measured_sys_khz": 399998, + "measured_usb_khz": 48000, + "core_voltage_mv": 1300, + "flash_clock_divider": 6, + "cyw43_pio_divider256": 1366, + "temperature_millicelsius": -1250, + "host_completed_writes": 100, + "acl_writes": 20, + "other_writes": 3, + "write_failures": 0, + "packet_read_optimized": 1, + } + assert transport["active"] is True + assert device.out_requests == [] + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("schema", "unsupported haptics transport probe schema"), + ("short", "payload size"), + ("long", "payload size"), + ("flags", "reserved flags"), + ("active", "active boolean"), + ("handle", "connection handle"), + ("envelope_run", "envelope run ID mismatch"), + ("status", "device busy"), + ("crc", "CRC mismatch"), + ], +) +def test_haptics_profile_rejects_malformed_transport( + mutation: str, + message: str, +) -> None: + payload = bytearray(transport_response()[20:]) + schema, flags, generation, status = 3, 0, 17, config_manager.STATUS_OK + if mutation == "schema": + schema = 1 + elif mutation == "short": + payload.pop() + elif mutation == "long": + payload.extend(b"\0\0\0\0") + elif mutation == "flags": + flags = 1 + elif mutation == "active": + struct.pack_into(" None: + device = HapticsDevice( + [haptics_response(5, run_id=17, slot=0, last_error=3, mode=1)], + transport_response=transport_response(connection_handle=0xFFFF), + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 + row = json.loads(capsys.readouterr().out) + assert row["state_name"] == "disconnected" and row["last_error"] == 3 + assert row["mode"] == 1 and "pattern" not in row + assert row["transport"]["connection_handle"] == 0xFFFF + assert row["transport"]["active"] is False + + +@pytest.mark.parametrize("unsupported", ["status", "stall"]) +def test_haptics_profile_unsupported_keeps_legacy_status_readable( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + unsupported: str, +) -> None: + response: bytes | Exception = make_response( + 0x41, + status=config_manager.STATUS_UNSUPPORTED_SCHEMA, + schema=1, + ) + if unsupported == "stall": + response = config_manager.usb.core.USBError( + "Pipe error", + error_code=-9, + errno=32, + ) + device = HapticsDevice( + [haptics_response(3, run_id=17, slot=0)], + transport_response=response, + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "0x41" in captured.err + assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err + assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == 17 + assert device.out_requests == [] + + +def test_haptics_profile_does_not_hide_usb_disconnect_as_unsupported() -> None: + disconnected = config_manager.usb.core.USBError( + "No such device", + error_code=-4, + errno=19, + ) + device = HapticsDevice( + [haptics_response(3, run_id=17, slot=0)], + transport_response=disconnected, + ) + with pytest.raises(config_manager.usb.core.USBError) as raised: + config_manager.read_haptics_experiment_profile(device) + assert raised.value is disconnected + + +@pytest.mark.parametrize( + ( + "after_run", + "after_generation", + "probe_run", + "probe_generation", + "after_slot", + "after_mode", + ), + [ + (17, 9, 16, 9, 0, 1), # A stale probe cannot attach to the current run. + (18, 9, 17, 9, 0, 1), # A new run starts after reading the probe. + (18, 9, 18, 9, 0, 1), # A new run starts before reading the probe. + (17, 10, 17, 9, 0, 1), # A connection changes after reading the probe. + (17, 10, 17, 10, 0, 1), # A connection changes before reading the probe. + (17, 9, 17, 8, 0, 1), # A matching run ID cannot mask stale connection data. + (17, 9, 17, 9, 1, 1), # A different slot must not inherit the profile. + (17, 9, 17, 9, 0, 0), # A fixture cannot impersonate gameplay. + ], +) +def test_haptics_profile_never_publishes_cross_run_metrics( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + after_run: int, + after_generation: int, + probe_run: int, + probe_generation: int, + after_slot: int, + after_mode: int, +) -> None: + device = HapticsDevice( + [ + haptics_response(2, run_id=17, slot=0, mode=1), + haptics_response( + 2, + run_id=after_run, + slot=after_slot, + connection_generation=after_generation, + mode=after_mode, + ), + ], + transport_response=transport_response( + run_id=probe_run, + connection_generation=probe_generation, + ), + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "cannot attribute measurements" in captured.err + + +def test_haptics_gameplay_watch_timeout_leaves_stream_armed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice( + [ + haptics_response(3, run_id=8, slot=0), + haptics_response(1, run_id=9, slot=3, mode=1), + haptics_response( + 2, + run_id=9, + slot=3, + mode=1, + sent_packets=2, + host_updates=4, + dropped_updates=1, + ), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "--timeout", + "0.2", + "haptics-experiment", + "gameplay", + "--slot", + "3", + "--watch", + "--json", + ] + ) + == 1 + ) + captured = capsys.readouterr() + series = [json.loads(line) for line in captured.out.splitlines()] + assert [row["state_name"] for row in series] == ["pending", "running", "running"] + assert all((row["run_id"], row["slot"], row["mode"]) == (9, 3, 1) for row in series) + assert all("pattern" not in row for row in series) + assert all(row["first_tone_submission_delay_us"] is None for row in series) + assert series[-1]["host_updates"] == 4 and series[-1]["dropped_updates"] == 1 + assert haptics_clock[0] == pytest.approx(0.2) + assert "still armed" in captured.err and "stop --slot 3" in captured.err + assert [request[1] for request in device.out_requests] == [b"\x02\x03"] + + +@pytest.mark.parametrize( + ("action", "response_run", "response_slot", "response_mode"), + [ + ("gameplay", 42, 0, 1), + ("gameplay", 41, 1, 1), + ("gameplay", 41, 0, 0), + ("start", 41, 0, 1), + ("stop", 40, 0, 0), + ], +) +def test_haptics_confirmation_never_attributes_another_run_slot_or_mode( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + action: str, + response_run: int, + response_slot: int, + response_mode: int, +) -> None: + stopping = action == "stop" + device = HapticsDevice( + [ + haptics_response( + 2 if stopping else 3, run_id=40, slot=0, mode=int(stopping) + ), + haptics_response( + 4 if stopping else 2, + run_id=response_run, + slot=response_slot, + mode=response_mode, + ), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", action, "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "run changed" in captured.err or "belongs to another" in captured.err + + +def test_haptics_gameplay_watch_rejects_fixture_with_same_run_and_slot( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice( + [ + haptics_response(2, run_id=7, slot=0, mode=1), + haptics_response(3, run_id=7, slot=0, mode=0), + ] + ) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert ( + config_manager.main( + [ + "haptics-experiment", + "status", + "--watch", + "--json", + ] + ) + == 1 + ) + captured = capsys.readouterr() + row = json.loads(captured.out) + assert row["state_name"] == "running" and row["mode"] == 1 + assert "cannot attribute measurements" in captured.err + + +def capture_envelope( + events: list[tuple[int, int]], + *, + first: int = 0, + total: int = 3, + generation: int = 9, + elapsed_us: int = 5000, +) -> config_manager.Envelope: + payload = struct.pack( + " None: + monkeypatch.setattr(config_manager, "_control_out", lambda *args: None) + monkeypatch.setattr( + config_manager, + "_control_in", + lambda *args: capture_envelope([(0, 0), (1500, 1), (3500, 0)]), + ) + page = config_manager.collect_macro_capture(FakeDevice(), 7) + steps = config_manager.capture_macro_steps(page) + assert [step.output_button_mask for step in steps] == [0, 1, 0] + assert [step.duration_ms for step in steps] == [2, 2, 1] + + +def test_capture_collection_rejects_mixed_connection_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pages = iter( + [ + capture_envelope( + [(index * 1000, 0) for index in range(32)], total=33, elapsed_us=40000 + ), + capture_envelope( + [(32000, 1)], first=32, total=33, generation=10, elapsed_us=40000 + ), + ] + ) + monkeypatch.setattr(config_manager, "_control_out", lambda *args: None) + monkeypatch.setattr(config_manager, "_control_in", lambda *args: next(pages)) + with pytest.raises( + config_manager.ConfigManagerError, match="changed while reading" + ): + config_manager.collect_macro_capture(FakeDevice(), 7) + + +def test_haptics_reports_actual_short_packet_lookback() -> None: + envelope = config_manager.parse_response( + haptics_response(2, run_id=7, slot=0, mode=1, packet_frames=32), + config_manager.OP_HAPTICS_EXPERIMENT, + ) + gameplay = config_manager.parse_haptics_experiment(envelope).to_json_object()[ + "gameplay" + ] + assert gameplay["stereo_frames_per_packet"] == 32 + assert gameplay["lookback_us"] == pytest.approx(10666.6666667) diff --git a/tests/test_configure_switch2_wake.py b/tests/test_configure_switch2_wake.py new file mode 100644 index 0000000..c4595e8 --- /dev/null +++ b/tests/test_configure_switch2_wake.py @@ -0,0 +1,70 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "configure_switch2_wake", + ROOT / "tools" / "configure_switch2_wake.py", +) +configure = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(configure) + +RAW_HEX = "0201061BFF53050100037E056620000181BDD6F7EBF1480F00000000000000" +RECORD = { + "version": 1, + "advertiser": "98:E2:55:07:DF:00", + "address_type": 0, + "event_type": 0, + "rssi": -67, + "pid": "2066", + "console": "48:F1:EB:F7:D6:BD", + "raw_hex": RAW_HEX, + "esphome_payload_hex": RAW_HEX[14:], +} + + +def test_valid_capture_generates_build_configuration(tmp_path: Path) -> None: + capture_log = tmp_path / "capture.log" + capture_log.write_text( + "scanner startup\nSWITCH2_WAKE_CAPTURE " + json.dumps(RECORD) + "\n", + encoding="utf-8", + ) + output = tmp_path / "switch2_wake_config.h" + + assert configure.main( + ["--input", str(capture_log), "--output", str(output)] + ) == 0 + generated = output.read_text(encoding="utf-8") + assert "#define SWITCH2_WAKE_CONFIGURED 1" in generated + assert "0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00" in generated + assert "0x02, 0x01, 0x06, 0x1B, 0xFF" in generated + assert "Target Switch 2: 48:F1:EB:F7:D6:BD" in generated + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("address_type", 1, "public advertiser"), + ("event_type", 3, "ADV_IND"), + ("console", "48:F1:EB:F7:D6:BE", "console address"), + ("pid", "2067", "PID"), + ], +) +def test_inconsistent_capture_is_rejected(field, value, message) -> None: + record = dict(RECORD) + record[field] = value + with pytest.raises(configure.CaptureError, match=message): + configure.validate_capture(record) + + +def test_non_wake_packet_is_rejected() -> None: + record = dict(RECORD) + raw = bytearray.fromhex(RAW_HEX) + raw[16] = 0 + record["raw_hex"] = raw.hex() + with pytest.raises(configure.CaptureError, match="wake flag"): + configure.validate_capture(record) diff --git a/tests/test_controller_macro_capture_native.py b/tests/test_controller_macro_capture_native.py new file mode 100644 index 0000000..bc3420f --- /dev/null +++ b/tests/test_controller_macro_capture_native.py @@ -0,0 +1,29 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_controller_macro_capture_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_macro_capture_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "controller_macro_capture_test.cpp"), + str(root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_cyw43_packet_transport_native.py b/tests/test_cyw43_packet_transport_native.py new file mode 100644 index 0000000..443d111 --- /dev/null +++ b/tests/test_cyw43_packet_transport_native.py @@ -0,0 +1,40 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_cyw43_packet_transport_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + executable = tmp_path / "cyw43_packet_transport_test" + stubs = root / "tests" / "cyw43_packet_transport_native_stubs" + subprocess.run( + [ + compiler, + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{stubs}", + str(root / "tests" / "cyw43_packet_transport_test.c"), + str(stubs / "sdk.c"), + str( + root + / "src" + / "firmware" + / "platform" + / "pico" + / "cyw43_packet_transport.c" + ), + "-Wl,--wrap=cyw43_btbus_read", + "-Wl,--wrap=cyw43_btbus_init", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_haptics_experiment_native.py b/tests/test_haptics_experiment_native.py new file mode 100644 index 0000000..4d1b28a --- /dev/null +++ b/tests/test_haptics_experiment_native.py @@ -0,0 +1,59 @@ +import shutil +import subprocess +import zlib +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"]) +@pytest.mark.parametrize("short_packets", [False, True], ids=["64frames", "32frames"]) +def test_haptics_experiment_native( + tmp_path: Path, ram: int, short_packets: bool +) -> 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 / "haptics_experiment_test" + corpus = tmp_path / "native_reports.bin" + subprocess.run( + [ + compiler, + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", + f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}", + *( + [ + "-DSWITCH_PICO_CYW43_PACKET_READ=1", + "-DSWITCH_PICO_HCI_CREDIT_BATCH=1", + "-DSWITCH_PICO_SYS_CLOCK_MHZ=300", + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", + ] + if short_packets + else [] + ), + f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "haptics_experiment_test.cpp"), + str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), + str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"), + str(root / "src" / "firmware" / "usb" / "switch" / "switch_haptics.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable), str(corpus)], check=True, cwd=root) + reports = corpus.read_bytes() + assert len(reports) == 288 * 143 + # Independent standard-library CRC across real module-generated packets: + # A2 is covered once, CRC itself excluded, and stored little-endian. + for offset in range(0, len(reports), 143): + report = reports[offset : offset + 143] + assert int.from_bytes(report[-4:], "little") == zlib.crc32(report[:-4]) diff --git a/tests/test_haptics_transport_probe_native.py b/tests/test_haptics_transport_probe_native.py new file mode 100644 index 0000000..4375432 --- /dev/null +++ b/tests/test_haptics_transport_probe_native.py @@ -0,0 +1,35 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_haptics_transport_probe_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 / "haptics_transport_probe_test" + stubs = root / "tests" / "haptics_transport_probe_native_stubs" + subprocess.run( + [ + compiler, + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{stubs}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "haptics_transport_probe_test.cpp"), + str(stubs / "sdk.cpp"), + str(root / "src" / "firmware" / "input" / "haptics_transport_probe.cpp"), + "-Wl,--wrap=cyw43_bluetooth_hci_write", + "-Wl,--wrap=cyw43_bluetooth_hci_read", + "-Wl,--wrap=btstack_run_loop_base_poll_data_sources", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_prepare_bluepad32.py b/tests/test_prepare_bluepad32.py index fe17f76..f0f5b31 100644 --- a/tests/test_prepare_bluepad32.py +++ b/tests/test_prepare_bluepad32.py @@ -115,6 +115,18 @@ def test_prepare_patches_copy_and_preserves_pristine_source( assert source_status(source) == "" +def test_prepare_applies_patch_inside_parent_repository( + bluepad32_fixture: tuple[Path, Path, Path, Path], +) -> None: + root, source, patch, output = bluepad32_fixture + run_git(root, "init") + + prepare_bluepad32(source, patch, output) + + assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n" + assert source_status(source) == "" + + def test_prepare_replaces_existing_output_idempotently( bluepad32_fixture: tuple[Path, Path, Path, Path], ) -> None: diff --git a/tests/test_profile_playtest_js.py b/tests/test_profile_playtest_js.py new file mode 100644 index 0000000..42c0d00 --- /dev/null +++ b/tests/test_profile_playtest_js.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_profile_playtest_math() -> None: + root = Path(__file__).resolve().parents[1] + node = shutil.which("node") + assert node is not None, "Node.js is required for Profile Studio tests" + _ = subprocess.run( + [node, str(root / "tests" / "profile_playtest_test.js")], + check=True, + cwd=root, + ) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py new file mode 100644 index 0000000..2eee9c9 --- /dev/null +++ b/tests/test_profile_web.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import replace +from typing import Any + +import pytest + +from switch_pico_bridge import config_manager, profile_web +from tests.test_config_manager import FakeDevice, custom_profile + + +@contextmanager +def running_server( + monkeypatch: pytest.MonkeyPatch, device: FakeDevice +) -> Iterator[tuple[str, str]]: + server = profile_web.ProfileEditorServer( + ("127.0.0.1", 0), + bus=None, + device_address=None, + timeout=1.0, + ) + monkeypatch.setattr(server, "find_device", lambda: device) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", server.mutation_token + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def request_json( + url: str, + *, + method: str = "GET", + value: Any = None, + token: str | None = None, +) -> tuple[int, dict[str, Any]]: + data = None + headers: dict[str, str] = {} + if value is not None: + data = json.dumps(value).encode("utf-8") + headers["Content-Type"] = "application/json" + if token is not None: + headers["X-Switch-Pico-Token"] = token + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=2) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +def test_editor_serves_assets_and_complete_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with running_server(monkeypatch, FakeDevice()) as (base_url, _): + with urllib.request.urlopen(f"{base_url}/", timeout=2) as response: + page = response.read().decode("utf-8") + assert response.headers["Content-Security-Policy"] + with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response: + script_size = len(response.read()) + assert response.headers["Content-Type"].startswith("text/javascript") + with urllib.request.urlopen( + f"{base_url}/assets/controller-switch-pro.svg", timeout=2 + ) as response: + artwork_size = len(response.read()) + assert response.headers["Content-Type"] == "image/svg+xml" + + status, schema = request_json(f"{base_url}/api/schema") + + assert status == 200 + assert "Profile Studio" in page + assert script_size > 1000 + assert artwork_size > 10000 + assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS) + assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS) + assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) + assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) + assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) + assert schema["profile_capacity"] == 8 + assert schema["control_labels"]["generic"]["south"] == "A" + assert schema["control_labels"]["xbox"]["left_shoulder"] == "LB" + assert schema["control_labels"]["switch"]["east"] == "A" + assert schema["control_labels"]["switch"]["left_trigger"] == "ZL" + assert schema["control_labels"]["playstation"]["south"] == "Cross" + assert schema["control_labels"]["playstation"]["select"] == "Create" + assert ( + config_manager.ControllerProfile.from_json( + json.dumps(schema["default_profile"]) + ) + == config_manager.ControllerProfile.default() + ) + + +def test_editor_identifies_connected_controller_artwork( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + switch = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_CLASSIC, + 0, + bytes.fromhex("010203040506"), + 0x057E, + 0x2009, + ) + dualsense = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_CLASSIC, + 0, + bytes.fromhex("111213141516"), + 0x054C, + 0x0CE6, + ) + device.profile_identities = [ + device.global_identity, + switch, + dualsense, + device.stable_identity, + ] + device.active_profiles = { + identity.to_bytes(): 0 for identity in device.profile_identities + } + + with running_server(monkeypatch, device) as (base_url, _): + status, listing = request_json(f"{base_url}/api/profiles") + + assert status == 200 + assert [identity["controller"] for identity in listing["identities"]] == [ + {"model": "Generic controller", "style": "generic"}, + {"model": "Nintendo Switch Pro Controller", "style": "switch"}, + {"model": "Sony DualSense", "style": "playstation"}, + {"model": "Xbox controller", "style": "xbox"}, + ] + assert [identity["label"] for identity in listing["identities"]] == [ + "Default profile", + "Switch Pro · 05:06", + "DualSense · 15:16", + "Xbox · 50:60", + ] + assert [identity["key"] for identity in listing["identities"]] == [ + identity.to_bytes().hex() for identity in device.profile_identities + ] + + +def test_editor_reads_writes_and_activates_profiles_atomically( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + with running_server(monkeypatch, device) as (base_url, token): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + assert listing["identities"][1]["active_profile"] == 2 + assert listing["identities"][1]["key"] == ( + device.stable_identity.to_bytes().hex() + ) + assert listing["identities"][1]["controller"] == { + "model": "Xbox controller", + "style": "xbox", + } + + status, playtest = request_json(f"{base_url}/api/profiles/1/8/playtest") + assert status == 200 + assert playtest["connected"] is True + assert playtest["label"] == "Xbox · 50:60" + assert playtest["controller"] == { + "model": "Xbox controller", + "style": "xbox", + } + assert playtest["buttons"] == [ + "south", + "dpad_up", + "dpad_right", + ] + assert playtest["left_stick"] == {"x": -1234, "y": 2345} + assert playtest["triggers"] == {"left": 123, "right": 65000} + assert playtest["battery"] == 100 + assert playtest["capabilities"] == [ + "rumble", + "lightbar", + "player_leds", + "motion", + ] + + status, selected = request_json(f"{base_url}/api/profiles/1/8") + assert status == 200 + assert selected["active"] is False + + profile = custom_profile().to_json_object() + status, stored = request_json( + f"{base_url}/api/profiles/1/8", + method="PUT", + value=profile, + token=token, + ) + assert status == 200 + assert stored["stored_generation"] == 8 + assert ( + config_manager.ControllerProfile.from_bytes( + device.profiles[(device.stable_identity.to_bytes(), 7)] + ) + == custom_profile() + ) + status, renamed = request_json( + f"{base_url}/api/profiles/1/8/name", + method="PUT", + value={"value": "Desktop"}, + token=token, + ) + assert status == 200 + assert renamed["stored_generation"] == 9 + assert device.profile_names[(device.stable_identity.to_bytes(), 7)] == "Desktop" + + status, aliased = request_json( + f"{base_url}/api/identities/1/alias", + method="PUT", + value={"value": "Desk pad"}, + token=token, + ) + assert status == 200 + assert aliased["label"] == "Desk pad" + + status, identified = request_json( + f"{base_url}/api/identities/1/identify", + method="POST", + token=token, + ) + assert status == 200 + assert identified == {"identified": True} + assert device.identified_identities == [device.stable_identity.to_bytes()] + + draft = config_manager.ControllerProfile.default().to_json_object() + draft["shortcuts"] = { + "modifier": "left_shoulder", + "profiles": ["south", None, None, None, None, None, None, "dpad_up"], + } + draft["shift"]["mode"] = "hold" + draft["shift"]["modifier"] = "right_shoulder" + draft["shift"]["button_map"]["south"] = "north" + draft["turbo"]["south"] = "burst" + draft["turbo_settings"]["overrides"]["south"] = { + "rate_hz": 7, + "duty_percent": 25, + "burst_count": 9, + } + draft["macros"][0]["playback"] = "repeat" + draft["macros"][0]["repeat_count"] = 4 + status, copied = request_json( + f"{base_url}/api/profiles/1/8/copy", + method="POST", + value={ + "identity_index": 1, + "profile_number": 4, + "profile": draft, + "name": "Draft copy", + }, + token=token, + ) + assert status == 200 + assert copied["stored_generation"] == 12 + assert ( + device.profiles[(device.stable_identity.to_bytes(), 3)] + == config_manager.ControllerProfile.from_json_object(draft).to_bytes() + ) + assert ( + device.profile_names[(device.stable_identity.to_bytes(), 3)] == "Draft copy" + ) + assert ( + device.profiles[(device.stable_identity.to_bytes(), 7)] + == custom_profile().to_bytes() + ) + + status, activated = request_json( + f"{base_url}/api/profiles/1/8/activate", + method="POST", + token=token, + ) + + assert status == 200 + assert activated["stored_generation"] == 13 + assert device.active_profiles[device.stable_identity.to_bytes()] == 7 + + +def test_editor_rejects_invalid_or_unauthorized_mutations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + original = device.profiles[(device.global_identity.to_bytes(), 0)] + with running_server(monkeypatch, device) as (base_url, token): + status, unauthorized = request_json( + f"{base_url}/api/profiles/0/1", + method="PUT", + value=config_manager.ControllerProfile.default().to_json_object(), + ) + assert status == 403 + assert "token" in unauthorized["error"] + + malformed = config_manager.ControllerProfile.default().to_json_object() + malformed["sticks"]["left"]["outer_saturation"] = 0 + status, invalid = request_json( + f"{base_url}/api/profiles/0/1", + method="PUT", + value=malformed, + token=token, + ) + + assert status == 400 + assert "outer_saturation" in invalid["error"] + assert device.profiles[(device.global_identity.to_bytes(), 0)] == original + + +def test_recorder_accepts_first_connection_generation_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + device.playtest_connection_generation = 0 + page = config_manager.MacroCapturePage( + 0, + 0, + 0, + device.playtest_slot, + 0, + 1, + 0, + 0, + 512, + 1024, + 10000, + 8, + (), + ) + + def start(*args: Any, **kwargs: Any) -> config_manager.MacroCapturePage: + nonlocal page + page = replace( + page, + run_id=1, + state=1, + total_events=1, + events=(config_manager.MacroCaptureEvent(0, 0, 0, 0, 0, 0, 0, 0),), + ) + return page + + def stop(*args: Any) -> config_manager.MacroCapturePage: + nonlocal page + page = replace(page, state=2, elapsed_us=100000) + return page + + monkeypatch.setattr(config_manager, "read_macro_capture", lambda *args: page) + monkeypatch.setattr(config_manager, "start_macro_capture", start) + monkeypatch.setattr(config_manager, "stop_macro_capture", stop) + monkeypatch.setattr(config_manager, "collect_macro_capture", lambda *args: page) + with running_server(monkeypatch, device) as (base_url, token): + status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest") + assert status == 200 + identity = device.stable_identity.to_bytes().hex() + status, started = request_json( + f"{base_url}/api/profiles/1/1/capture/start", + method="POST", + token=token, + value={ + "capture_id": "first-connection", + "owner_key": identity, + "slot": device.playtest_slot, + "connection_generation": 0, + "macro_index": 0, + "profile": config_manager.ControllerProfile.default().to_json_object(), + "channels": 1, + "max_events": 8, + "axis_quantum": 512, + "trigger_quantum": 1024, + "max_duration_ms": 10000, + }, + ) + assert status == 200 and started["state_name"] == "recording" + status, stopped = request_json( + f"{base_url}/api/profiles/1/1/capture/stop", + method="POST", + token=token, + value={ + "capture_id": "first-connection", + "owner_key": identity, + "connection_generation": 0, + "run_id": started["run_id"], + }, + ) + assert status == 200 and stopped["state_name"] == "stopped" + assert stopped["steps"][0]["duration_ms"] == 100 diff --git a/tests/test_switch2_wake_native.py b/tests/test_switch2_wake_native.py new file mode 100644 index 0000000..aacf8ad --- /dev/null +++ b/tests/test_switch2_wake_native.py @@ -0,0 +1,28 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_switch2_wake_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 / "switch2_wake_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'switch2_wake_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "switch2_wake_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch_hd_rumble_synth_native.py b/tests/test_switch_hd_rumble_synth_native.py new file mode 100644 index 0000000..9ccc070 --- /dev/null +++ b/tests/test_switch_hd_rumble_synth_native.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch_hd_rumble_synth_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 / "switch_hd_rumble_synth_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'src' / 'firmware'}", + str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"), + str(root / "tests" / "switch_hd_rumble_synth_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root, timeout=15) diff --git a/tests/test_usb_configuration_management_native.py b/tests/test_usb_configuration_management_native.py index 5d279f0..d3ca830 100644 --- a/tests/test_usb_configuration_management_native.py +++ b/tests/test_usb_configuration_management_native.py @@ -2,9 +2,14 @@ import shutil import subprocess from pathlib import Path +import pytest -def test_usb_configuration_management_native(tmp_path: Path) -> None: +@pytest.mark.parametrize("experiment_enabled", [False, True]) +def test_usb_configuration_management_native( + tmp_path: Path, + experiment_enabled: bool, +) -> 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" @@ -18,9 +23,11 @@ def test_usb_configuration_management_native(tmp_path: Path) -> None: "-Wextra", "-Werror", "-pedantic", + *(["-DSWITCH_PICO_HAPTICS_EXPERIMENT=1"] if experiment_enabled else []), f"-I{root / 'tests' / 'usb_management_native_stubs'}", f"-I{root / 'src' / 'firmware'}", str(root / "tests" / "usb_configuration_management_test.cpp"), + str(root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"), "-o", str(executable), ], diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 2ea86f9..0265f95 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -6,6 +6,10 @@ #include #include "usb/usb_output_driver.h" +#include "input/haptics_experiment.h" +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/haptics_transport_probe.h" +#endif namespace { @@ -14,6 +18,9 @@ ConfigurationServiceSnapshot current_configuration{}; ProfileServiceListSnapshot current_profile_list{}; ProfileServiceSelectedSnapshot current_profile_selected{}; ProfileServiceTransactionSnapshot current_profile_transaction{}; +ProfileServiceMetadataSnapshot current_profile_metadata{}; +Bluepad32PlaytestSnapshot current_playtest[ + BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe; uint8_t current_capabilities = USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | @@ -50,6 +57,16 @@ 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; +bool profile_metadata_requested = false; +uint32_t profile_metadata_transaction_id = 0; +uint8_t profile_metadata_index = 0; +std::string profile_metadata_value; +bool identify_requested = false; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +HapticsExperimentDiagnostics current_haptics{}; +uint32_t haptics_request_count = 0; +HapticsTransportProbe current_transport{}; +#endif void require(bool condition, const char* message) { if (!condition) { @@ -64,6 +81,11 @@ void write_u16(std::vector* output, size_t offset, (*output)[offset + 1] = static_cast(value >> 8); } +uint16_t read_u16(const std::vector& input, size_t offset) { + return static_cast(input[offset]) | + static_cast(input[offset + 1] << 8); +} + void write_u32(std::vector* output, size_t offset, uint32_t value) { (*output)[offset] = static_cast(value); @@ -447,12 +469,12 @@ void test_profile_vendor_requests() { Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize); require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && - control_payload.size() == kResponseHeaderSize + 33 && + control_payload.size() == kResponseHeaderSize + 97 && control_payload[5] == static_cast(Operation::kProfileList) && control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION && control_payload[kResponseHeaderSize] == 2 && - control_payload[kResponseHeaderSize + 31] == 2, + control_payload[kResponseHeaderSize + 63] == 2, "profile list response was not encoded"); current_profile_selected = {}; @@ -479,10 +501,110 @@ void test_profile_vendor_requests() { static_cast( CONTROLLER_PROFILE_SCHEMA_VERSION) && control_payload[kResponseHeaderSize + 1] == 0 && - control_payload[kResponseHeaderSize + 2] == 0 && + control_payload[kResponseHeaderSize + 2] == 0x80 && control_payload[kResponseHeaderSize + 3] == 1, "selected profile response was not encoded"); + current_playtest[2] = {}; + current_playtest[2].active = true; + current_playtest[2].connection_generation = 0x11223344; + current_playtest[2].state_generation = 0x55667788; + current_playtest[2].identity = expected_identity; + current_playtest[2].physical_button_mask = 0x8001; + current_playtest[2].state.left_stick_x = -1234; + current_playtest[2].state.left_stick_y = 2345; + current_playtest[2].state.right_stick_x = INT16_MIN; + current_playtest[2].state.right_stick_y = INT16_MAX; + current_playtest[2].state.left_trigger = 123; + current_playtest[2].state.right_trigger = 65000; + current_playtest[2].battery = 201; + current_playtest[2].capabilities = 0x0f; + current_playtest[2].state.motion_sample_count = 1; + current_playtest[2].state.motion_samples[0] = + {1, -2, 3, -4, 5, -6}; + request = setup_request( + Operation::kProfilePlaytest, TUSB_DIR_IN, + kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == + kResponseHeaderSize + kProfilePlaytestPayloadSize && + control_payload[5] == + static_cast(Operation::kProfilePlaytest) && + control_payload[7] == 3 && + control_payload[10] == + kProfilePlaytestSchemaVersion && + control_payload[kResponseHeaderSize] == 3 && + control_payload[kResponseHeaderSize + 1] == 2 && + read_u16(control_payload, kResponseHeaderSize + 2) == + 0x8001 && + read_u32(control_payload, kResponseHeaderSize + 4) == + 0x11223344 && + read_u32(control_payload, kResponseHeaderSize + 8) == + 0x55667788 && + static_cast(read_u16( + control_payload, kResponseHeaderSize + 26)) == + -1234 && + read_u16(control_payload, kResponseHeaderSize + 36) == + 65000 && + static_cast(read_u16( + control_payload, kResponseHeaderSize + 52)) == -6 && + control_payload[kResponseHeaderSize + 39] == 201 && + control_payload[kResponseHeaderSize + 40] == 0x0f, + "profile playtest response lost live controller state"); + current_playtest[2].active = false; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[kResponseHeaderSize] == 0 && + control_payload[kResponseHeaderSize + 1] == 0xff, + "disconnected profile playtest was not encoded"); + current_profile_metadata = {}; + current_profile_metadata.metadata.state = ProfileServiceState::kReady; + current_profile_metadata.metadata.generation = 10; + current_profile_metadata.status = + ConfigurationTransactionStatus::kCommitted; + current_profile_metadata.valid = true; + memcpy(current_profile_metadata.alias, "Desk pad", 9); + memcpy(current_profile_metadata.profile_names[7], "Desktop", 8); + request = setup_request( + Operation::kProfileMetadataRead, TUSB_DIR_IN, + kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == + kResponseHeaderSize + kProfileMetadataPayloadSize && + control_payload[kResponseHeaderSize] == 8 && + memcmp(&control_payload[kResponseHeaderSize + 1], + "Desk pad", 8) == 0 && + control_payload[ + kResponseHeaderSize + + 8 * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1)] == 7, + "profile metadata response was not encoded"); + + std::vector metadata(27); + write_u32(&metadata, 0, 0x12345678); + require(controller_identity_encode( + expected_identity, &metadata[4], + CONTROLLER_IDENTITY_ENCODED_SIZE), + "metadata identity did not encode"); + metadata[18] = 7; + metadata[19] = 7; + memcpy(&metadata[20], "Desktop", 7); + perform_out(Operation::kProfileMetadataSet, metadata); + require(profile_metadata_requested && + profile_metadata_transaction_id == 0x12345678 && + profile_metadata_index == 7 && + profile_metadata_value == "Desktop", + "profile metadata mutation was not dispatched"); + + std::vector identify(CONTROLLER_IDENTITY_ENCODED_SIZE); + require(controller_identity_encode( + expected_identity, identify.data(), identify.size()), + "identify identity did not encode"); + perform_out(Operation::kProfileIdentify, identify); + require(identify_requested, + "controller Identify request was not dispatched"); + current_profile_transaction = {}; current_profile_transaction.metadata.state = ProfileServiceState::kReady; @@ -599,6 +721,264 @@ void test_profile_vendor_requests() { "short profile selection request was accepted"); } +std::vector read_haptics_payload() { + using namespace UsbConfigurationManagement; + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_IN, kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "experiment diagnostics IN was rejected"); + require(control_payload.size() == kResponseHeaderSize + 84 && + control_payload[5] == 0x40 && + control_payload[6] == static_cast(Status::kOk) && + control_payload[7] == 0 && + read_u16(control_payload, 8) == 84 && + read_u16(control_payload, 10) == 5, + "experiment schema-5 envelope is invalid"); + std::vector payload( + control_payload.begin() + kResponseHeaderSize, control_payload.end()); + require(read_u32(control_payload, 16) == + configuration_crc32(payload.data(), payload.size()), + "experiment response CRC is invalid"); + require(payload[71] == 0 && (payload[73] == 32 || payload[73] == 64) && + payload[74] <= 1 && payload[75] == 0, + "experiment reserved payload bytes must remain zero"); + return payload; +} + +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +void perform_haptics_out(uint8_t action, uint8_t slot, bool accepted = true) { + using namespace UsbConfigurationManagement; + next_out_payload = make_request(Operation::kHapticsExperiment, {action, slot}); + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "experiment OUT setup was rejected"); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) == accepted, + "experiment must reject invalid or busy requests before status ACK"); + const uint32_t requests_after_data = haptics_request_count; + if (accepted) { + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) && + haptics_request_count == requests_after_data, + "duplicate DATA replayed experiment control"); + } + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) == accepted, + "experiment ACK did not preserve DATA-stage result"); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) && + haptics_request_count == requests_after_data, + "ACK replayed experiment control"); +} +#endif + +void test_haptics_experiment_requests() { + using namespace UsbConfigurationManagement; + for (uint16_t payload_size : {0, 1, 3}) { + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + kRequestHeaderSize + payload_size); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "malformed experiment control size was accepted"); + } + std::vector expected(84, 0); + expected[69] = 0xff; + expected[73] = 64; +#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT + expected[68] = 6; + require(read_haptics_payload() == expected, + "disabled firmware must expose only the unsupported snapshot"); + for (uint8_t action : {0, 1, 2}) { + next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0}); + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "disabled firmware accepted experiment control"); + } +#else + perform_haptics_out(3, 0, false); + perform_haptics_out(1, 4, false); + perform_haptics_out(0, 0xff, false); + require(haptics_request_count == 0, + "malformed control reached the experiment service"); + + next_out_payload = make_request(Operation::kHapticsExperiment, {1, 2}); + next_out_payload.back() ^= 1; + tusb_control_request_t request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + !usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request), + "bad experiment request CRC was accepted"); + require(haptics_request_count == 0, + "bad CRC control reached the experiment service"); + + perform_haptics_out(2, 2); + auto payload = read_haptics_payload(); + require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 && + read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0, + "USB acceptance must remain pending until the service starts"); + perform_haptics_out(1, 1, false); + perform_haptics_out(2, 1, false); + payload = read_haptics_payload(); + require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 && + read_u32(payload, 0) == 1, + "busy start overwrote the accepted run"); + + // Model the independently progressing Core 1 service, not a USB echo. + current_haptics = { + 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, + 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, + 1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678, + }; + const uint32_t fields[] = { + 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, + 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, 1100000, + }; + for (size_t index = 0; index < 17; ++index) { + write_u32(&expected, index * 4, fields[index]); + } + expected[68] = 2; + expected[69] = 2; + expected[72] = 1; + write_u32(&expected, 76, 0x89abcdef); + write_u32(&expected, 80, 0x12345678); + require(read_haptics_payload() == expected, + "schema-3 timing and gameplay mode fields are not in wire order"); + + perform_haptics_out(0, 2); + payload = read_haptics_payload(); + require(payload[68] == 2 && payload[72] == 1 && read_u32(payload, 0) == 1, + "USB stop ACK must not fabricate terminal completion"); + current_haptics.state = HapticsExperimentState::kStopped; + payload = read_haptics_payload(); + require(payload[68] == 4 && payload[72] == 1, + "service stop transition was not observable"); + + next_out_payload = make_request(Operation::kHapticsExperiment, {1, 3}); + request = setup_request( + Operation::kHapticsExperiment, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + const uint32_t requests_before_cancel = haptics_request_count; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "canceled experiment setup was rejected"); + read_haptics_payload(); // A new SETUP cancels the previous OUT transfer. + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_DATA, &request) && + !usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request) && + haptics_request_count == requests_before_cancel, + "canceled experiment request reused stale payload"); + + perform_haptics_out(1, 3); + current_haptics.state = HapticsExperimentState::kDisconnected; + current_haptics.last_error = 3; + payload = read_haptics_payload(); + require(payload[68] == 5 && payload[69] == 3 && payload[70] == 3 && + read_u32(payload, 0) == 2 && payload[72] == 0, + "asynchronous connection failure lost request correlation"); +#endif +} + +void test_haptics_transport_probe_requests() { + using namespace UsbConfigurationManagement; + for (uint16_t length : {0, 16, 18, 120}) { + tusb_control_request_t request = setup_request( + Operation::kHapticsTransportProbe, TUSB_DIR_OUT, length); + for (uint8_t stage : { + CONTROL_STAGE_SETUP, CONTROL_STAGE_DATA, CONTROL_STAGE_ACK}) { + require(!usb_configuration_management_vendor_control( + 0, stage, &request), + "IN-only transport probe accepted an OUT transfer"); + } + } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + current_transport.run_id = 0x10203040; + current_transport.connection_generation = 0x50607080; + current_transport.connection_handle = 0xffff; + current_transport.timer_wakes = 4; + current_transport.max_timer_lateness_us = 5; + current_transport.total_timer_lateness_us = 6; + current_transport.send_calls = 7; + current_transport.max_send_us = 8; + current_transport.total_send_us = 9; + current_transport.write_calls = 10; + current_transport.max_write_us = 11; + current_transport.total_write_us = 12; + current_transport.read_calls = 13; + current_transport.read_packets = 14; + current_transport.max_read_us = 15; + current_transport.total_read_us = 16; + current_transport.poll_calls = 17; + current_transport.max_poll_us = 18; + current_transport.total_poll_us = 19; + current_transport.completion_events = 20; + current_transport.completed_packets = 21; + current_transport.max_completion_gap_us = 22; + current_transport.max_outstanding_acl = 23; + current_transport.min_free_acl = 24; + current_transport.first_tone_send_return_us = 0xfffffff0; + current_transport.active = 1; + current_transport.max_permission_wait_us = 27; + current_transport.total_permission_wait_us = 0xffffffff; + current_transport.permission_callbacks = 29; + current_transport.max_poll_gap_us = 30; + current_transport.controller_acl_packet_bytes = 1021; + current_transport.controller_acl_packet_count = 10; +#endif + tusb_control_request_t request = setup_request( + Operation::kHapticsTransportProbe, TUSB_DIR_IN, kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "transport probe IN was rejected"); + require(control_payload.size() >= kResponseHeaderSize, + "transport probe response header is truncated"); + require(control_payload[5] == 0x41 && control_payload[7] == 0 && + read_u16(control_payload, 10) == 3, + "transport probe operation, flags, or schema are invalid"); + const std::vector payload( + control_payload.begin() + kResponseHeaderSize, control_payload.end()); + require(read_u32(control_payload, 16) == + configuration_crc32(payload.data(), payload.size()), + "transport probe response CRC is invalid"); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + require(control_payload.size() == kResponseHeaderSize + 176 && + read_u16(control_payload, 8) == 176 && + control_payload[6] == static_cast(Status::kOk) && + read_u32(control_payload, 12) == 0x10203040, + "transport probe schema-3 envelope is invalid"); + const uint32_t fields[] = { + 0x10203040, 0x50607080, 0xffff, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 0xfffffff0, 1, 27, 0xffffffff, 29, 30, + 1021, 10, + }; + std::vector expected(176); + for (size_t index = 0; index < 32; ++index) { + write_u32(&expected, index * 4, fields[index]); + } + require(payload == expected, + "transport probe fields are not in explicit little-endian wire order"); +#else + require(control_payload.size() == kResponseHeaderSize && + read_u16(control_payload, 8) == 0 && + read_u32(control_payload, 12) == 0 && + control_payload[6] == + static_cast(Status::kUnsupportedSchema), + "disabled firmware must report the transport probe as unsupported"); +#endif +} + } // namespace uint32_t configuration_crc32(const uint8_t* data, size_t size) { @@ -678,6 +1058,17 @@ ConfigurationTransactionStatus profile_service_select( return ConfigurationTransactionStatus::kPending; } +ConfigurationTransactionStatus profile_service_set_metadata( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t selected_profile, const char* value, size_t value_size) { + profile_metadata_requested = true; + profile_metadata_transaction_id = transaction_id; + profile_identity = identity; + profile_metadata_index = selected_profile; + profile_metadata_value.assign(value, value + value_size); + return ConfigurationTransactionStatus::kPending; +} + ConfigurationTransactionStatus profile_service_begin( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t selected_profile, uint16_t schema_version, @@ -706,6 +1097,17 @@ ConfigurationTransactionStatus profile_service_commit( return ConfigurationTransactionStatus::kPending; } +void profile_service_metadata_snapshot( + ProfileServiceMetadataSnapshot* output) { + *output = current_profile_metadata; +} + +bool bluepad32_input_backend_identify( + const ControllerIdentity&) { + identify_requested = true; + return true; +} + ConfigurationTransactionStatus profile_service_reset( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t selected_profile) { @@ -740,6 +1142,43 @@ void profile_service_transaction_snapshot( *output = current_profile_transaction; } +namespace { +ControllerMacroCapture capture_fixture; +} + +bool bluepad32_input_backend_capture_start( + uint8_t slot, uint32_t generation, const CaptureOptions& options) { + return capture_fixture.start(slot, generation, options, 0, + controller_neutral_state()); +} + +bool bluepad32_input_backend_capture_stop(uint32_t run_id) { + if (run_id == 0 || run_id != capture_fixture.run_id()) return false; + capture_fixture.stop(100000); + return true; +} + +bool bluepad32_input_backend_capture_page( + uint32_t run_id, uint16_t first, Bluepad32CaptureSnapshot* output) { + if (output == nullptr || (run_id && run_id != capture_fixture.run_id()) || + first > capture_fixture.event_count()) return false; + *output = {}; + output->run_id = capture_fixture.run_id(); + output->connection_generation = capture_fixture.generation(); + output->elapsed_us = capture_fixture.elapsed_us(100000); + output->slot = capture_fixture.slot(); + output->state = capture_fixture.state(); + output->options = capture_fixture.options(); + output->total_events = capture_fixture.event_count(); + output->first_index = first; + while (output->event_count < BLUEPAD32_CAPTURE_PAGE_EVENTS && + capture_fixture.event(first + output->event_count, + &output->events[output->event_count])) { + ++output->event_count; + } + return true; +} + void bluepad32_input_backend_request_pairing_snapshot() { refresh_requested = true; } @@ -754,11 +1193,44 @@ void bluepad32_input_backend_pairing_snapshot( *out = current_pairings; } +void bluepad32_input_backend_playtest_snapshot( + uint8_t slot, Bluepad32PlaytestSnapshot* out) { + *out = current_playtest[slot]; +} + void bluepad32_input_backend_diagnostics( Bluepad32BackendDiagnostics* out) { *out = current_diagnostics; } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +bool haptics_experiment_request(uint8_t action, uint8_t slot) { + ++haptics_request_count; + if ((action == 1 || action == 2) && + (current_haptics.state == HapticsExperimentState::kPending || + current_haptics.state == HapticsExperimentState::kRunning)) { + return false; + } + if (action == 1 || action == 2) { + const uint32_t run_id = current_haptics.run_id + 1; + current_haptics = {}; + current_haptics.run_id = run_id; + current_haptics.slot = slot; + current_haptics.state = HapticsExperimentState::kPending; + current_haptics.mode = action == 2 ? 1 : 0; + } + return true; +} + +void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { + *output = current_haptics; +} + +void haptics_transport_probe_snapshot(HapticsTransportProbe* output) { + *output = current_transport; +} +#endif + bool adapter_reboot_to_bootsel() { bootsel_reboot_requested = true; return true; @@ -801,5 +1273,7 @@ int main() { test_vendor_requests(); test_mode_vendor_requests(); test_profile_vendor_requests(); + test_haptics_experiment_requests(); + test_haptics_transport_probe_requests(); return 0; } diff --git a/tests/usb_output_driver_test.cpp b/tests/usb_output_driver_test.cpp index edf1f2d..5c3882a 100644 --- a/tests/usb_output_driver_test.cpp +++ b/tests/usb_output_driver_test.cpp @@ -590,6 +590,13 @@ void test_xinput_boundary_dispatch() { static_cast(0x40 + instance), "XInput rumble crossed instance boundaries"); } + driver->reset(0); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(xinput_rumble_events[instance].count == 2 && + xinput_rumble_events[instance].output.low_frequency_magnitude == 0 && + xinput_rumble_events[instance].output.high_frequency_magnitude == 0, + "USB reset left the host's stateful rumble running"); + } expect_usb_string(1, "Switch Pico", "XInput manufacturer string changed"); diff --git a/tools/configure_switch2_wake.py b/tools/configure_switch2_wake.py new file mode 100755 index 0000000..bd37053 --- /dev/null +++ b/tools/configure_switch2_wake.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Capture a Switch 2 wake record and generate the AIO firmware header.""" + +import argparse +import json +import re +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ( + ROOT / "src" / "firmware" / "platform" / "pico" / + "switch2_wake_config.h" +) +CAPTURE_PREFIX = "SWITCH2_WAKE_CAPTURE " +ADDRESS_PATTERN = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") +EXPECTED_PREFIX = bytes.fromhex("0201061BFF53050100037E05") +EXPECTED_SUFFIX = bytes.fromhex("0F00000000000000") + + +class CaptureError(ValueError): + pass + + +def normalize_address(value, field): + if not isinstance(value, str) or ADDRESS_PATTERN.fullmatch(value) is None: + raise CaptureError(f"{field} must be a six-byte Bluetooth address.") + normalized = value.upper() + if normalized == "00:00:00:00:00:00": + raise CaptureError(f"{field} must not be all zeroes.") + return normalized + + +def parse_capture_line(line): + line = line.strip() + if line.startswith(CAPTURE_PREFIX): + line = line[len(CAPTURE_PREFIX):] + if not line.startswith("{"): + return None + try: + record = json.loads(line) + except json.JSONDecodeError: + return None + if not isinstance(record, dict) or "raw_hex" not in record: + return None + return validate_capture(record) + + +def validate_capture(record): + if record.get("version", 1) != 1: + raise CaptureError("unsupported capture version") + if record.get("address_type") != 0: + raise CaptureError("capture must use a public advertiser address") + if record.get("event_type") != 0: + raise CaptureError("capture must be an ADV_IND wake advertisement") + advertiser = normalize_address(record.get("advertiser"), "advertiser") + console = normalize_address(record.get("console"), "console") + raw_hex = record.get("raw_hex") + if not isinstance(raw_hex, str) or re.fullmatch(r"[0-9A-Fa-f]{62}", raw_hex) is None: + raise CaptureError("raw_hex must contain exactly 31 bytes") + raw = bytes.fromhex(raw_hex) + if raw[:12] != EXPECTED_PREFIX: + raise CaptureError("capture is not a Nintendo Switch 2 advertisement") + if raw[14:16] != b"\x00\x01": + raise CaptureError("capture has an unexpected reconnect marker") + if raw[16] != 0x81: + raise CaptureError("capture does not contain the Switch 2 wake flag") + if raw[23:] != EXPECTED_SUFFIX: + raise CaptureError("capture has an unexpected reserved suffix") + decoded_console = ":".join(f"{byte:02X}" for byte in reversed(raw[17:23])) + if decoded_console != console: + raise CaptureError("console address does not match raw wake data") + pid = int.from_bytes(raw[12:14], "little") + if record.get("pid", f"{pid:04X}").upper() != f"{pid:04X}": + raise CaptureError("PID does not match raw wake data") + payload = raw[7:].hex().upper() + supplied_payload = record.get("esphome_payload_hex", payload) + if supplied_payload.upper() != payload: + raise CaptureError("manufacturer payload does not match raw wake data") + return { + "advertiser": advertiser, + "console": console, + "pid": pid, + "raw": raw, + } + + +def byte_initializer(data): + return ", ".join(f"0x{byte:02X}" for byte in data) + + +def render_header(capture): + source = bytes.fromhex(capture["advertiser"].replace(":", "")) + return ( + "#pragma once\n\n" + "// Generated by tools/configure_switch2_wake.py. Do not commit this file.\n" + f"// Captured source controller: {capture['advertiser']}\n" + f"// Target Switch 2: {capture['console']}; PID: 0x{capture['pid']:04X}\n" + "#define SWITCH2_WAKE_CONFIGURED 1\n" + "#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \\\n" + f" {{{byte_initializer(source)}}}\n" + "#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \\\n" + f" {{{byte_initializer(capture['raw'])}}}\n" + ) + + +def capture_from_stream(stream, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + line = stream.readline() + if not line: + continue + if isinstance(line, bytes): + line = line.decode("utf-8", errors="replace") + print(line.rstrip()) + capture = parse_capture_line(line) + if capture is not None: + return capture + raise CaptureError("timed out waiting for a Switch 2 wake capture") + + +def pico_serial_ports(): + try: + from serial.tools import list_ports + except ImportError as exc: + raise CaptureError( + "pyserial is required; install project dependencies first" + ) from exc + return [port.device for port in list_ports.comports() if port.vid == 0x2E8A] + + +def capture_from_pico(port, timeout): + try: + import serial + except ImportError as exc: + raise CaptureError( + "pyserial is required; install project dependencies first" + ) from exc + if port is None: + ports = pico_serial_ports() + if len(ports) != 1: + detail = ", ".join(ports) if ports else "none" + raise CaptureError( + f"expected one Pico USB serial port, found {detail}; use --port" + ) + port = ports[0] + print(f"Waiting for automatic wake capture on {port}...") + with serial.Serial(port, 115200, timeout=0.25, write_timeout=1) as device: + deadline = time.monotonic() + timeout + next_request = 0.0 + while time.monotonic() < deadline: + now = time.monotonic() + if now >= next_request: + device.write(b"p\n") + next_request = now + 1.0 + line = device.readline() + if not line: + continue + text = line.decode("utf-8", errors="replace").strip() + print(text) + capture = parse_capture_line(text) + if capture is not None: + return capture + raise CaptureError("timed out waiting for a Switch 2 wake capture") + + +def write_header(path, capture): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(render_header(capture), encoding="utf-8") + temporary.replace(path) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description=( + "Read the one-shot Pico wake capture and configure the AIO build." + ) + ) + parser.add_argument("--port", help="Pico USB serial port; auto-detected by default") + parser.add_argument( + "--input", + type=Path, + help="read a saved capture log instead of a live Pico", + ) + parser.add_argument("--timeout", type=float, default=90.0) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + try: + if args.timeout <= 0: + raise CaptureError("timeout must be positive") + if args.input is None: + capture = capture_from_pico(args.port, args.timeout) + else: + with args.input.open("r", encoding="utf-8") as stream: + capture = capture_from_stream(stream, args.timeout) + write_header(args.output, capture) + except (CaptureError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print(f"Configured Switch 2 wake: {args.output}") + print("Build and flash it with: python3 build.py --aio") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/prepare_bluepad32.py b/tools/prepare_bluepad32.py index 67a634e..c9e4ef2 100755 --- a/tools/prepare_bluepad32.py +++ b/tools/prepare_bluepad32.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import os import shutil import subprocess import sys @@ -22,10 +23,16 @@ def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path, Path]: ) -def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: +def _run( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: return subprocess.run( command, cwd=cwd, + env=env, capture_output=True, text=True, check=False, @@ -94,6 +101,12 @@ def _copy_and_patch(source_path: Path, patch_path: Path, output_path: Path) -> N result = _run( ["git", "apply", "--no-index", str(patch_path.resolve())], cwd=staging_path, + env={ + **os.environ, + "GIT_CEILING_DIRECTORIES": str( + staging_path.resolve().parent + ), + }, ) if result.returncode != 0: detail = result.stderr.strip() or "git apply failed" diff --git a/tools/switch2_wake_capture/CMakeLists.txt b/tools/switch2_wake_capture/CMakeLists.txt new file mode 100644 index 0000000..43175db --- /dev/null +++ b/tools/switch2_wake_capture/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.13) +set(PICO_BOARD pico2_w CACHE STRING "Target board") +include(${CMAKE_CURRENT_LIST_DIR}/../../pico_sdk_import.cmake) +project(switch2_wake_capture C CXX ASM) +set(CMAKE_C_STANDARD 11) +pico_sdk_init() +add_executable(switch2-wake-capture main.c) +target_include_directories(switch2-wake-capture PRIVATE ${CMAKE_CURRENT_LIST_DIR}) +target_link_libraries(switch2-wake-capture PRIVATE + pico_stdlib + pico_cyw43_arch_none + pico_btstack_cyw43 + pico_btstack_ble +) +pico_enable_stdio_usb(switch2-wake-capture 1) +pico_enable_stdio_uart(switch2-wake-capture 0) +pico_set_program_name(switch2-wake-capture "Switch 2 wake capture") +pico_set_program_version(switch2-wake-capture "1.0") +pico_add_extra_outputs(switch2-wake-capture) diff --git a/tools/switch2_wake_capture/btstack_config.h b/tools/switch2_wake_capture/btstack_config.h new file mode 100644 index 0000000..20dec82 --- /dev/null +++ b/tools/switch2_wake_capture/btstack_config.h @@ -0,0 +1,30 @@ +#ifndef SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H +#define SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H + +#define ENABLE_LE_CENTRAL +#define ENABLE_LE_PERIPHERAL +#define ENABLE_LOG_ERROR +#define ENABLE_PRINTF_HEXDUMP +#define HAVE_EMBEDDED_TIME_MS +#define HAVE_ASSERT +#define HCI_OUTGOING_PRE_BUFFER_SIZE 4 +#define HCI_ACL_PAYLOAD_SIZE (1691 + 4) +#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4 +#define MAX_NR_HCI_CONNECTIONS 1 +#define MAX_NR_GATT_CLIENTS 1 +#define MAX_NR_L2CAP_CHANNELS 2 +#define MAX_NR_L2CAP_SERVICES 2 +#define MAX_NR_SM_LOOKUP_ENTRIES 1 +#define MAX_NR_WHITELIST_ENTRIES 1 +#define MAX_NR_LE_DEVICE_DB_ENTRIES 16 +#define NVM_NUM_DEVICE_DB_ENTRIES 16 +#define MAX_ATT_DB_SIZE 128 +#define MAX_NR_CONTROLLER_ACL_BUFFERS 3 +#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL +#define HCI_HOST_ACL_PACKET_LEN 1024 +#define HCI_HOST_ACL_PACKET_NUM 3 +#define HCI_HOST_SCO_PACKET_LEN 120 +#define HCI_HOST_SCO_PACKET_NUM 3 +#define HCI_RESET_RESEND_TIMEOUT_MS 1000 + +#endif diff --git a/tools/switch2_wake_capture/main.c b/tools/switch2_wake_capture/main.c new file mode 100644 index 0000000..a75d570 --- /dev/null +++ b/tools/switch2_wake_capture/main.c @@ -0,0 +1,152 @@ +#include +#include + +#include "pico/cyw43_arch.h" +#include "pico/stdio_usb.h" +#include "pico/stdlib.h" +#include "btstack.h" +#include "nintendo_wake.h" + +typedef struct { + bd_addr_t advertiser; + uint8_t address_type; + uint8_t event_type; + int8_t rssi; + uint8_t data[31]; +} wake_capture_t; + +static wake_capture_t capture; +static bool captured; +static bool scan_started; +static bool usb_was_connected; +static uint32_t last_print_ms; +static btstack_packet_callback_registration_t registration; +static btstack_timer_source_t output_timer; + +static void to_hex(const uint8_t* bytes, size_t size, char* output) { + static const char digits[] = "0123456789ABCDEF"; + for (size_t index = 0; index < size; ++index) { + output[index * 2] = digits[bytes[index] >> 4]; + output[index * 2 + 1] = digits[bytes[index] & 0x0f]; + } + output[size * 2] = 0; +} + +static void print_capture(void) { + const uint8_t* manufacturer = &capture.data[5]; + char raw_hex[63]; + char payload_hex[49]; + to_hex(capture.data, sizeof(capture.data), raw_hex); + // ESPHome-style payload excludes the two-byte Nintendo company ID. + to_hex(&manufacturer[2], 24, payload_hex); + printf("SWITCH2_WAKE_CAPTURE {\"version\":1,\"advertiser\":" + "\"%02X:%02X:%02X:%02X:%02X:%02X\",\"address_type\":%u," + "\"event_type\":%u,\"rssi\":%d,\"pid\":\"%04X\"," + "\"console\":\"%02X:%02X:%02X:%02X:%02X:%02X\"," + "\"raw_hex\":\"%s\",\"esphome_payload_hex\":\"%s\"}\n", + capture.advertiser[0], capture.advertiser[1], + capture.advertiser[2], capture.advertiser[3], + capture.advertiser[4], capture.advertiser[5], + capture.address_type, capture.event_type, (int)capture.rssi, + (unsigned)(manufacturer[7] | (manufacturer[8] << 8)), + manufacturer[17], manufacturer[16], manufacturer[15], + manufacturer[14], manufacturer[13], manufacturer[12], + raw_hex, payload_hex); +} + +static void output_task(btstack_timer_source_t* timer) { + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + const bool usb_connected = stdio_usb_connected(); + if (usb_connected && !usb_was_connected) { + puts("Switch 2 wake capture ready. Put the console to sleep, then wake it with a paired Joy-Con 2 HOME button."); + puts("Scanning stops automatically after one valid public ADV_IND wake packet."); + if (captured) { + print_capture(); + last_print_ms = now_ms; + } + } + if (usb_connected) { + int key; + while ((key = getchar_timeout_us(0)) != PICO_ERROR_TIMEOUT) { + if ((key == 'p' || key == 'P') && captured) { + print_capture(); + last_print_ms = now_ms; + } + } + // Repeat for a generator attached after capture; scanning remains stopped. + if (captured && now_ms - last_print_ms >= 1000) { + print_capture(); + last_print_ms = now_ms; + } + } + usb_was_connected = usb_connected; + static bool blink; + blink = !blink; + cyw43_arch_gpio_put( + CYW43_WL_GPIO_LED_PIN, captured || (scan_started && blink)); + btstack_run_loop_set_timer(timer, 250); + btstack_run_loop_add_timer(timer); +} + +static void handle_packet(uint8_t packet_type, uint16_t, + uint8_t* packet, uint16_t size) { + if (packet_type != HCI_EVENT_PACKET || size < 2 || captured) { + return; + } + const uint8_t event_type = hci_event_packet_get_type(packet); + if (event_type == BTSTACK_EVENT_STATE && size >= 3 && + btstack_event_state_get_state(packet) == HCI_STATE_WORKING) { + gap_set_scan_params(0, 96, 96, 0); // Passive, continuous, accept all. + gap_set_scan_duplicate_filter(false); + gap_start_scan(); + scan_started = true; + return; + } + if (event_type != GAP_EVENT_ADVERTISING_REPORT || size < 12) { + return; + } + const uint8_t length = + gap_event_advertising_report_get_data_length(packet); + if (length != sizeof(capture.data) || size < 12u + length || + gap_event_advertising_report_get_address_type(packet) != + BD_ADDR_TYPE_LE_PUBLIC || + gap_event_advertising_report_get_advertising_event_type(packet) != 0) { + return; + } + const uint8_t* data = + gap_event_advertising_report_get_data(packet); + if (switch2_wake_manufacturer(data, length) == NULL) { + return; + } + gap_event_advertising_report_get_address(packet, capture.advertiser); + capture.address_type = BD_ADDR_TYPE_LE_PUBLIC; + capture.event_type = 0; + capture.rssi = + (int8_t)gap_event_advertising_report_get_rssi(packet); + memcpy(capture.data, data, sizeof(capture.data)); + captured = true; + scan_started = false; + gap_stop_scan(); + if (stdio_usb_connected()) { + print_capture(); + last_print_ms = btstack_run_loop_get_time_ms(); + } +} + +int main(void) { + stdio_init_all(); + if (cyw43_arch_init() != 0) { + while (true) { + puts("ERROR: CYW43 initialization failed"); + sleep_ms(1000); + } + } + registration.callback = handle_packet; + hci_add_event_handler(®istration); + btstack_run_loop_set_timer_handler(&output_timer, output_task); + btstack_run_loop_set_timer(&output_timer, 250); + btstack_run_loop_add_timer(&output_timer); + hci_power_control(HCI_POWER_ON); + btstack_run_loop_execute(); + return 0; +} diff --git a/tools/switch2_wake_capture/nintendo_wake.h b/tools/switch2_wake_capture/nintendo_wake.h new file mode 100644 index 0000000..8c8ed91 --- /dev/null +++ b/tools/switch2_wake_capture/nintendo_wake.h @@ -0,0 +1,34 @@ +#ifndef SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H +#define SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H + +#include +#include +#include +#include + +static inline const uint8_t* switch2_wake_manufacturer( + const uint8_t* data, size_t size) { + static const uint8_t prefix[] = { + 0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05, + }; + static const uint8_t suffix[] = { + 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + if (size != 31 || memcmp(data, "\x02\x01\x06\x1b\xff", 5) != 0) { + return NULL; + } + const uint8_t* manufacturer = &data[5]; + if (memcmp(manufacturer, prefix, sizeof(prefix)) != 0 || + manufacturer[9] != 0x00 || manufacturer[10] != 0x01 || + manufacturer[11] != 0x81 || + memcmp(&manufacturer[18], suffix, sizeof(suffix)) != 0) { + return NULL; + } + uint8_t target_any = 0; + for (size_t index = 12; index < 18; ++index) { + target_any |= manufacturer[index]; + } + return target_any == 0 ? NULL : manufacturer; +} + +#endif