feat: add Set B profiles and low-latency native haptics

Add schema-6 profiles, atomic catalog migration, shortcuts, Shift, configurable Turbo/Burst, macro recording and playback. Promote qualified 300 MHz native DualSense transport to AIO/XInput defaults with bounded bus transactions, credit batching and generation-safe persistent rumble.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-05 21:56:19 -06:00
commit 3c2d9fa723
75 changed files with 9599 additions and 1819 deletions

View file

@ -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 130 Hz and 199% duty, shared defaults/sparse overrides, and 1255 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. |
@ -613,22 +613,21 @@ Completion evidence:
- the browser editor renders and selects all eight slots
- Pico 2 W hardware read and activated profile 8, then restored profile 1
### Candidate profile and Profile Studio enhancements — Set A complete
### Profile and Profile Studio enhancements — Sets A and B complete
The live playtest, eight-slot catalog, controller-native labels, automatic
active-profile synchronization, and all Set A usability work are complete.
Set B and Set C remain candidates; their order reflects user value and
dependency, not a commitment to implement every item.
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 | Store a short profile name; rename, duplicate to another slot or controller, expose JSON import/export in the browser, and reset one section without replacing the entire profile. Names use catalog metadata records, so the 256-byte input profile remains stable. | Implemented with atomic metadata records and existing profile transactions; no report-path cost. |
| 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 | Direct profile shortcuts | Assign a modifier plus face button or D-pad direction to select profiles 18 directly while retaining the existing cycle chord. Publish feedback only after the activation record commits. | Profile schema/runtime change; chord precedence and consumption must remain deterministic. |
| B2 | One Shift layer per profile | While a configured modifier is held or toggled, apply one alternate button map over the base profile. Start with buttons only; do not layer analog transforms, Turbo, or macros initially. | Profile schema change and explicit precedence between base mapping, Shift, Turbo, and macros. |
| B3 | Macro authoring tools | Record live controller input, insert, duplicate, remove, and drag-reorder steps; preview playback; show time and sparse-byte cost on every edit. Later add once, while-held, toggle, and bounded-repeat playback modes. | Editing tools fit the current format; new playback modes require schema/runtime work and must remain within the shared 16-step/136-byte budget. |
| B4 | Configurable Turbo and finite Burst | Add a bounded repeat rate, duty cycle, and finite Burst count while retaining hold Turbo and Auto Burst. Support shared defaults with optional per-button overrides rather than duplicating full settings sixteen times. | Profile schema/runtime change; scheduling must preserve the existing deterministic phase accumulator. |
| B1 — Complete | Direct profile shortcuts | Modifier plus unique face/D-pad selectors address profiles 18, 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. |
@ -685,6 +684,25 @@ Research basis:
- 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
Goal: preserve Nintendo's left/right, low/high-band commands on controllers

View file

@ -26,12 +26,39 @@ 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 the host-triggered DualSense PCM transport experiment" OFF)
"Enable native DualSense haptics and transport diagnostics" ${SWITCH_PICO_NATIVE_DEFAULT})
option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM
"Execute the haptics experiment hot path from SRAM" ON)
"Execute the native haptics hot path from SRAM" ON)
option(SWITCH_PICO_HD_RUMBLE
"Auto-arm native Switch HD rumble for a DualSense in slot zero" OFF)
"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()
@ -59,6 +86,29 @@ if(SWITCH_PICO_HAPTICS_EXPERIMENT
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)
@ -67,6 +117,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")
@ -125,8 +234,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
@ -156,9 +267,20 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
SWITCH_PICO_HID_INSTANCE_COUNT=4
SWITCH_PICO_USB_OUTPUT_MODES=1
PICO_FLASH_ASSUME_CORE1_SAFE=0
PICO_STACK_SIZE=4096
PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$<IF:$<BOOL:${SWITCH_PICO_HAPTICS_EXPERIMENT}>,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
@ -212,7 +334,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)

View file

@ -2,7 +2,7 @@
## Goal and evidence
Native Nintendo HD-rumble decoding is connected to the proven Bluetooth PCM transport through opt-in gameplay mode. A dedicated HD image auto-arms one DualSense in slot 0. Normal builds retain compatibility rumble, and the deterministic transport fixture remains available. Console gameplay was user-tested; precise actuator-onset latency and perceptual equivalence to Nintendo hardware are not claimed.
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 12 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.
@ -27,13 +27,13 @@ Sources:
## Implementation contract
1. Build-only opt-in `SWITCH_PICO_HAPTICS_EXPERIMENT`; separate build directory/artifacts. Preserve wake identity, pairing storage, USB modes and ordinary firmware artifacts. Use stock clock/voltage. Experimental builds use the controller's advertised outgoing ACL capacity and one-packet receive batches with explicit rescheduling; normal builds retain the three-credit cap and sixteen-packet batches. Incoming flow control and all FIFO sizes remain unchanged.
2. One selected connected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. The fixture requires explicit start. `SWITCH_PICO_HD_RUMBLE=ON` additionally auto-arms continuous gameplay for a DualSense in slot 0; it emits silence until host commands or local confirmation arrive. Other controllers retain compatibility output.
3. 142-byte report 0x32 plus A2 transaction = 143-byte L2CAP SDU. The first report selects native mode using sized state block 0x90, length 63, with rumble-selection bits and other write flags clear; it carries one silent 64-byte haptic block (0x92). Subsequent reports use compact audio control `{0x91,3,0x62,16,counter}` and **two** 64-byte haptic blocks: descriptor 0xd2, length 64, followed by 128 sample bytes. Thus 0xd2 is valid here, unlike the original spike's single-block mismatch. Deterministic padding and Bluetooth CRC. No speaker, microphone, USB audio endpoint, Opus or resampler.
4. Steady-state 64 stereo frames per report at 3 kHz, 46.875 reports/s. Absolute microsecond/sample deadlines use integer rational arithmetic; 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 are separate. Arm flags before requests and handle synchronous callbacks without recursive stream generation.
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}`. Qualified gameplay carries one 64-byte block (32 stereo frames, descriptor 0x92); the fixture and unoptimized builds carry two blocks (64 frames, descriptor 0xd2). 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` use existing USB management framing. Ordinary builds report unsupported. General runtime diagnostics remain unchanged. The experiment response is schema 3; transport profiling remains schema 2. Update the host and experimental firmware together.
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
@ -44,21 +44,25 @@ Build using the provisioned Pico SDK/toolchain environment:
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_HAPTICS_EXPERIMENT_RAM=ON -DSWITCH_PICO_LOG=OFF
cmake --build build-hd-rumble
```
Load `build-hd-rumble/switch-pico.elf` or `.uf2`. The HD option implies the experimental transport and auto-arms slot 0 on connection. On the Switch, reconnect the DualSense with PS. Manual PC arming is `uv run switch-pico-config haptics-experiment gameplay --slot 0`; it does not persist across power cycles. `stop` disarms the native stream, not ordinary compatibility rumble. The standard `build.py` commands explicitly disable both HD and experiment options.
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 command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3 at 3 kHz. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each side expires 50 ms after its last update, matching the existing conservative Switch-rumble timeout policy.
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.
One report interval (21.333 ms) of causal lookback preserves commands received between Bluetooth sends without predicting future input. Fixed 16-entry cross-core and synthesis command histories contain decoded states, not PCM. Overflow is counted; stale sample intervals are skipped, not replayed as a backlog. `host_updates` and `dropped_updates` expose command ingestion and loss.
One report interval of causal lookback is 10.667 ms in the qualified 32-frame path and 21.333 ms in the 64-frame path. 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 drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 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 output weights are capped at 65535, preventing coefficient overflow and waveform clipping. Zero remains zero. Frequencies, timing, and local-confirmation strength are unchanged. This response replaced the initial 1.5x gain and a low-band-only experiment after user comparison.
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.
The gameplay stream continues with silence while idle. It is stopped on disconnect, explicit stop, or a stalled send-permission watchdog; it yields to compatibility behavior in XInput mode. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup.
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
@ -68,12 +72,13 @@ Use the repository's provisioned Pico SDK/toolchain environment. These CMake com
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 experimental artifacts are `build-haptics/switch-pico.elf` and `.uf2`. For the same-clock flash comparison, use another build directory and `-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=OFF`. The standard `build.py` entry points explicitly disable the experiment, including when reusing an old CMake cache.
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:
@ -88,11 +93,11 @@ For the deterministic fixture only, the first tone is intentionally scheduled 1.
## Protocol
USB vendor management operation 0x40: OUT two-byte payload `{action, slot}` (0=stop, 1=finite fixture, 2=continuous gameplay; slot 0..3); existing request envelope. IN is schema 3, 84 bytes. The first 72 bytes retain the previous layout; mode and gameplay counters follow.
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); bytes 7375: zero reserved bytes.
- 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.
@ -100,7 +105,7 @@ USB vendor management operation 0x40: OUT two-byte payload `{action, slot}` (0=s
### Transport timing probe
Operation `0x41` is IN-only. Schema 2 contains 32 little-endian u32 fields in the declaration order of `HapticsTransportProbe` in `input/haptics_transport_probe.h` (128 bytes). The final two fields expose ACL packet size/count from the controller's raw HCI Read Buffer Size response, before the SDK's software cap. Measurements reset for each run, retain their final values, and correlate by run ID and connection generation. Ordinary firmware reports this operation as unsupported.
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
@ -122,11 +127,13 @@ Only after timing evidence, consider selected L2CAP/HCI/CYW43 and USB hot call c
## Optional overclocking
Overclocking is permitted, but the first transport run stays at the Pico 2 W's stock 150 MHz and stock regulator setting. Compare flash versus SRAM at the same clock first; then compare clocks with identical packet contents and scheduling. An overclock cannot repair a malformed block descriptor or a lost synchronous callback, and it does not increase Bluetooth's negotiated air rate or controller ACL credits.
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 [ClockworkPi thread](https://forum.clockworkpi.com/t/overclocking-pico-2/18226) reports successful 300 MHz and higher configurations, alongside warnings about peripheral limits. [Pimoroni's measurements](https://learn.pimoroni.com/article/overclocking-the-pico-2) found 312 MHz at 1.1 V on one sample under their initial benchmark. Those are experimental observations, not a stability guarantee for this board running both cores, USB and CYW43 simultaneously. Do not copy the article's extreme voltages, voltage-limit removal, or dry-ice setup.
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.
For a subsequent opt-in clock experiment, record requested/measured system clock, regulator voltage, flash divider, CYW43 PIO divider, temperature/environment, and the same packet diagnostics. Preserve the 48 MHz USB clock; adjust flash and CYW43 dividers before raising the system clock so neither bus is inadvertently overclocked. Validate USB enumeration/control transfers, sustained controller input, reconnect, flash-safe persistence and packet timing—not just a CPU benchmark. Keep a stock UF2 and BOOTSEL recovery path. No clock or voltage change is applied by this transport experiment.
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
@ -229,3 +236,54 @@ The user described the 1.5x mix as thin/hollow and lacking body. A 2x low / 1.5x
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.

View file

@ -243,7 +243,7 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `
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, so naming does not change the 256-byte profile format. The
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
@ -259,9 +259,10 @@ rumble/light pulse only to the selected live controller.
`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
@ -275,7 +276,14 @@ The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run
- On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number.
- Each controller identity and each of the four active USB slots remain isolated.
Profile input processing is deterministic: physical buttons and analog triggers are mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Each profile has four independently triggered macros. A macro supports up to eight state steps, while all four share a fixed sixteen-step decoded pool and a 136-byte sparse wire stream. A wait-only step uses 3 bytes, a button-only step 5 bytes, and a full-precision all-field step 17 bytes; therefore the stream holds 16 typical button steps or exactly 8 all-field steps. Stick coordinates remain signed 16-bit values. Macro triggers can be multi-control chords, partial chords remain ordinary input, and each macro has its own cancel control.
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 18. 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; 130 Hz, 199% duty, and 1255 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 (1255 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
@ -315,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.
Qualified optimized gameplay uses **32 stereo frames at 3 kHz** per Bluetooth report (93.75 reports/s), with 10.667 ms causal lookback. Unoptimized builds and the deterministic fixture retain 64-frame packets. Native streaming continues silence while idle to avoid mode churn. This is not a measured physical actuator-onset bound; details and the accepted reference pattern are in [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
@ -347,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.
@ -379,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:
@ -712,33 +728,37 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
| Resource | Used or reserved | Device capacity |
|---|---:|---:|
| Executable flash image | 695,592 bytes | 4 MiB |
| Executable flash image | 747,024 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 |
| Linked SRAM | 99,040 bytes | 520 KiB |
| Allocated/reserved SRAM, including heap and stacks | 131,144 bytes | 520 KiB |
The executable plus persistent reservations consume 978,216 bytes (23.32%)
of flash, leaving 3,216,088 bytes (3.07 MiB). Linked SRAM consumes 18.60%,
leaving 433,440 bytes of link-time headroom.
The executable plus persistent reservations consume 1,029,648 bytes of flash,
leaving 3,164,656 bytes. Allocated SRAM sections leave 401,336 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. Each independently published
record contains one identity/profile key, generation, schema, length, and CRC.
The compact in-memory index is 1,556 bytes; only the fallback and active
profile for each observed identity are decoded and published. Including the
active cache, selected-profile buffer, transaction state, profile runtime
contexts, and catalog index, the profile subsystem uses approximately 13 KiB
of SRAM instead of retaining every profile in decoded form.
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 58 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 therefore leave the previous valid record
available. On first boot after upgrading, the legacy four-profile banks are
read from their old flash addresses and copied into the new catalog before
the legacy region can be erased.
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 15 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

74
build.py Normal file → Executable file
View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Build and flash the project with optional grip color overrides."""
import argparse
import os
import random
@ -22,18 +23,10 @@ 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"
)
WAKE_CAPTURE_FIRMWARE_ELF_PATH = (
FIRMWARE_DIR / "switch-pico-wake-capture.elf"
)
WAKE_CAPTURE_FIRMWARE_UF2_PATH = (
FIRMWARE_DIR / "switch-pico-wake-capture.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()
@ -53,9 +46,7 @@ CMAKE_CACHE_PATHS = tuple(
WAKE_CAPTURE_BUILD_DIR,
)
)
TOOLCHAIN_COMPILER = (
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
)
TOOLCHAIN_COMPILER = "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
class BuildEnvironmentError(RuntimeError):
@ -266,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.",
@ -313,14 +303,17 @@ def parse_args():
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")
@ -352,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)
@ -361,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
@ -374,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,
@ -400,9 +400,19 @@ def build(
"-DPICO_BOARD=pico",
"-DSWITCH_PICO_INPUT_BACKEND=UART",
]
# Experimental images are built explicitly in their own CMake directory.
definitions.append("-DSWITCH_PICO_HAPTICS_EXPERIMENT=OFF")
definitions.append("-DSWITCH_PICO_HD_RUMBLE=OFF")
# 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",
f"-DSWITCH_PICO_CYW43_PACKET_READ={native}",
f"-DSWITCH_PICO_HCI_CREDIT_BATCH={native}",
]
)
run_cmd(
[
@ -416,12 +426,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)
@ -447,14 +457,10 @@ def build_wake_capture():
]
)
run_cmd(["cmake", "--build", str(WAKE_CAPTURE_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: Wake capture build did not produce: {missing}\n"
)
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)
@ -478,6 +484,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:
@ -538,5 +545,6 @@ def main():
allow_elf_override=not args.aio and not args.adapter_feasibility,
)
if __name__ == "__main__":
main()

View file

@ -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;

View file

@ -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;

View file

@ -104,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;
@ -166,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
@ -203,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
@ -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);
}
@ -833,6 +845,8 @@ 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<uint8_t>(&slot - g_slots),
slot.connection_generation, time_us_32());
slot.motion_enabled = kDefaultMotionEnabled;
slot.pre_hotkey_button_mask = 0;
slot.feedback_pending = false;
@ -1266,6 +1280,47 @@ void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms,
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();
@ -1285,7 +1340,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
const bool xinput_host_mode =
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (xinput_host_mode) haptics_experiment_suspend_gameplay();
if (xinput_host_mode) seed_native_host_rumble();
haptics_experiment_poll();
#endif
@ -1304,16 +1359,6 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
#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.retained_host_rumble_valid &&
(!xinput_host_mode ||
slot.retained_host_rumble.duration_ms !=
@ -1325,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) {
@ -1431,11 +1486,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
static_cast<int32_t>(
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;
@ -1445,11 +1506,10 @@ 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) &&
(envelope.rumble.hd.actuators[0].sample_count != 0 ||
envelope.rumble.hd.actuators[1].sample_count != 0)) {
// Full timestamped frames already went directly from the USB
// producer to the native timeline, even during local feedback.
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
@ -1666,9 +1726,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
haptics_experiment_attach(
static_cast<uint8_t>(slot_index), lighting_generation, device);
#ifdef SWITCH_PICO_HD_RUMBLE
if (slot_index == 0 &&
host_rumble_duration_ms() != kXInputHostRumbleDurationMs &&
connection_identity.vendor_id == 0x054c &&
if (connection_identity.vendor_id == 0x054c &&
(connection_identity.product_id == 0x0ce6 ||
connection_identity.product_id == 0x0df2)) {
haptics_experiment_request(2, static_cast<uint8_t>(slot_index));
@ -1791,6 +1849,57 @@ extern "C" bool uni_platform_on_l2cap_can_send_now(
}
#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;
@ -1854,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() {
@ -2068,7 +2178,11 @@ void bluepad32_input_backend_queue_rumble(
#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(
@ -2084,8 +2198,14 @@ void bluepad32_input_backend_queue_rumble(
critical_section_exit(&g_state_lock);
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (native_candidate) {
haptics_experiment_submit(
slot_index, native_generation, received_us, rumble.hd);
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
}

View file

@ -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;
@ -123,3 +124,25 @@ 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);

View file

@ -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<uint16_t>(
(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<int32_t>(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<int16_t>(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<uint32_t>(value) + quantum / 2u) / quantum) * quantum;
return static_cast<uint16_t>(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;
}

View file

@ -0,0 +1,73 @@
#pragma once
#include <stdint.h>
#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]{};
};

View file

@ -17,7 +17,12 @@
namespace {
constexpr uint32_t kPacketNumeratorUs = 64000;
#if defined(SWITCH_PICO_CYW43_PACKET_READ) && defined(SWITCH_PICO_HCI_CREDIT_BATCH) && \
defined(SWITCH_PICO_SYS_CLOCK_MHZ) && SWITCH_PICO_SYS_CLOCK_MHZ >= 300
constexpr uint32_t kGameplayFrames = 32;
#else
constexpr uint32_t kGameplayFrames = 64;
#endif
constexpr uint32_t kPacketDenominator = 3;
constexpr uint32_t kPackets = 288;
constexpr uint32_t kPrimingPackets = 48;
@ -61,6 +66,9 @@ struct Command {
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;
@ -123,6 +131,10 @@ 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) {
@ -136,10 +148,38 @@ void drain_host_updates() {
g_host_head = (g_host_head + 1) % kHostQueueCapacity;
--g_host_count;
critical_section_exit(&g_lock);
g_synth.push(update.frame, update.received_us);
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;
@ -149,7 +189,7 @@ void update_max(uint32_t* value, uint32_t candidate) {
uint64_t packet_due(uint32_t packet) {
// Round each absolute rational deadline up, never its relative interval.
return g_start_us +
(static_cast<uint64_t>(packet) * kPacketNumeratorUs +
(static_cast<uint64_t>(packet) * packet_numerator_us() +
kPacketDenominator - 1) / kPacketDenominator;
}
@ -337,7 +377,7 @@ void request_send() {
} else {
const uint32_t current = static_cast<uint32_t>(
((now_us - g_start_us) * kPacketDenominator) /
kPacketNumeratorUs);
packet_numerator_us());
g_diagnostics.skipped_packets += current + 1 - g_next_packet;
g_next_packet = current + 1;
if (gameplay() || g_next_packet < kPackets) {
@ -347,7 +387,7 @@ void request_send() {
}
} else if (g_phase == Phase::kDrain) {
schedule_timer(&g_cadence_timer, &g_cadence_armed,
time_us_64() + 21334);
time_us_64() + (packet_numerator_us() + 2) / 3);
}
}
publish();
@ -373,25 +413,24 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet,
sample_offset = 70;
frames = 32; // Initial mode handoff occupies the first silent interval.
} else {
// Compact 0x11: mic disabled, buffer length and sample-block counter.
// Two 64-byte blocks fit in 0x32 with these reference-supported fields.
// 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<uint8_t>(g_diagnostics.sent_packets * 2);
report[8] = 0xd2; // Double-sized 0x12: genuinely two 64-byte blocks.
report[7] = static_cast<uint8_t>(g_diagnostics.sent_packets * (frames / 32));
report[8] = frames == 64 ? 0xd2 : 0x92;
report[9] = 64;
sample_offset = 10;
frames = 64;
}
bool tone = false;
if (gameplay() && !silence) {
drain_host_updates();
// One report of causal lookback preserves every 8 ms USB command,
// including substeps arriving between 21.333 ms Bluetooth sends.
// including substeps arriving between Bluetooth sends.
if (packet != 0 && g_diagnostics.sent_packets != 0) {
const uint64_t first_sample = uint64_t{packet - 1} * 64;
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 ||
@ -656,30 +695,17 @@ bool haptics_experiment_request(uint8_t action, uint8_t slot) {
bool haptics_experiment_submit(uint8_t slot, uint32_t generation,
uint64_t received_us,
const SwitchHapticsFrame& frame) {
if (!g_prepared || slot >= 4 ||
frame.actuators[0].sample_count > 3 || frame.actuators[1].sample_count > 3 ||
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;
}
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] = {received_us, frame};
++g_host_count;
if (g_host_updates != UINT32_MAX) ++g_host_updates;
}
critical_section_exit(&g_lock);
return accepted;
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) {
@ -691,6 +717,7 @@ void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) {
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<uint32_t>(time_us_64());
output->elapsed_us = now_us - output->start_us;
@ -793,16 +820,11 @@ 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) {
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;
}
void haptics_experiment_suspend_gameplay() {
critical_section_enter_blocking(&g_lock);
const uint8_t slot = g_busy && g_snapshot.mode == 1 ? g_snapshot.slot : kNoSlot;
critical_section_exit(&g_lock);
if (slot != kNoSlot) haptics_experiment_request(0, slot);
}
bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device,
uint16_t cid) {
@ -859,7 +881,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device,
uint64_t due_us = now_us;
if (!stopping) {
const uint32_t current = static_cast<uint32_t>(
((now_us - g_start_us) * kPacketDenominator) / kPacketNumeratorUs);
((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);
@ -912,6 +934,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device,
++g_diagnostics.sent_packets;
g_diagnostics.last_sent_us = static_cast<uint32_t>(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<uint32_t>(submit_us);
@ -925,7 +948,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device,
restore_compatibility(g_finish_state);
} else {
schedule_timer(&g_cadence_timer, &g_cadence_armed,
time_us_64() + 21334);
time_us_64() + (packet_numerator_us() + 2) / 3);
}
} else {
++g_next_packet;

View file

@ -41,6 +41,8 @@ struct HapticsExperimentDiagnostics {
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.
@ -51,6 +53,10 @@ void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output);
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,
@ -61,6 +67,5 @@ 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);
void haptics_experiment_suspend_gameplay();
bool haptics_experiment_on_can_send_now(uni_hid_device_t* device,
uint16_t cid);

View file

@ -3,6 +3,9 @@
#include <btstack.h>
#include <pico/critical_section.h>
#include <pico/stdlib.h>
#if PICO_ON_DEVICE
#include "platform/pico/system_clock.h"
#endif
extern "C" {
#include <cyw43.h>
@ -188,6 +191,19 @@ void haptics_transport_probe_snapshot(HapticsTransportProbe* output) {
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,
@ -254,6 +270,9 @@ void haptics_transport_probe_send(uint32_t duration_us, uint32_t return_us,
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;
@ -262,6 +281,10 @@ extern "C" int __wrap_cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length)
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);
}
}

View file

@ -2,7 +2,7 @@
#include <stdint.h>
// Operation 0x41, schema 2: 32 little-endian u32 fields in declaration order.
// 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;
@ -37,6 +37,18 @@ struct HapticsTransportProbe {
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.

View file

@ -186,6 +186,28 @@ bool SwitchHdRumbleSynth::push(const SwitchHapticsFrame& frame,
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<uint32_t>(low_magnitude) * 32768u + 127u) / 255u;
command.frame.actuators[1].samples[0].high_amplitude_q15 =
(static_cast<uint32_t>(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) {
@ -241,6 +263,7 @@ void SwitchHdRumbleSynth::apply(const Command& command) {
sides_[side].frame = command.frame.actuators[side];
sides_[side].start = command.sample;
sides_[side].expires = command.sample + kWatchdogSamples;
sides_[side].persistent = command.persistent;
}
}
}
@ -281,7 +304,7 @@ uint64_t SwitchHdRumbleSynth::next_boundary(uint64_t limit) const {
for (unsigned index = 1; index < side.frame.sample_count; ++index) {
consider(side.start + index * spacing);
}
consider(side.expires);
if (!side.persistent) consider(side.expires);
}
consider(feedback_expires_);
return limit;
@ -341,7 +364,8 @@ void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames,
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 = due(sides_[side].expires, cursor_);
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]);

View file

@ -14,6 +14,7 @@ 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
@ -22,6 +23,14 @@ public:
// 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.
@ -32,7 +41,7 @@ public:
// 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 must be chronological independently of push().
// 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);
@ -48,10 +57,12 @@ private:
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;
};

View file

@ -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();

View file

@ -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 <stdbool.h>
#include <stdint.h>
#include <string.h>
#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;
}

View file

@ -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<int32_t>(
(uint64_t{sum} * 3300000u + 16384u) / 32768u);
g_status.temperature_millicelsius =
27000 - static_cast<int32_t>((int64_t{microvolts - 706000} * 1000) / 1721);
return g_status;
}

View file

@ -0,0 +1,18 @@
#pragma once
#include <stdint.h>
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();

View file

@ -57,6 +57,14 @@ bool valid_control_output(uint8_t output) {
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) {
@ -277,6 +285,40 @@ bool controller_profile_validate(const ControllerProfile& profile) {
return false;
}
}
if (!valid_control_output(profile.shortcuts.modifier) ||
!valid_control_output(profile.shift.modifier) ||
static_cast<uint8_t>(profile.shift.mode) >
static_cast<uint8_t>(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<uint16_t>(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) {
@ -318,6 +360,9 @@ bool controller_profile_validate(const ControllerProfile& profile) {
profile.macros[macro_index];
if ((macro.trigger_mask & ~kLogicalControlMask) != 0 ||
!valid_control_output(macro.cancel_control) ||
static_cast<uint8_t>(macro.mode) >
static_cast<uint8_t>(ControllerProfileMacroMode::kRepeat) ||
macro.repeat_count == 0 ||
macro.first_step != expected_first_step ||
macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
macro.step_count >
@ -331,6 +376,7 @@ bool controller_profile_validate(const ControllerProfile& profile) {
}
}
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];
@ -338,6 +384,12 @@ bool controller_profile_validate(const ControllerProfile& profile) {
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<uint8_t>(expected_first_step + macro.step_count);
@ -359,7 +411,7 @@ bool controller_profile_validate(const ControllerProfile& profile) {
}
for (ControllerProfileTurboMode mode : profile.turbo_modes) {
if (static_cast<uint8_t>(mode) >
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
static_cast<uint8_t>(ControllerProfileTurboMode::kBurst)) {
return false;
}
}
@ -449,6 +501,30 @@ bool controller_profile_encode(const ControllerProfile& profile,
}
descriptor[5] =
static_cast<uint8_t>(stream_offset - macro_start);
output[336 + macro_index * 2] = static_cast<uint8_t>(macro.mode);
output[337 + macro_index * 2] = macro.repeat_count;
}
output[256] = profile.shortcuts.modifier;
memcpy(&output[257], profile.shortcuts.selectors,
CONTROLLER_PROFILE_COUNT);
output[265] = static_cast<uint8_t>(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;
}
@ -456,13 +532,19 @@ bool controller_profile_encode(const ControllerProfile& profile,
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]);
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 ||
profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
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)) {
return false;
@ -472,7 +554,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
const bool has_action_controls =
schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION;
const bool sparse_macros =
schema_version == CONTROLLER_PROFILE_SCHEMA_VERSION;
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) ||
@ -575,6 +657,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
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<ControllerProfileMacroMode>(
input[336 + macro_index * 2]);
macro.repeat_count = input[337 + macro_index * 2];
}
size_t consumed = 0;
for (uint8_t step_index = 0;
@ -666,12 +753,48 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
macro.first_step = 0;
macro.step_count = static_cast<uint8_t>(legacy_step_count - 1u);
profile.macro_step_count = macro.step_count;
for (uint8_t index = 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<ControllerProfileTurboMode>(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<ControllerProfileShiftMode>(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<uint8_t>(mode) >
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
return false;
}
}
}
if (!controller_profile_validate(profile)) {
return false;
}
@ -826,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;
@ -844,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;
@ -863,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))) {
@ -875,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;
}
@ -900,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;

View file

@ -9,8 +9,10 @@ constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2;
constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3;
constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4;
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 5;
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256;
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_LOGICAL_CONTROL_COUNT = 18;
@ -27,12 +29,26 @@ 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;
@ -42,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 == 35104,
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,
@ -78,6 +96,39 @@ enum class ControllerProfileTurboMode : uint8_t {
kOff = 0,
kTurbo = 1,
kAutoBurst = 2,
kBurst = 3,
};
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;
};
@ -127,6 +178,8 @@ struct ControllerProfileMacro {
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 {
@ -145,6 +198,12 @@ struct ControllerProfile {
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 {

View file

@ -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,13 +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 motion_toggle_chord_held = false;
bool switching_activation_requested = false;
uint32_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;
@ -63,6 +64,20 @@ uint32_t effective_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;
@ -85,7 +100,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
const ControllerIdentity& identity,
uint32_t connection_generation,
uint32_t observed_database_generation,
const ControllerState& current_input) {
const Bluepad32SlotSnapshot& input_snapshot) {
ProfileServiceActiveProfileSnapshot snapshot{};
profile_service_active_profile_snapshot(identity, &snapshot);
@ -99,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;
@ -115,6 +131,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
}
context->active = true;
context->connection_seen = true;
context->connection_generation = connection_generation;
context->identity = identity;
context->database_generation = snapshot.valid
@ -125,13 +142,17 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
const uint32_t current_input_control_mask =
controller_profile_extract_control_mask(
current_input, context->profile);
input_snapshot.state, context->profile);
controller_synthetic_input_cancel(
&context->synthetic, current_input_control_mask);
const uint32_t motion_chord =
effective_motion_toggle_chord(context->profile);
context->motion_toggle_chord_held =
(current_input_control_mask & motion_chord) == motion_chord;
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<uint8_t>(
@ -147,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<uint8_t>(
(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 = {
@ -176,6 +192,7 @@ ControllerProfileRuntimeContext* update_context(
if (!snapshot.active) {
if (context.active) {
clear_context(&context);
context.connection_seen = true;
}
return nullptr;
}
@ -188,74 +205,107 @@ ControllerProfileRuntimeContext* update_context(
context.database_generation != database_generation) {
refresh_profile(
&context, snapshot.identity, snapshot.connection_generation,
database_generation, snapshot.state);
database_generation, snapshot);
}
return &context;
}
void process_profile_switching(
ControllerProfileRuntimeContext* context,
const ControllerIdentity& identity,
uint32_t switching_input_control_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_control_mask &
context->held_switching_chord) ==
context->held_switching_chord) {
controller_profile_remove_control_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 uint32_t chord =
effective_switching_chord(context->profile);
const bool chord_fully_held =
(switching_input_control_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<uint8_t>(
(context->active_profile_index + 1u) %
CONTROLLER_PROFILE_COUNT);
context->switching_transaction_id =
next_activation_transaction_id();
controller_profile_remove_control_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<uint8_t>(
(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() {
@ -276,50 +326,32 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
if (context == nullptr) {
return g_neutral_output;
}
ControllerState consumed_input = snapshot.state;
const uint32_t reset_generation =
configuration_service_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;
const uint32_t motion_toggle_chord =
effective_motion_toggle_chord(context->profile);
const bool motion_toggle_chord_held =
(input_control_mask & motion_toggle_chord) ==
motion_toggle_chord;
if (motion_toggle_chord_held) {
if (!context->motion_toggle_chord_held) {
bluepad32_input_backend_toggle_motion(
slot, snapshot.connection_generation);
}
controller_profile_remove_control_mask(
motion_toggle_chord, &consumed_input);
}
context->motion_toggle_chord_held =
motion_toggle_chord_held;
process_profile_switching(
context, snapshot.identity, input_control_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) {
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_control_mask(
consumed_input, context->profile));
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(

View file

@ -50,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,

View file

@ -334,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<uint16_t>(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<uint16_t>(1u << output);
}
@ -350,13 +353,17 @@ 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);
uint16_t output_button_mask =
controller_profile_map_button_mask(input_button_mask, profile);
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,
@ -370,7 +377,7 @@ ControllerProfileTransformResult controller_profile_transform(
static_cast<uint16_t>(1u << input_button)) == 0) {
continue;
}
const uint8_t output = profile.button_map[input_button];
const uint8_t output = selected_map[input_button];
if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) {
output_triggers[

View file

@ -22,10 +22,12 @@ void controller_profile_remove_control_mask(
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);

View file

@ -2,8 +2,7 @@
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<uint16_t>(1u << button);
@ -22,39 +21,48 @@ 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<uint64_t>(binding->phase_units) +
static_cast<uint64_t>(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<uint16_t>(
total_units -
transition_count * kTurboPhaseUnitsPerTransition);
static_cast<uint64_t>(elapsed_ms) * settings.rate_hz;
const uint64_t cycles = total_units / kTurboCycleUnits;
binding->phase_units =
static_cast<uint16_t>(total_units % kTurboCycleUnits);
binding->phase_on = binding->phase_units <
static_cast<uint32_t>(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<uint8_t>(
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,
@ -65,46 +73,71 @@ bool start_macro(ControllerSyntheticInputContext* context,
}
const ControllerProfileMacro& macro = profile.macros[macro_index];
if (macro.step_count == 0 ||
macro.first_step >= profile.macro_step_count) {
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[macro.first_step].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_index >= CONTROLLER_PROFILE_MACRO_COUNT) {
stop_macro(context);
return;
}
const ControllerProfileMacro& macro =
profile.macros[context->macro_index];
if (context->macro_step_index >= macro.step_count) {
stop_macro(context);
return;
}
if (!deadline_reached(now_ms, context->macro_deadline_ms)) {
return;
}
const uint8_t next_index =
static_cast<uint8_t>(context->macro_step_index + 1u);
if (next_index >= macro.step_count) {
stop_macro(context);
return;
}
context->macro_step_index = next_index;
context->macro_deadline_ms +=
profile.macro_steps[macro.first_step + 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<uint64_t>(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<uint8_t>(
context->macro_cycles_remaining - cycles);
}
context->macro_cycle_elapsed_ms =
static_cast<uint32_t>(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,
@ -143,32 +176,62 @@ void controller_synthetic_input_cancel(
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 uint32_t input_control_mask =
const uint32_t raw_control_mask =
controller_profile_extract_control_mask(input, profile);
uint32_t rising_control_mask =
input_control_mask & ~context->previous_input_control_mask;
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;
uint32_t consumed_controls = 0;
for (const ControllerProfileMacro& macro : profile.macros) {
if (is_bound_control(macro.cancel_control) &&
(input_control_mask & control_bit(macro.cancel_control)) != 0) {
cancel_pressed = true;
consumed_controls |= control_bit(macro.cancel_control);
break;
}
}
if (cancel_pressed) {
controller_synthetic_input_cancel(context, input_control_mask);
controller_synthetic_input_cancel(context, raw_control_mask);
rising_control_mask = 0;
}
bool macro_started = false;
if (!cancel_pressed && !context->macro_active) {
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 =
@ -208,6 +271,10 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
}
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);
@ -223,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;
@ -237,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;
@ -253,7 +330,9 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
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_index < CONTROLLER_PROFILE_MACRO_COUNT) {
const ControllerProfileMacro& macro =
@ -268,6 +347,6 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
}
}
context->previous_input_control_mask = input_control_mask;
context->previous_input_control_mask = raw_control_mask;
return result;
}

View file

@ -8,6 +8,7 @@ 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;
};
@ -15,21 +16,27 @@ struct ControllerSyntheticInputContext {
bool macro_active = false;
uint8_t macro_index = 0;
uint8_t macro_step_index = 0;
uint32_t macro_deadline_ms = 0;
uint32_t 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,
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);

View file

@ -8,8 +8,12 @@ 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 kCatalogVersion = 1;
constexpr size_t kRecordPayloadOffset = PROFILE_STORAGE_PAGE_SIZE;
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;
@ -100,7 +104,11 @@ bool ProfileStorage::initialize(const ProfileStorageIo &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 &&
@ -110,45 +118,65 @@ bool ProfileStorage::initialize(const ProfileStorageIo &io) {
return false;
}
ProfileStorageIdentityIndex
candidate[PROFILE_STORAGE_ARENA_COUNT]
[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{};
uint8_t counts[PROFILE_STORAGE_ARENA_COUNT]{};
uint32_t epochs[PROFILE_STORAGE_ARENA_COUNT]{};
uint32_t generations[PROFILE_STORAGE_ARENA_COUNT]{};
uint32_t payload_crcs[PROFILE_STORAGE_ARENA_COUNT]{};
size_t offsets[PROFILE_STORAGE_ARENA_COUNT]{};
bool valid[PROFILE_STORAGE_ARENA_COUNT]{};
for (uint8_t arena = 0; arena < PROFILE_STORAGE_ARENA_COUNT; ++arena) {
valid[arena] = scan_arena(arena, &epochs[arena], &generations[arena],
&payload_crcs[arena], &offsets[arena],
candidate[arena], &counts[arena]);
}
uint8_t selected = 0;
if (valid[1] && (!valid[0] || generation_is_newer(epochs[1], epochs[0]))) {
selected = 1;
}
if (valid[selected]) {
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[selected][index];
index_[index] = candidate[index];
}
identity_count_ = counts[selected];
epoch_ = epochs[selected];
next_offset_ = offsets[selected];
identity_count_ = count;
epoch_ = epoch;
catalog_version_ = version;
next_offset_ = offset;
snapshot_.valid = true;
snapshot_.active_bank = selected;
snapshot_.generation = generations[selected];
snapshot_.payload_crc = payload_crcs[selected];
snapshot_.active_bank = arena;
snapshot_.generation = generation;
snapshot_.payload_crc = payload_crc;
}
if (snapshot_.valid) {
if (catalog_version_ != kCatalogVersion &&
compact() != ProfileStorageResult::kOk) {
initialized_ = false;
return false;
}
return true;
}
if (migrate_legacy()) {
return true;
const ProfileStorageResult migrated = migrate_legacy();
if (migrated != ProfileStorageResult::kUnchanged) {
initialized_ = migrated == ProfileStorageResult::kOk;
return initialized_;
}
for (ProfileStorageIdentityIndex &entry : index_) {
entry = {};
// 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;
@ -156,7 +184,8 @@ bool ProfileStorage::initialize(const ProfileStorageIo &io) {
for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) {
index_[0].profile_record[profile] = PROFILE_STORAGE_NO_RECORD;
}
return publish_empty_arena(0, 1);
initialized_ = publish_empty_arena(0, 1);
return initialized_;
}
ProfileStorageResult
@ -324,12 +353,10 @@ ProfileStorageResult ProfileStorage::get_profile_name(
output[0] = '\0';
return ProfileStorageResult::kOk;
}
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (!io_.read(
io_.context, record_arena(entry->profile_names_record),
record_offset(entry->profile_names_record) +
kRecordPayloadOffset,
payload, sizeof(payload))) {
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 =
@ -365,14 +392,12 @@ ProfileStorageResult ProfileStorage::set_profile_name(
(value_size == 0 || memcmp(current, value, value_size) == 0)) {
return ProfileStorageResult::kUnchanged;
}
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t payload[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{};
if (entry != nullptr &&
entry->profile_names_record != PROFILE_STORAGE_NO_RECORD &&
!io_.read(
io_.context, record_arena(entry->profile_names_record),
record_offset(entry->profile_names_record) +
kRecordPayloadOffset,
payload, sizeof(payload))) {
!read_record_payload(entry->profile_names_record,
RecordType::kProfileNames, payload,
sizeof(payload))) {
return ProfileStorageResult::kIoError;
}
const size_t offset =
@ -408,18 +433,20 @@ const ProfileStorageSnapshot &ProfileStorage::snapshot() const {
return snapshot_;
}
bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch,
uint32_t *generation, uint32_t *payload_crc,
size_t *next_offset,
ProfileStorageIdentityIndex *index,
uint8_t *identity_count) const {
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)) ||
memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 ||
read_u16(&superblock[4]) != kCatalogVersion ||
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 false;
return ProfileStorageResult::kUnchanged;
}
*epoch = read_u32(&superblock[8]);
*generation = 0;
@ -432,117 +459,150 @@ bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch,
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 header[PROFILE_STORAGE_PAGE_SIZE]{};
uint8_t payload_prefix[4]{};
if (!io_.read(io_.context, arena, offset, header, sizeof(header)) ||
!io_.read(io_.context, arena, offset + kRecordPayloadOffset,
payload_prefix, sizeof(payload_prefix))) {
return false;
uint8_t record[PROFILE_STORAGE_RECORD_SIZE]{};
if (!io_.read(io_.context, arena, offset, record, sizeof(record))) {
return ProfileStorageResult::kIoError;
}
if (!bytes_are(0xff, header, 4) ||
!bytes_are(0xff, payload_prefix, sizeof(payload_prefix))) {
// 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 (memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 ||
read_u16(&header[4]) != kCatalogVersion ||
profile_storage_crc32(header, kRecordHeaderCrcOffset) !=
read_u32(&header[kRecordHeaderCrcOffset]) ||
!bytes_are(0, &header[kRecordHeaderCrcOffset + 4],
sizeof(header) - kRecordHeaderCrcOffset - 4)) {
if (!validate_record(record, *version)) {
continue;
}
const auto type = static_cast<RecordType>(header[6]);
const uint8_t profile_index = header[7];
const uint32_t record_generation = read_u32(&header[8]);
const size_t payload_size = read_u16(&header[12]);
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 ||
type == RecordType::kProfileNames
? CONTROLLER_PROFILE_ENCODED_SIZE
: type == RecordType::kAlias
? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE
: 0;
if (!controller_identity_decode(
&header[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) {
continue;
}
if (payload_size != 0) {
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset,
payload, payload_size) ||
profile_storage_crc32(payload, payload_size) !=
read_u32(&header[16])) {
continue;
}
if (type == RecordType::kProfile) {
ControllerProfile decoded{};
if (read_u16(&header[14]) != read_u16(payload) ||
!controller_profile_decode(payload, payload_size, &decoded)) {
continue;
}
} else if (type == RecordType::kAlias) {
if (read_u16(&header[14]) != 0 ||
!metadata_value_valid(payload, payload_size)) {
continue;
}
} else {
bool valid_names = read_u16(&header[14]) == 0;
for (size_t name = 0;
name < CONTROLLER_PROFILE_COUNT && valid_names; ++name) {
valid_names = metadata_value_valid(
&payload[name * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE],
PROFILE_STORAGE_METADATA_PAYLOAD_SIZE);
}
if (!valid_names) {
continue;
}
}
} else if (read_u16(&header[14]) != 0) {
continue;
}
apply_record(index, identity_count, type, identity_value, profile_index,
record_generation, pack_record(arena, offset));
if (generation_is_newer(record_generation, *generation)) {
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<RecordType>(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(&header[16]);
*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<RecordType>(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<uint8_t>(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]{};
return record != PROFILE_STORAGE_NO_RECORD &&
io_.read(io_.context, record_arena(record),
record_offset(record) + kRecordPayloadOffset, encoded,
sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), output);
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 (record == PROFILE_STORAGE_NO_RECORD ||
!io_.read(
io_.context, record_arena(record),
record_offset(record) + kRecordPayloadOffset,
payload, sizeof(payload)) ||
if (!read_record_payload(record, RecordType::kAlias, payload,
sizeof(payload)) ||
payload[0] > PROFILE_STORAGE_METADATA_MAX_BYTES ||
output_size <= payload[0]) {
return false;
@ -601,12 +661,10 @@ ProfileStorageResult ProfileStorage::compact() {
}
offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t metadata[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t metadata[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{};
if (entry.alias_record != PROFILE_STORAGE_NO_RECORD) {
if (!io_.read(
io_.context, record_arena(entry.alias_record),
record_offset(entry.alias_record) + kRecordPayloadOffset,
metadata, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE) ||
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,
@ -616,15 +674,13 @@ ProfileStorageResult ProfileStorage::compact() {
offset += PROFILE_STORAGE_RECORD_SIZE;
}
if (entry.profile_names_record != PROFILE_STORAGE_NO_RECORD) {
if (!io_.read(
io_.context, record_arena(entry.profile_names_record),
record_offset(entry.profile_names_record) +
kRecordPayloadOffset,
metadata, CONTROLLER_PROFILE_ENCODED_SIZE) ||
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, CONTROLLER_PROFILE_ENCODED_SIZE)) {
metadata, sizeof(metadata))) {
return ProfileStorageResult::kIoError;
}
offset += PROFILE_STORAGE_RECORD_SIZE;
@ -636,26 +692,23 @@ ProfileStorageResult ProfileStorage::compact() {
offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{};
memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic));
write_u16(&superblock[4], kCatalogVersion);
write_u32(&superblock[8], epoch_ + 1u);
write_u32(&superblock[12], profile_storage_crc32(superblock, 12));
if (!io_.program_page(io_.context, target, 0, superblock,
sizeof(superblock))) {
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_epoch, &rebuilt_generation,
&rebuilt_payload_crc, &rebuilt_offset, rebuilt,
&rebuilt_count)) {
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;
@ -664,6 +717,7 @@ ProfileStorageResult ProfileStorage::compact() {
}
identity_count_ = rebuilt_count;
epoch_ = rebuilt_epoch;
catalog_version_ = rebuilt_version;
next_offset_ = rebuilt_offset;
snapshot_.active_bank = target;
snapshot_.generation = rebuilt_generation;
@ -672,7 +726,7 @@ ProfileStorageResult ProfileStorage::compact() {
return ProfileStorageResult::kOk;
}
bool ProfileStorage::migrate_legacy() {
ProfileStorageResult ProfileStorage::migrate_legacy() {
struct LegacyHeader {
bool valid = false;
uint32_t generation = 0;
@ -685,8 +739,10 @@ bool ProfileStorage::migrate_legacy() {
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)) ||
memcmp(header, kLegacyStorageMagic, 4) != 0 ||
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 ||
@ -701,8 +757,7 @@ bool ProfileStorage::migrate_legacy() {
while (remaining != 0) {
const size_t size = remaining < sizeof(page) ? remaining : sizeof(page);
if (!io_.read(io_.context, arena, payload_offset, page, size)) {
remaining = SIZE_MAX;
break;
return ProfileStorageResult::kIoError;
}
crc = crc32_update(crc, page, size);
payload_offset += size;
@ -721,7 +776,7 @@ bool ProfileStorage::migrate_legacy() {
}
}
if (selected < 0) {
return false;
return ProfileStorageResult::kUnchanged;
}
const size_t legacy_base =
@ -738,40 +793,46 @@ bool ProfileStorage::migrate_legacy() {
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 false;
return ProfileStorageResult::kIoError;
}
if (!io_.erase_arena(io_.context, 0)) {
return false;
return ProfileStorageResult::kIoError;
}
size_t target_offset = PROFILE_STORAGE_RECORDS_OFFSET;
uint32_t generation = 0;
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_ENCODED_SIZE;
if (!io_.read(io_.context, arena, source, encoded, sizeof(encoded)) ||
!controller_profile_decode(encoded, sizeof(encoded), &decoded) ||
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 false;
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 false;
return ProfileStorageResult::kIoError;
}
target_offset += PROFILE_STORAGE_RECORD_SIZE;
constexpr size_t kLegacyEntrySize =
16 +
PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
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_ENCODED_SIZE;
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) {
@ -779,7 +840,7 @@ bool ProfileStorage::migrate_legacy() {
uint8_t entry_header[16]{};
if (!io_.read(io_.context, arena, entry_base, entry_header,
sizeof(entry_header))) {
return false;
return ProfileStorageResult::kIoError;
}
if (entry_header[15] == 0) {
continue;
@ -791,46 +852,48 @@ bool ProfileStorage::migrate_legacy() {
entry_header, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) ||
!identity_value.stable ||
controller_identity_is_global(identity_value)) {
return false;
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_ENCODED_SIZE;
if (!io_.read(io_.context, arena, source, encoded, sizeof(encoded)) ||
!controller_profile_decode(encoded, sizeof(encoded), &decoded) ||
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 false;
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 false;
return ProfileStorageResult::kIoError;
}
target_offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t superblock[PROFILE_STORAGE_PAGE_SIZE]{};
memcpy(superblock, kSuperblockMagic, sizeof(kSuperblockMagic));
write_u16(&superblock[4], kCatalogVersion);
write_u32(&superblock[8], 1);
write_u32(&superblock[12], profile_storage_crc32(superblock, 12));
if (!io_.program_page(io_.context, 0, 0, superblock, sizeof(superblock))) {
return false;
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_epoch, &rebuilt_generation, &rebuilt_payload_crc,
&rebuilt_offset, rebuilt, &rebuilt_count)) {
return false;
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) {
@ -838,28 +901,48 @@ bool ProfileStorage::migrate_legacy() {
}
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 true;
return ProfileStorageResult::kOk;
}
bool ProfileStorage::publish_empty_arena(uint8_t arena, uint32_t epoch) {
if (!io_.erase_arena(io_.context, arena)) {
return false;
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));
if (!io_.program_page(io_.context, arena, 0, superblock,
sizeof(superblock))) {
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::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;
@ -874,45 +957,51 @@ bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type,
const uint8_t *payload,
size_t payload_size) const {
const size_t expected_payload_size =
type == RecordType::kProfile ||
type == RecordType::kProfileNames
type == RecordType::kProfile
? CONTROLLER_PROFILE_ENCODED_SIZE
: type == RecordType::kAlias
? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE
: 0;
: 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_PAGE_SIZE != 0 ||
offset % PROFILE_STORAGE_RECORD_SIZE != 0 ||
payload_size != expected_payload_size ||
(payload_size != 0 && payload == nullptr)) {
return false;
}
if (payload_size != 0) {
uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{};
memcpy(page, payload, payload_size);
if (!io_.program_page(io_.context, arena,
offset + kRecordPayloadOffset,
page, sizeof(page))) {
return false;
}
}
uint8_t header[PROFILE_STORAGE_PAGE_SIZE]{};
memcpy(header, kRecordMagic, sizeof(kRecordMagic));
write_u16(&header[4], kCatalogVersion);
header[6] = static_cast<uint8_t>(type);
header[7] = profile_index;
write_u32(&header[8], generation);
write_u16(&header[12], static_cast<uint16_t>(payload_size));
write_u16(&header[14], type == RecordType::kProfile ? read_u16(payload) : 0);
write_u32(&header[16], profile_storage_crc32(payload, payload_size));
if (!controller_identity_encode(identity, &header[20],
uint8_t record[PROFILE_STORAGE_RECORD_SIZE]{};
memcpy(record, kRecordMagic, sizeof(kRecordMagic));
write_u16(&record[4], kCatalogVersion);
record[6] = static_cast<uint8_t>(type);
record[7] = profile_index;
write_u32(&record[8], generation);
write_u16(&record[12], static_cast<uint16_t>(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(&header[kRecordHeaderCrcOffset],
profile_storage_crc32(header, kRecordHeaderCrcOffset));
return io_.program_page(io_.context, arena, offset, header, sizeof(header));
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,

View file

@ -18,6 +18,8 @@ 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);
@ -27,6 +29,9 @@ constexpr size_t PROFILE_STORAGE_RECORD_CAPACITY =
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);
// Layout of the retired v1/v2 whole-database store. The indexed catalog reads
// this region once during migration; new firmware never writes it.
@ -127,10 +132,13 @@ private:
kProfileNames = 6,
};
bool scan_arena(uint8_t arena, uint32_t *epoch, uint32_t *generation,
uint32_t *payload_crc, size_t *next_offset,
ProfileStorageIdentityIndex *index,
uint8_t *identity_count) 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;
@ -139,7 +147,8 @@ private:
uint8_t profile_index, const uint8_t *payload,
size_t payload_size);
ProfileStorageResult compact();
bool migrate_legacy();
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,
@ -156,6 +165,7 @@ private:
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;
};

View file

@ -15,6 +15,9 @@
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<uint16_t>(input[0]) |
static_cast<uint16_t>(input[1] << 8);
@ -78,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:
@ -231,12 +238,57 @@ size_t encode_haptics_experiment(uint8_t* output, size_t output_size) {
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<uint8_t>(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<uint16_t>(event.left_x));
write_u16(encoded + 8, static_cast<uint16_t>(event.left_y));
write_u16(encoded + 10, static_cast<uint16_t>(event.right_x));
write_u16(encoded + 12, static_cast<uint16_t>(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{};
@ -274,6 +326,18 @@ size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) {
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<uint32_t>(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,
@ -547,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<uint32_t>(payload[0]) |
@ -881,6 +974,9 @@ bool usb_configuration_management_vendor_control(
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);

View file

@ -29,10 +29,13 @@ constexpr size_t kProfileMetadataPayloadSize =
(CONTROLLER_PROFILE_COUNT + 1) *
(PROFILE_SERVICE_METADATA_MAX_BYTES + 1);
constexpr uint16_t kProfileMetadataSchemaVersion = 1;
constexpr uint16_t kHapticsExperimentSchemaVersion = 3;
constexpr uint16_t kHapticsExperimentSchemaVersion = 5;
constexpr size_t kHapticsExperimentPayloadSize = 84;
constexpr uint16_t kHapticsTransportProbeSchemaVersion = 2;
constexpr size_t kHapticsTransportProbePayloadSize = 128;
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 =
@ -70,6 +73,7 @@ enum class Operation : uint8_t {
kProfileIdentify = 0x3c,
kHapticsExperiment = 0x40,
kHapticsTransportProbe = 0x41,
kMacroCapture = 0x42,
};
enum class Status : uint8_t {

View file

@ -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) {

View file

@ -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<uint8_t>(&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<uint8_t>(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;
}

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
"""Local-only HTTP backend for the graphical controller profile editor."""
from __future__ import annotations
import json
@ -8,7 +9,7 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
from importlib import resources
from typing import Any, Callable, cast
from urllib.parse import urlsplit
from urllib.parse import parse_qs, urlsplit
import usb.core
@ -39,39 +40,72 @@ _ASSET_TYPES = {
_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",
"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",
"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",
"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",
"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]:
@ -95,6 +129,7 @@ def _controller_presentation(
return {"model": "8BitDo controller", "style": "switch"}
return {"model": "Connected controller", "style": "generic"}
def _controller_label(
identity: config_manager.ControllerIdentity, alias: str = ""
) -> str:
@ -113,13 +148,10 @@ def _controller_label(
"Connected controller": "Controller",
}
model = friendly_models.get(presentation["model"], presentation["model"])
address_suffix = ":".join(
f"{octet:02X}" for octet in identity.address[-2:]
)
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."""
@ -137,6 +169,7 @@ class ProfileEditorServer(HTTPServer):
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:
@ -154,7 +187,6 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
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)
@ -182,6 +214,29 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
],
"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,
@ -205,6 +260,14 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
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:
@ -215,7 +278,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
return
if action == "playtest":
self._api_call(self._read_playtest)
self._api_call(
lambda: self._read_playtest(identity_index, profile_index)
)
return
self.send_error(HTTPStatus.NOT_FOUND)
@ -235,15 +300,11 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
return
identity_index, profile_index, action = selection
if action is None:
self._api_call(
lambda: self._write_profile(identity_index, profile_index)
)
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
)
lambda: self._set_profile_name(identity_index, profile_index)
)
return
self.send_error(HTTPStatus.NOT_FOUND)
@ -252,6 +313,28 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
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
@ -263,16 +346,12 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
identity_index, profile_index, action = selection
if action == "activate":
self._api_call(
lambda: self._activate_profile(
identity_index, profile_index
)
lambda: self._activate_profile(identity_index, profile_index)
)
return
if action == "copy":
self._api_call(
lambda: self._copy_profile(
identity_index, profile_index
)
lambda: self._copy_profile(identity_index, profile_index)
)
return
self.send_error(HTTPStatus.NOT_FOUND)
@ -317,6 +396,16 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
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("/")
@ -326,11 +415,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
identity_index = int(parts[2])
except ValueError:
return None
return (
(identity_index, parts[3])
if identity_index >= 0
else None
)
return (identity_index, parts[3]) if identity_index >= 0 else None
def _entries_and_identity(
self, device: config_manager.UsbDevice, identity_index: int
@ -358,14 +443,18 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"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]:
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)
@ -378,10 +467,18 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"active": entries[identity_index].active_profile_index == profile_index,
}
def _read_playtest(self) -> dict[str, Any]:
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
@ -394,6 +491,265 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
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", ""))
@ -415,9 +771,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
return value
def _write_profile(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:
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)
@ -438,13 +792,14 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
) -> dict[str, Any]:
value = self._read_json_object().get("value")
if not isinstance(value, str):
raise config_manager.ConfigManagerError(
"profile name must be text"
)
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,
device,
identity,
profile_index,
value,
self.profile_server.operation_timeout,
)
return {"stored_generation": status.stored_generation}
@ -452,14 +807,15 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
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"
)
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,
device,
identity,
config_manager.PROFILE_NONE_BUTTON,
value,
self.profile_server.operation_timeout,
)
return {
"stored_generation": status.stored_generation,
@ -472,40 +828,46 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
config_manager.identify_controller(device, identity)
return {"identified": True}
def _copy_profile(
self, identity_index: int, profile_index: int
) -> dict[str, Any]:
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
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(
"copy destination is invalid"
"profile name must contain at most 31 UTF-8 bytes"
)
device = self.profile_server.find_device()
_, source_identity = self._entries_and_identity(
device, identity_index
)
profile = config_manager.read_profile(
device, source_identity, profile_index
)
metadata = config_manager.read_selected_profile_metadata(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,
device,
destination_identity,
target_profile,
profile,
self.profile_server.operation_timeout,
)
name_status = config_manager.set_profile_metadata(
device, destination_identity, target_profile,
metadata.profile_names[profile_index],
device,
destination_identity,
target_profile,
name,
self.profile_server.operation_timeout,
)
return {
@ -526,9 +888,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
)
return {"stored_generation": status.stored_generation}
def _api_call(
self, operation: Callable[[], dict[str, Any]]
) -> None:
def _api_call(self, operation: Callable[[], dict[str, Any]]) -> None:
try:
result = operation()
except config_manager.ConfigManagerError as exc:
@ -559,9 +919,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
return
self._send_bytes(payload, content_type)
def _send_json(
self, value: Any, *, status: HTTPStatus = HTTPStatus.OK
) -> None:
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",

View file

@ -142,6 +142,9 @@ h4 { font-size: 1rem; }
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;
@ -181,6 +184,8 @@ h4 { font-size: 1rem; }
}
.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);
@ -281,6 +286,8 @@ h4 { font-size: 1rem; }
#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; }
@ -417,6 +424,17 @@ h4 { font-size: 1rem; }
.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; }
@ -469,6 +487,7 @@ h4 { font-size: 1rem; }
.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;
@ -522,13 +541,63 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.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; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.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); } }
@ -578,7 +647,7 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.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; }
.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; }
@ -596,6 +665,9 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.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; }

View file

@ -85,6 +85,8 @@
<nav class="section-nav" aria-label="Editor sections">
<a href="#mapping">Mapping</a>
<a href="#shortcuts">Shortcuts</a>
<a href="#shift">Shift</a>
<a href="#analog">Analog</a>
<a href="#feedback">Feedback</a>
<a href="#turbo">Turbo</a>
@ -161,6 +163,26 @@
</div>
</section>
<section class="panel" id="shortcuts" aria-labelledby="shortcutsTitle">
<div class="panel-heading">
<div><p class="eyebrow">Direct selection</p><h3 id="shortcutsTitle">Profile shortcuts</h3></div>
<div class="panel-heading-actions"><p>Hold a modifier and press one face or D-pad button to select a profile directly.</p><button class="section-reset" type="button" data-reset-section="shortcuts">Reset section</button></div>
</div>
<div class="modifier-controls" id="shortcutModifierFields"></div>
<div class="mapping-grid" id="shortcutFields"></div>
<p class="field-help">Each selector is unique. Shortcut chords take priority over cycling, motion toggle, Shift and macros. Release before selecting again.</p>
</section>
<section class="panel" id="shift" aria-labelledby="shiftTitle">
<div class="panel-heading">
<div><p class="eyebrow">Alternate layer</p><h3 id="shiftTitle">Shift button mapping</h3></div>
<div class="panel-heading-actions"><p>Hold or toggle one modifier to use these alternate button outputs.</p><button class="section-reset" type="button" data-reset-section="shift">Reset section</button></div>
</div>
<div class="modifier-controls" id="shiftFields"></div>
<div class="mapping-grid" id="shiftMapFields"></div>
<p class="field-help">The modifier is consumed while Shift is enabled. Stick and trigger transforms stay on the base profile. Turbo settings and macro triggers remain tied to physical controls.</p>
</section>
<section class="panel" id="analog">
<div class="panel-heading">
<div>
@ -260,9 +282,11 @@
<p class="eyebrow">04 · Repeat</p>
<h3>Turbo behavior</h3>
</div>
<div class="panel-heading-actions"><p>Off, hold-to-repeat, or press-once Auto Burst per button.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
<div class="panel-heading-actions"><p>Hold Turbo repeats while pressed; Auto Burst toggles continuous repetition; Burst plays a fixed pulse count per fresh press.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
</div>
<div class="mapping-grid compact" id="turboFields"></div>
<div class="subpanel turbo-defaults" id="turboDefaultFields"></div>
<p class="timing-notice" id="turboTimingNotice" role="status" aria-live="polite"></p>
<div class="turbo-grid" id="turboFields"></div>
</section>
<section class="panel" id="macro">
@ -275,32 +299,133 @@
</div>
<div class="builtin-actions" id="builtinActions"></div>
<div class="macro-controls" id="macroControls"></div>
<section class="macro-capture" id="macroCapture" aria-labelledby="macroCaptureTitle" data-state="idle">
<div class="macro-preview-heading">
<div>
<p class="eyebrow">Live input · bounded firmware recorder</p>
<h4 id="macroCaptureTitle">Record into macro 1</h4>
</div>
<div class="macro-preview-actions">
<button class="button button-secondary" id="macroCaptureRecord" type="button" disabled>Record input</button>
<button class="button button-ghost" id="macroCaptureStop" type="button" disabled>Stop recording</button>
</div>
</div>
<p class="field-help">Records raw input before profile mapping, not controller output. Selected analog channels are explicitly quantized; this is not lossless arbitrary-rate capture. Firmware retains timestamped changes between UI polls, within its input-report precision and the budgets below.</p>
<fieldset id="macroCaptureOptions" class="capture-options">
<legend>Channels to override in every recorded step</legend>
<div class="check-grid">
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="1" checked><span>Buttons</span></label>
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="2"><span>Left stick</span></label>
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="4"><span>Right stick</span></label>
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="8"><span>Left trigger</span></label>
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="16"><span>Right trigger</span></label>
</div>
<div class="capture-number-fields">
<div class="number-field"><label for="macroCaptureAxis">Stick quantization · raw units</label><input class="number-input" id="macroCaptureAxis" form="macroCaptureSettings" type="number" min="1" max="32767" step="1" value="512" required></div>
<div class="number-field"><label for="macroCaptureTrigger">Trigger quantization · raw units</label><input class="number-input" id="macroCaptureTrigger" form="macroCaptureSettings" type="number" min="1" max="65535" step="1" value="1024" required></div>
<div class="number-field"><label for="macroCaptureDuration">Time limit · ms</label><input class="number-input" id="macroCaptureDuration" form="macroCaptureSettings" type="number" min="1" max="80000" step="1" value="10000" required></div>
</div>
</fieldset>
<p class="field-help" id="macroCaptureBudget"></p>
<div class="macro-preview-timing">
<strong id="macroCaptureState" role="status" aria-live="polite">Ready</strong>
<output id="macroCaptureElapsed">0 ms</output>
</div>
<progress id="macroCaptureProgress" max="1" value="0" aria-label="Recording event capacity"></progress>
<p class="capture-notice" id="macroCaptureNotice" role="status" aria-live="polite"></p>
<ol id="macroCaptureEvents" class="capture-events" aria-label="Retained recorded states"></ol>
<div class="macro-preview-actions">
<button class="button button-secondary" id="macroCaptureUse" type="button" disabled>Use recorded steps</button>
<button class="button button-ghost" id="macroCaptureDiscard" type="button" disabled>Discard recording</button>
<button class="button button-ghost" id="macroCaptureRecover" type="button" hidden>Read retained recording</button>
</div>
<p class="field-help">Use replaces only this macros steps in the unsaved draft. Trigger, cancel and playback settings stay unchanged. Nothing is written to flash until you choose Save to Pico. Stop is requested on hiding or leaving this page; recordings never resume automatically.</p>
</section>
<section class="macro-preview" id="macroPreview" aria-labelledby="macroPreviewTitle" data-state="stopped">
<div class="macro-preview-heading">
<div>
<p class="eyebrow">Visual only · never sent to the controller</p>
<h4 id="macroPreviewTitle">Macro 1 draft preview</h4>
</div>
<div class="macro-preview-actions">
<button class="button button-secondary" id="macroPreviewPlay" type="button">Play preview</button>
<button class="button button-ghost" id="macroPreviewStop" type="button" disabled>Stop</button>
<button class="button button-ghost" id="macroPreviewRestart" type="button">Restart</button>
</div>
</div>
<p class="field-help">Highlighted fields override input for this step. Passthrough fields keep controller input; their values are not simulated. Zero-duration steps take no preview time.</p>
<p class="field-help" id="macroPreviewModeHelp"></p>
<div class="macro-preview-timing">
<span id="macroPreviewStatus" role="status" aria-live="polite">Ready</span>
<output id="macroPreviewTime">0 / 0 ms</output>
</div>
<progress id="macroPreviewProgress" max="1" value="0" aria-label="Draft preview elapsed time"></progress>
<div class="macro-preview-buttons" data-preview-field="buttons" data-mode="passthrough">
<div class="playtest-card-heading"><strong>Buttons</strong><span data-preview-mode>Passthrough</span></div>
<div class="check-grid" id="macroPreviewButtons"></div>
</div>
<div class="playtest-grid">
<article class="playtest-card" data-preview-field="left_stick" data-mode="passthrough">
<div class="playtest-card-heading"><strong>Left stick</strong><span data-preview-mode>Passthrough</span></div>
<div class="stick-scope">
<span class="stick-axis stick-axis-x"></span><span class="stick-axis stick-axis-y"></span>
<i class="stick-dot stick-dot-output"></i>
</div>
<output data-preview-value>Controller input</output>
</article>
<article class="playtest-card" data-preview-field="right_stick" data-mode="passthrough">
<div class="playtest-card-heading"><strong>Right stick</strong><span data-preview-mode>Passthrough</span></div>
<div class="stick-scope">
<span class="stick-axis stick-axis-x"></span><span class="stick-axis stick-axis-y"></span>
<i class="stick-dot stick-dot-output"></i>
</div>
<output data-preview-value>Controller input</output>
</article>
<article class="playtest-card macro-preview-triggers">
<div data-preview-field="left_trigger" data-mode="passthrough">
<div class="playtest-card-heading"><strong>Left trigger</strong><span data-preview-mode>Passthrough</span></div>
<progress max="65535" value="0" aria-label="Preview left trigger"></progress>
<output data-preview-value>Controller input</output>
</div>
<div data-preview-field="right_trigger" data-mode="passthrough">
<div class="playtest-card-heading"><strong>Right trigger</strong><span data-preview-mode>Passthrough</span></div>
<progress max="65535" value="0" aria-label="Preview right trigger"></progress>
<output data-preview-value>Controller input</output>
</div>
</article>
</div>
</section>
<div class="macro-steps-heading">
<div>
<p class="eyebrow">Sequence</p>
<h4 id="macroStepsTitle">Macro 1 steps</h4>
<p class="field-help" id="macroDuration"></p>
</div>
<span class="macro-step-action">
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
</span>
</div>
<div class="macro-steps" id="macroSteps"></div>
<p class="field-help" id="macroReorderHelp">Insert adds a 100 ms button step before the current step (5 bytes). Drag a step handle, use Move up / Move down, or press Alt + Arrow Up / Down on its handle to reorder.</p>
<p class="macro-notice" id="macroNotice" role="status" aria-live="polite"></p>
<div class="macro-steps" id="macroSteps" role="list" aria-label="Selected macro steps" aria-describedby="macroReorderHelp"></div>
</section>
</form>
</section>
</main>
<form id="macroCaptureSettings" hidden></form>
<dialog class="copy-dialog" id="copyDialog">
<form method="dialog">
<p class="eyebrow">Duplicate profile</p>
<h3>Choose a destination</h3>
<p class="field-help">Copy this current draft, including unsaved settings, to the destination. This replaces its stored profile and name; the source draft is unchanged.</p>
<label class="field-label" for="copyIdentity">Controller</label>
<select class="select" id="copyIdentity"></select>
<label class="field-label" for="copySlot">Profile slot</label>
<select class="select" id="copySlot"></select>
<div class="dialog-actions">
<button class="button button-ghost" value="cancel">Cancel</button>
<button class="button button-primary" id="confirmCopyButton" value="default">Copy profile</button>
<button class="button button-primary" id="confirmCopyButton" value="default">Copy draft to Pico</button>
</div>
</form>
</dialog>

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,11 @@
#include <cstring>
#include <iostream>
#include <string>
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include <algorithm>
#include <array>
#include <vector>
#endif
#include <uni.h>
#include "platform/pico/controller_color_config.h"
@ -433,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;
}
@ -445,6 +452,10 @@ 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;
@ -466,6 +477,55 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
#include "core/controller_identity.cpp"
#include "input/bluepad32_input_backend.cpp"
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
std::vector<btstack_timer_source_t*> native_timers;
std::array<uint8_t, 143> 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) {
@ -2476,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") {

View file

@ -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);

View file

@ -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() {}

View file

@ -1,3 +1,5 @@
#pragma once
void multicore_launch_core1(void (*entry)());
#include <stddef.h>
#include <stdint.h>
void multicore_launch_core1_with_stack(void (*entry)(), uint32_t* stack, size_t size);

View file

@ -1,3 +1,6 @@
#pragma once
#include <stdint.h>
uint32_t time_us_32();
uint64_t time_us_64();
void tight_loop_contents();

View file

@ -101,7 +101,8 @@ struct uni_controller_t {
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 {
@ -131,9 +132,19 @@ 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;
@ -151,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 {

View file

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

View file

@ -0,0 +1,338 @@
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
// 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;
}

View file

@ -0,0 +1,522 @@
#include "input/controller_macro_capture.h"
#include <cstdlib>
#include <iostream>
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;
}

View file

@ -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,

View file

@ -28,6 +28,8 @@ unsigned active_snapshot_count = 0;
std::array<ActivationAttempt, 32> 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;
@ -57,6 +59,7 @@ 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;
@ -875,6 +878,288 @@ 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<Bluepad32SlotSnapshot, 4> 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(
@ -903,7 +1188,7 @@ ConfigurationTransactionStatus profile_service_activate_internal(
--activation_busy_attempts;
return ConfigurationTransactionStatus::kBusy;
}
return ConfigurationTransactionStatus::kPending;
return activation_result;
}
@ -941,5 +1226,11 @@ int main() {
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;
}

View file

@ -45,9 +45,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 5 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v5/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,
@ -174,7 +174,7 @@ void test_profile_wire_schema() {
"reversed raw trigger range was accepted");
invalid = profile;
invalid.turbo_modes[0] =
static_cast<ControllerProfileTurboMode>(3);
static_cast<ControllerProfileTurboMode>(4);
require(!controller_profile_validate(invalid),
"invalid Turbo mode was accepted");
invalid = profile;
@ -225,7 +225,7 @@ void test_legacy_profile_migration() {
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 v5");
"migrated default profile did not encode as v6");
require(controller_profile_decode(
kLegacyNarrowRawRangeProfile,
@ -269,7 +269,7 @@ void test_legacy_profile_migration() {
kLegacyCustomThresholdProfile[79],
"legacy macro trigger was not migrated to descriptor zero");
uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t previous_encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
memcpy(previous_encoded, kLegacyDefaultProfile,
sizeof(previous_encoded));
previous_encoded[0] = static_cast<uint8_t>(
@ -300,7 +300,17 @@ void test_legacy_profile_migration() {
static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture) &&
migrated.motion_toggle_chord == 0,
"v2 profile controls did not migrate to v5");
"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);
@ -346,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");
@ -360,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<ControllerProfileMacroMode>(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<uint8_t>(total_size);
encoded_database[7] = static_cast<uint8_t>(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;
}

View file

@ -527,6 +527,217 @@ 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() {
@ -539,5 +750,8 @@ int main() {
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;
}

View file

@ -0,0 +1,37 @@
#pragma once
#include <stdint.h>
/* 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);

View file

@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
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);

View file

@ -0,0 +1,11 @@
#pragma once
#include <stdint.h>
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();

View file

@ -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);
}

View file

@ -0,0 +1,409 @@
#include <assert.h>
#include <setjmp.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}

View file

@ -223,8 +223,8 @@ uint64_t start(uint8_t slot = 0) {
return now_us;
}
uint64_t due(uint64_t started, uint32_t packet) {
return started + (static_cast<uint64_t>(packet) * 64000 + 2) / 3;
uint64_t due(uint64_t started, uint32_t packet, uint32_t frames = 64) {
return started + (static_cast<uint64_t>(packet) * frames * 1000 + 2) / 3;
}
void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) {
@ -622,10 +622,15 @@ void gameplay_timeline_and_lifecycle() {
assert(haptics_experiment_gameplay_owns(&devices[0]));
now_us = started + 8000;
feed(false);
run_until(due(started, 1) + 1000);
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 < 64; ++frame) {
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) {
@ -636,7 +641,7 @@ void gameplay_timeline_and_lifecycle() {
right_nonzero += right != 0;
}
}
assert(left_nonzero > 10 && right_nonzero > 20);
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;
@ -695,6 +700,81 @@ void gameplay_queued_start_and_command_overflow() {
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
@ -812,6 +892,8 @@ int main(int argc, char** argv) {
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";
}

View file

@ -42,7 +42,6 @@ HapticsTransportProbe snapshot() {
void unchanged(const HapticsTransportProbe& expected) {
const auto actual = snapshot();
static_assert(sizeof(actual) == 128);
assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0);
}

View file

@ -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);

View file

@ -133,6 +133,15 @@ void test_eight_profile_transactions_and_active_cache() {
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,
@ -140,8 +149,11 @@ void test_eight_profile_transactions_and_active_cache() {
ProfileServiceSelectedSnapshot selected{};
profile_service_selected_snapshot(&selected);
require(selected.valid && selected.profile_index == 7 &&
selected.profile.strong_rumble_scale == 37,
"selected profile eight was not decoded on demand");
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");
require(profile_service_activate(2, global, 7) ==
ConfigurationTransactionStatus::kPending,
@ -152,8 +164,10 @@ void test_eight_profile_transactions_and_active_cache() {
"profile eight activation did not persist");
active = active_snapshot(global);
require(active.valid && active.profile_index == 7 &&
active.profile.strong_rumble_scale == 37,
"active cache did not publish profile eight");
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");
const ControllerIdentity connected = stable_identity();
require(profile_service_observe_identity_on_storage_core(connected),
@ -173,11 +187,28 @@ void test_eight_profile_transactions_and_active_cache() {
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");
require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) ==
ConfigurationTransactionStatus::kPending,
@ -246,6 +277,148 @@ void test_profile_bounds_and_transaction_namespace() {
"transaction namespaces were not enforced");
}
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");
// 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");
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");
}
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<uint8_t>(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(); }
@ -253,6 +426,8 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
int main() {
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;
}

View file

@ -5,6 +5,7 @@
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <initializer_list>
namespace {
@ -13,6 +14,11 @@ struct FakeFlash {
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;
};
@ -48,6 +54,11 @@ bool fake_erase(void *context, uint8_t arena) {
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;
@ -59,9 +70,14 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
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 ||
(storage->fail_after_programs >= 0 &&
storage->programs >= storage->fail_after_programs)) {
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];
}
return false;
}
for (size_t index = 0; index < size; ++index) {
@ -72,6 +88,9 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
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;
}
@ -109,7 +128,16 @@ void write_u32(uint8_t *output, uint32_t value) {
output[3] = static_cast<uint8_t>(value >> 24);
}
void install_legacy_database() {
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);
}
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;
@ -130,22 +158,20 @@ void install_legacy_database() {
payload[10] = 3;
payload[11] = 1;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
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<uint8_t>(20 + profile);
require(controller_profile_encode(value, encoded, sizeof(encoded)),
"legacy fallback profile did not encode");
encode_schema5(value, encoded);
memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded));
}
constexpr size_t kEntrySize = 16 + PROFILE_STORAGE_LEGACY_PROFILE_COUNT *
CONTROLLER_PROFILE_ENCODED_SIZE;
uint8_t *entry =
payload + 32 +
PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
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),
@ -156,22 +182,184 @@ void install_legacy_database() {
++profile) {
ControllerProfile value = controller_profile_default(stable, profile);
value.strong_rumble_scale = static_cast<uint8_t>(40 + profile);
require(controller_profile_encode(value, encoded, sizeof(encoded)),
"legacy stable profile did not encode");
encode_schema5(value, encoded);
memcpy(entry + 16 + profile * sizeof(encoded), encoded, sizeof(encoded));
}
(void)kEntrySize;
memcpy(header, "SPPF", 4);
write_u16(&header[4], 1);
write_u16(&header[6], 2);
write_u32(&header[8], 41);
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<uint8_t>(10 + index);
profile.strong_rumble_scale = static_cast<uint8_t>(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<uint16_t>(1 + step);
if (step < 14) {
value.override_flags = kControllerProfileOverrideButtons |
kControllerProfileOverrideLeftStick;
value.output_button_mask = static_cast<uint16_t>(1u << (step % 16));
value.left_stick_x = static_cast<int16_t>(100 * step - 500);
value.left_stick_y = static_cast<int16_t>(100 * slot + index);
}
}
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<uint8_t>(button + 1),
static_cast<uint8_t>(button + 2),
static_cast<uint8_t>(button + 3)};
}
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
profile.macros[0].repeat_count = 255;
}
return profile;
}
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<uint16_t>(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 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;
}
uint8_t alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
alias[0] = 1;
alias[1] = static_cast<uint8_t>('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<uint8_t>('A' + index);
names[base + 2] = static_cast<uint8_t>('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 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");
}
}
}
void test_empty_catalog_and_eight_profiles() {
erase_all();
ProfileStorage storage;
@ -247,6 +435,14 @@ void test_interrupted_and_corrupt_append_recovery() {
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() {
@ -351,6 +547,186 @@ void test_legacy_migration_is_atomic_and_complete() {
"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;
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 &&
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");
}
}
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() {
@ -360,6 +736,12 @@ int main() {
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;
}

View file

@ -364,6 +364,177 @@ void test_stall_and_overflow() {
"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<uint8_t>(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);
@ -397,6 +568,11 @@ int main() {
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;

View file

@ -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,57 @@ 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",
]
)
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",

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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)

View file

@ -7,7 +7,10 @@ import pytest
@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"])
def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
@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"
@ -24,6 +27,15 @@ def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
"-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",
]
if short_packets
else []
),
f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "haptics_experiment_test.cpp"),

View file

@ -6,6 +6,7 @@ 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
@ -49,9 +50,7 @@ def request_json(
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
)
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())
@ -75,7 +74,6 @@ def test_editor_serves_assets_and_complete_schema(
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
@ -136,9 +134,7 @@ def test_editor_identifies_connected_controller_artwork(
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [
identity["controller"] for identity in listing["identities"]
] == [
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"},
@ -171,9 +167,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
"style": "xbox",
}
status, playtest = request_json(
f"{base_url}/api/profiles/1/8/playtest"
)
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"
@ -190,7 +184,10 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
assert playtest["triggers"] == {"left": 123, "right": 65000}
assert playtest["battery"] == 100
assert playtest["capabilities"] == [
"rumble", "lightbar", "player_leds", "motion"
"rumble",
"lightbar",
"player_leds",
"motion",
]
status, selected = request_json(f"{base_url}/api/profiles/1/8")
@ -212,7 +209,6 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
== custom_profile()
)
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, renamed = request_json(
f"{base_url}/api/profiles/1/8/name",
method="PUT",
@ -221,9 +217,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert renamed["stored_generation"] == 9
assert device.profile_names[
(device.stable_identity.to_bytes(), 7)
] == "Desktop"
assert device.profile_names[(device.stable_identity.to_bytes(), 7)] == "Desktop"
status, aliased = request_json(
f"{base_url}/api/identities/1/alias",
@ -241,25 +235,48 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert identified == {"identified": True}
assert device.identified_identities == [
device.stable_identity.to_bytes()
]
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},
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)
] == custom_profile().to_bytes()
assert device.profile_names[
(device.stable_identity.to_bytes(), 3)
] == "Desktop"
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",
@ -298,3 +315,82 @@ def test_editor_rejects_invalid_or_unauthorized_mutations(
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

View file

@ -5,10 +5,10 @@ from pathlib import Path
import pytest
@pytest.mark.parametrize("experiment_enabled", [False, True])
def test_usb_configuration_management_native(
tmp_path: Path, experiment_enabled: bool,
tmp_path: Path,
experiment_enabled: bool,
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
@ -27,6 +27,7 @@ def test_usb_configuration_management_native(
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),
],

View file

@ -501,7 +501,7 @@ void test_profile_vendor_requests() {
static_cast<uint8_t>(
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");
@ -733,15 +733,15 @@ std::vector<uint8_t> read_haptics_payload() {
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
control_payload[7] == 0 &&
read_u16(control_payload, 8) == 84 &&
read_u16(control_payload, 10) == 3,
"experiment schema-3 envelope is invalid");
read_u16(control_payload, 10) == 5,
"experiment schema-5 envelope is invalid");
std::vector<uint8_t> 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] == 0 &&
payload[74] == 0 && payload[75] == 0,
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;
}
@ -788,6 +788,7 @@ void test_haptics_experiment_requests() {
}
std::vector<uint8_t> expected(84, 0);
expected[69] = 0xff;
expected[73] = 64;
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
expected[68] = 6;
require(read_haptics_payload() == expected,
@ -943,7 +944,7 @@ void test_haptics_transport_probe_requests() {
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) == 2,
read_u16(control_payload, 10) == 3,
"transport probe operation, flags, or schema are invalid");
const std::vector<uint8_t> payload(
control_payload.begin() + kResponseHeaderSize, control_payload.end());
@ -951,18 +952,18 @@ void test_haptics_transport_probe_requests() {
configuration_crc32(payload.data(), payload.size()),
"transport probe response CRC is invalid");
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
require(control_payload.size() == kResponseHeaderSize + 128 &&
read_u16(control_payload, 8) == 128 &&
require(control_payload.size() == kResponseHeaderSize + 176 &&
read_u16(control_payload, 8) == 176 &&
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
read_u32(control_payload, 12) == 0x10203040,
"transport probe schema-2 envelope is invalid");
"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<uint8_t> expected(128);
std::vector<uint8_t> expected(176);
for (size_t index = 0; index < 32; ++index) {
write_u32(&expected, index * 4, fields[index]);
}
@ -1141,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;
}

View file

@ -590,6 +590,13 @@ void test_xinput_boundary_dispatch() {
static_cast<uint8_t>(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");