diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 07db703..29daa1a 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -85,10 +85,10 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: | Stick sensitivity | Complete | Per-stick center calibration, inner deadzone, outer saturation, fixed-point curve, and inversion run before every output serializer. | | Trigger ranges | Complete for current outputs | Per-trigger deadzone, saturation, curve, and digital threshold preserve analog XInput values and configured Switch thresholds. | | Vibration intensity | Complete | Independent weak/strong profile scales apply to host rumble; local confirmation policy remains separate. | -| Macros | Complete | One bounded eight-step deterministic macro per profile supports buttons, D-pad, sticks, triggers, waits, and explicit end. | -| Turbo and Auto Burst | Complete | Fixed-point phase accumulation produces 15 activations per second with deterministic cancellation. | -| Persistent profiles | Complete | Global fallback plus sixteen stable identities each store four fixed 256-byte profiles in a two-bank atomic database. | -| Profile switching | Complete | PC commands and a configurable controller chord persist selection with isolated rumble/onboard/transient controller LED confirmation. | +| Macros | Complete | Four sequences share sixteen steps and 136 sparse bytes; Once, While held, Toggle and bounded Repeat are supported. | +| Turbo, Auto Burst and finite Burst | Complete | Configurable 1–30 Hz and 1–99% duty, shared defaults/sparse overrides, and 1–255 finite pulses use bounded phase arithmetic. | +| Persistent profiles | Complete | Eight schema-6/384-byte profiles per global/stable identity use a two-arena indexed catalog with migration and atomic publication. | +| Profile switching | Complete | PC activation, retained cycle chord and eight direct modifier shortcuts publish feedback only after commit. | | Firmware updater | Partial | UF2 updating works; version query and guided reboot/install tool are missing. | | Switch 2 | Unverified | Requires real-hardware qualification. | | Windows/SteamOS/Linux/Android compatibility | Partial | Windows XInput feasibility passed; other host/output combinations need qualification. | @@ -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 1–8 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 1–8, with the cycle chord retained and feedback after commit. | Deterministic arbitration, selector rollover and held/generation transitions are covered. | +| B2 — Complete | One Shift layer per profile | Hold/Toggle selects one alternate button map; base analog tuning, physical Turbo settings and macro definitions stay shared. | Modifier consumption and reset precedence are explicit. | +| B3 — Complete | Macro authoring tools | Timestamped firmware recording, insert/duplicate/remove/drag/keyboard reorder, visual-only preview and live duration/byte budgets; Once/While held/Toggle/Repeat playback. | Eight steps per macro, sixteen shared steps and 136 sparse bytes remain fixed. | +| B4 — Complete | Configurable Turbo and finite Burst | Shared rate/duty/count defaults with per-button overrides retain hold Turbo and Auto Burst. | Fixed-point timing skips elapsed cycles; UI warns about pulses narrower than host sampling. | | C1 | Motion calibration and tuning | Expose live gyro/accelerometer values, bias calibration, axis orientation/inversion, sensitivity, drift threshold, smoothing, and hold/toggle activation. Preserve native Switch motion units rather than introducing gyro-to-stick emulation first. | Controller-specific validation and physical motion testing; filters must not add report latency. | | C2 | Feedback preview and profile lighting | Add non-persistent weak/strong rumble tests, profile-switch preview, and player LED/lightbar preview. Optionally persist an RGB profile color where the controller supports it. | New bounded management command; unsupported output capabilities must be visibly disabled. | @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 05836d4..0e645d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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=$,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) diff --git a/HAPTICS_EXPERIMENT.md b/HAPTICS_EXPERIMENT.md index eb8a237..3ed8099 100644 --- a/HAPTICS_EXPERIMENT.md +++ b/HAPTICS_EXPERIMENT.md @@ -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 1–2 seconds of gameplay-to-haptics delay in OMP session `01a06fa9-cdc7-72de-ac0e-7de08c355f06`. Both a DS5Dongle-style 0x39 stream and a short 0x32 stream failed after continuous silence, latest-state replacement and can-send callbacks were tried. Do not repeat those changes as newly discovered fixes or attribute the observed delay to profile feedback. @@ -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 73–75: 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. diff --git a/README.md b/README.md index 4d848cd..eed11d2 100644 --- a/README.md +++ b/README.md @@ -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 1–8. Chord controls are consumed; holding or changing a profile cannot retrigger the same press. Activation and feedback wait for the atomic commit. The existing one-second minimum commit interval still applies to rapid successive changes. +- **Shift:** one alternate button-only map, enabled while held or toggled on a fresh modifier press. It does not layer analog tuning, Turbo settings, or macro definitions. Toggle state resets with connection, profile, mode, and configuration changes. +- **Turbo/Burst:** shared settings with optional per-button overrides; 1–30 Hz, 1–99% duty, and 1–255 finite Burst pulses. Hold Turbo follows the button, Auto Burst toggles continuous repetition, and Burst runs its configured count after a press. Defaults remain 15 Hz/50%. Counts describe scheduled ON windows; narrow phases can be missed by host report sampling, so the editor warns instead of silently changing settings. +- **Macros:** four independently triggered sequences, up to eight steps each, sharing sixteen decoded steps and a **136-byte sparse stream**. Wait/button/all-field steps cost 3/5/17 bytes. Playback supports Once, While held, Toggle, and bounded Repeat (1–255 cycles). Repeating zero-duration sequences are rejected; clock gaps skip elapsed cycles rather than replay a backlog. +- **Authoring:** insert, duplicate, remove, drag-reorder, or move steps with keyboard controls. Duration and byte budgets update on every edit. The visual preview shows overridden versus passthrough fields and never injects controller output. +- **Recording:** Record input captures timestamped firmware-side changes before profile mapping, not the editor's 75 ms snapshots. Choose channels and explicit analog quantization; the initial state consumes one entry and long holds split at ten seconds. The UI limits capture to the remaining 8/16/136 budget, visibly reports capacity/time/disconnect endings, and retains data for review. **Use recorded steps** changes only the unsaved macro; **Save to Pico** is still separate. Input-report and millisecond playback precision remain real limits. ### Per-controller motion toggle @@ -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 5–8 do not consume flash until changed. When an arena fills, the latest indexed records are compacted into its peer and the new superblock is published last. -Interrupted or corrupt appends 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 1–5 profiles retain their meaning when decoded as schema 6. +Keep a profile export before downgrading: older firmware cannot read the new +catalog/profile format. ## References - GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE diff --git a/build.py b/build.py old mode 100644 new mode 100755 index 1375f54..b6ff84e --- a/build.py +++ b/build.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Build and flash the project with optional grip color overrides.""" + import argparse import os import random @@ -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() diff --git a/patches/btstack-credit-batch.patch b/patches/btstack-credit-batch.patch new file mode 100644 index 0000000..8a53b9f --- /dev/null +++ b/patches/btstack-credit-batch.patch @@ -0,0 +1,296 @@ +--- a/lib/btstack/src/hci.c ++++ b/lib/btstack/src/hci.c +@@ -93,6 +93,12 @@ + #endif + #endif + ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++#if !defined(ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL) || (HCI_HOST_ACL_PACKET_NUM != 3) ++#error "SWITCH_PICO_HCI_CREDIT_BATCH requires controller-to-host flow control with three ACL credits" ++#endif ++#endif ++ + #ifndef MAX_NR_CONTROLLER_ACL_BUFFERS + #define MAX_NR_CONTROLLER_ACL_BUFFERS 255 + #endif +@@ -273,6 +279,74 @@ + #endif + static hci_stack_t * hci_stack = NULL; + ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++static btstack_timer_source_t hci_credit_batch_timer; ++static bool hci_credit_batch_armed; ++static bool hci_credit_batch_due; ++static bool hci_credit_batch_enabled; ++static uint32_t hci_credit_batch_generation; ++ ++static void hci_credit_batch_reset(void){ ++ if (hci_credit_batch_armed){ ++ btstack_run_loop_remove_timer(&hci_credit_batch_timer); ++ hci_credit_batch_armed = false; ++ } ++ hci_credit_batch_due = false; ++} ++ ++static void hci_credit_batch_stop(void){ ++ hci_credit_batch_enabled = false; ++ hci_credit_batch_generation++; ++ hci_credit_batch_reset(); ++} ++ ++static void hci_credit_batch_timeout(btstack_timer_source_t * timer); ++ ++static void hci_credit_batch_arm(void){ ++ if (!hci_credit_batch_enabled || hci_credit_batch_armed || (hci_stack == NULL)) return; ++ if (hci_stack->state != HCI_STATE_WORKING) return; ++ btstack_run_loop_set_timer_handler(&hci_credit_batch_timer, hci_credit_batch_timeout); ++ // Pico adds one millisecond to its truncated clock: 1 ms requests expire within 2 ms. ++ btstack_run_loop_set_timer(&hci_credit_batch_timer, 1); ++ hci_credit_batch_armed = true; ++ btstack_run_loop_add_timer(&hci_credit_batch_timer); ++} ++ ++static bool hci_credit_batch_ready(void){ ++ uint32_t pending = 0; ++ bool sco_pending = false; ++ btstack_linked_item_t * it; ++ for (it = (btstack_linked_item_t *) hci_stack->connections; it; it = it->next){ ++ hci_connection_t * connection = (hci_connection_t *) it; ++ pending += connection->num_packets_completed; ++ if ((connection->address_type == BD_ADDR_TYPE_SCO) && (connection->num_packets_completed != 0)){ ++ sco_pending = true; ++ } ++ } ++ if (pending == 0){ ++ hci_stack->host_completed_packets = 0; ++ hci_credit_batch_reset(); ++ return false; ++ } ++ // Three advertised ACL credits leave one in reserve at the two-completion low water mark. ++ return hci_credit_batch_due || sco_pending || (pending >= 2); ++} ++ ++static void hci_credit_batch_timeout(btstack_timer_source_t * timer){ ++ UNUSED(timer); ++ if (!hci_credit_batch_armed) return; ++ hci_credit_batch_armed = false; ++ if (!hci_credit_batch_enabled || (hci_stack == NULL)) return; ++ if (hci_stack->state != HCI_STATE_WORKING) return; ++ hci_credit_batch_due = true; ++ if (!hci_credit_batch_ready()) return; ++ // Retry at a future tick if the transport/buffer is busy, never spin. Arm before hci_run: ++ // a synchronous send may close/reinitialize HCI, so do not touch its state afterwards. ++ hci_credit_batch_arm(); ++ hci_run(); ++} ++#endif ++ + #ifdef ENABLE_CLASSIC + // default name + static const char * default_classic_name = "BTstack 00:00:00:00:00:00"; +@@ -1195,6 +1269,9 @@ + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + hci_stack->host_completed_packets = 1; + conn->num_packets_completed++; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_arm(); ++#endif + #endif + + // handle different packet types +@@ -1269,8 +1346,10 @@ + return; + } + ++#ifndef SWITCH_PICO_HCI_CREDIT_BATCH + // execute main loop + hci_run(); ++#endif + } + + static void hci_connection_stop_timer(hci_connection_t * conn){ +@@ -1296,6 +1375,9 @@ + + btstack_linked_list_remove(&hci_stack->connections, (btstack_linked_item_t *) conn); + btstack_memory_hci_connection_free( conn ); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ (void) hci_credit_batch_ready(); ++#endif + + // now it's gone + hci_emit_nr_connections_changed(); +@@ -4314,6 +4396,12 @@ + + conn = hci_connection_for_handle(handle); + if (!conn) break; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // The controller reclaims disconnected credits. Clear them before pairing or ++ // disconnection callbacks can re-enter hci_run, including handle reuse. ++ conn->num_packets_completed = 0; ++ (void) hci_credit_batch_ready(); ++#endif + #ifdef ENABLE_CLASSIC + // pairing failed if it was ongoing + hci_pairing_complete(conn, ERROR_CODE_REMOTE_USER_TERMINATED_CONNECTION); +@@ -4780,6 +4868,10 @@ + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + conn->num_packets_completed++; + hci_stack->host_completed_packets = 1; ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // SCO is never deferred, but needs the same transport-busy retry guarantee. ++ hci_credit_batch_arm(); ++#endif + hci_run(); + #endif + } +@@ -4814,6 +4906,10 @@ + break; + case HCI_ACL_DATA_PACKET: + acl_handler(packet, size); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Also service credits for malformed fragments that return early after accounting. ++ if (hci_stack != NULL) hci_run(); ++#endif + break; + #ifdef ENABLE_CLASSIC + case HCI_SCO_DATA_PACKET: +@@ -4869,6 +4965,11 @@ + #endif + + static void hci_state_reset(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_reset(); ++ hci_credit_batch_generation++; ++ hci_stack->host_completed_packets = 0; ++#endif + // no connections yet + hci_stack->connections = NULL; + +@@ -4953,6 +5054,10 @@ + #endif + + void hci_init(const hci_transport_t *transport, const void *config){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + + #ifdef HAVE_MALLOC + if (!hci_stack) { +@@ -5082,6 +5187,9 @@ + } + + void hci_deinit(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + btstack_run_loop_remove_timer(&hci_stack->timeout); + #ifdef HAVE_MALLOC + if (hci_stack) { +@@ -5138,6 +5246,9 @@ + } + + void hci_close(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + #ifdef ENABLE_CLASSIC + // close remote device db +@@ -5264,6 +5375,10 @@ + // HCI_STATE_FALLING_ASLEEP on open + + static int hci_power_control_on(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + + // power on + int err = 0; +@@ -5300,6 +5415,9 @@ + } + + static void hci_power_control_off(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + log_info("hci_power_control_off"); + +@@ -5319,6 +5437,9 @@ + } + + static void hci_power_control_sleep(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++#endif + + log_info("hci_power_control_sleep"); + +@@ -5363,6 +5484,10 @@ + } + + static void hci_power_enter_initializing_state(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ hci_credit_batch_stop(); ++ hci_credit_batch_enabled = true; ++#endif + // set up state machine + hci_stack->num_cmd_packets = 1; // assume that one cmd can be sent + hci_stack->hci_packet_buffer_reserved = false; +@@ -5543,6 +5668,10 @@ + } + + int hci_power_control(HCI_POWER_MODE power_mode){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Repeated POWER_ON while working must not restart a pending credit's deadline. ++ if (power_mode != HCI_POWER_ON) hci_credit_batch_stop(); ++#endif + log_info("hci_power_control: %d, current mode %u", power_mode, hci_stack->state); + btstack_run_loop_remove_timer(&hci_stack->timeout); + int err = 0; +@@ -5834,6 +5963,11 @@ + + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL + static void hci_host_num_completed_packets(void){ ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // Clear batching state before any transport/application callback can add new credits. ++ hci_credit_batch_reset(); ++ uint32_t generation = hci_credit_batch_generation; ++#endif + + // create packet manually as arrays are not supported and num_commands should not get reduced + hci_reserve_packet_buffer(); +@@ -5868,6 +6002,9 @@ + + hci_dump_packet(HCI_COMMAND_DATA_PACKET, 0, packet, size); + hci_stack->hci_transport->send_packet(HCI_COMMAND_DATA_PACKET, packet, size); ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ if ((hci_stack == NULL) || (generation != hci_credit_batch_generation)) return; ++#endif + + // release packet buffer for synchronous transport implementations + if (hci_transport_synchronous()){ +@@ -7664,12 +7801,21 @@ + #endif + + #ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL ++#ifdef SWITCH_PICO_HCI_CREDIT_BATCH ++ // A single deferred ACL credit must not block ordinary HCI commands. ++ if (hci_stack->host_completed_packets && hci_credit_batch_ready()){ ++ if (!hci_can_send_command_packet_transport()) return; ++ hci_host_num_completed_packets(); ++ return; ++ } ++#else + // send host num completed packets next as they don't require num_cmd_packets > 0 + if (!hci_can_send_command_packet_transport()) return; + if (hci_stack->host_completed_packets){ + hci_host_num_completed_packets(); + return; + } ++#endif + #endif + + if (!hci_can_send_command_packet_now()) return; diff --git a/patches/cyw43-packet-read-status.patch b/patches/cyw43-packet-read-status.patch new file mode 100644 index 0000000..2bde2fb --- /dev/null +++ b/patches/cyw43-packet-read-status.patch @@ -0,0 +1,27 @@ +--- a/cybt_shared_bus_driver.c ++++ b/cybt_shared_bus_driver.c +@@ -501,8 +501,11 @@ + cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *p_buf_index) { + uint32_t buf[4]; + +- cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf)); ++ cybt_result_t result = cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf)); ++ if (result != CYBT_SUCCESS) { ++ return result; ++ } + + p_buf_index->host2bt_in_val = buf[0]; + p_buf_index->host2bt_out_val = buf[1]; + p_buf_index->bt2host_in_val = buf[2]; +@@ -596,7 +599,10 @@ + if ((mem_addr & 0xFFF) + transfer_size > 0x1000) { + transfer_size = 0x1000 - (mem_addr & 0xFFF); + } +- cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data); ++ int result = cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data); ++ if (result != 0) { ++ return CYBT_ERR_HCI_READ_FAILED; ++ } + cybt_debug(" read_mem addr 0x%08lx len %ld\n", mem_addr, transfer_size); + DUMP_BYTES(p_data, transfer_size); + data_len -= transfer_size; diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index c732097..5811bc8 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -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(&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( 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(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(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 } diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 60dad7e..dc37634 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -6,6 +6,7 @@ #include "core/controller_identity.h" #include "profile/controller_profile.h" #include "core/controller_state.h" +#include "input/controller_macro_capture.h" #include "usb/switch/switch_haptics.h" constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; @@ -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); diff --git a/src/firmware/input/controller_macro_capture.cpp b/src/firmware/input/controller_macro_capture.cpp new file mode 100644 index 0000000..6b36991 --- /dev/null +++ b/src/firmware/input/controller_macro_capture.cpp @@ -0,0 +1,232 @@ +#include "input/controller_macro_capture.h" + +namespace { + +constexpr uint8_t kButtons = 1u << 0; +constexpr uint8_t kLeftStick = 1u << 1; +constexpr uint8_t kRightStick = 1u << 2; +constexpr uint8_t kLeftTrigger = 1u << 3; +constexpr uint8_t kRightTrigger = 1u << 4; +constexpr uint32_t kMaxHoldUs = 10000000; +constexpr uint32_t kMaxDurationMs = 80000; + +uint16_t button_mask(const ControllerState& state) { + return static_cast( + (state.button_south ? 1u << 0 : 0) | + (state.button_east ? 1u << 1 : 0) | + (state.button_west ? 1u << 2 : 0) | + (state.button_north ? 1u << 3 : 0) | + (state.button_left_shoulder ? 1u << 4 : 0) | + (state.button_right_shoulder ? 1u << 5 : 0) | + (state.button_select ? 1u << 6 : 0) | + (state.button_start ? 1u << 7 : 0) | + (state.button_system ? 1u << 8 : 0) | + (state.button_capture ? 1u << 9 : 0) | + (state.button_left_stick ? 1u << 10 : 0) | + (state.button_right_stick ? 1u << 11 : 0) | + (state.dpad_up ? 1u << 12 : 0) | + (state.dpad_down ? 1u << 13 : 0) | + (state.dpad_left ? 1u << 14 : 0) | + (state.dpad_right ? 1u << 15 : 0)); +} + +// Round magnitudes to nearest, with ties away from zero. Keep full scale +// exact and use the same magnitude on both sides of zero where representable. +int16_t quantize_axis(int16_t value, uint16_t quantum) { + if (value == INT16_MIN) { + return value; + } + const int32_t magnitude = value < 0 ? -static_cast(value) : value; + if (magnitude == INT16_MAX) { + return value; + } + int32_t rounded = ((magnitude + quantum / 2) / quantum) * quantum; + if (rounded > INT16_MAX) { + rounded = INT16_MAX; + } + return static_cast(value < 0 ? -rounded : rounded); +} + +uint16_t quantize_trigger(uint16_t value, uint16_t quantum) { + if (value == UINT16_MAX) { + return value; + } + const uint32_t rounded = + ((static_cast(value) + quantum / 2u) / quantum) * quantum; + return static_cast(rounded > UINT16_MAX ? UINT16_MAX : rounded); +} + +bool same_channels(const CaptureEvent& left, const CaptureEvent& right) { + return left.buttons == right.buttons && left.left_x == right.left_x && + left.left_y == right.left_y && left.right_x == right.right_x && + left.right_y == right.right_y && + left.left_trigger == right.left_trigger && + left.right_trigger == right.right_trigger; +} + +} // namespace + +bool ControllerMacroCapture::start(uint8_t slot, uint32_t generation, + const CaptureOptions& options, + uint32_t now_us, + const ControllerState& initial) { + if (state_ == CaptureState::kRecording || slot >= 4 || + options.channels == 0 || options.channels > 31 || + options.axis_quantum == 0 || options.axis_quantum > INT16_MAX || + options.trigger_quantum == 0 || options.max_events == 0 || + options.max_events > kCapacity || options.max_duration_ms == 0 || + options.max_duration_ms > kMaxDurationMs) { + return false; + } + options_ = options; + slot_ = slot; + generation_ = generation; + started_us_ = now_us; + ended_us_ = 0; + ++run_id_; + if (run_id_ == 0) { + ++run_id_; + } + events_[0] = snapshot(initial, 0); + event_count_ = 1; + state_ = CaptureState::kRecording; + return true; +} + +CaptureEvent ControllerMacroCapture::snapshot(const ControllerState& state, + uint32_t at_us) const { + CaptureEvent captured{}; + captured.at_us = at_us; + if ((options_.channels & kButtons) != 0) { + captured.buttons = button_mask(state); + } + if ((options_.channels & kLeftStick) != 0) { + captured.left_x = quantize_axis(state.left_stick_x, options_.axis_quantum); + captured.left_y = quantize_axis(state.left_stick_y, options_.axis_quantum); + } + if ((options_.channels & kRightStick) != 0) { + captured.right_x = quantize_axis(state.right_stick_x, options_.axis_quantum); + captured.right_y = quantize_axis(state.right_stick_y, options_.axis_quantum); + } + if ((options_.channels & kLeftTrigger) != 0) { + captured.left_trigger = + quantize_trigger(state.left_trigger, options_.trigger_quantum); + } + if ((options_.channels & kRightTrigger) != 0) { + captured.right_trigger = + quantize_trigger(state.right_trigger, options_.trigger_quantum); + } + return captured; +} + +void ControllerMacroCapture::finish(CaptureState state, uint32_t at_us) { + state_ = state; + ended_us_ = at_us; +} + +bool ControllerMacroCapture::append(const CaptureEvent& captured) { + if (captured.at_us == events_[event_count_ - 1].at_us) { + events_[event_count_ - 1] = captured; + return true; + } + if (event_count_ >= options_.max_events) { + finish(CaptureState::kFull, captured.at_us); + return false; + } + events_[event_count_++] = captured; + return true; +} + +void ControllerMacroCapture::advance(uint32_t now_us, bool ending) { + if (state_ != CaptureState::kRecording) { + return; + } + const uint32_t elapsed = now_us - started_us_; + const uint32_t duration = options_.max_duration_ms * 1000u; + const bool timed_out = elapsed >= duration; + const uint32_t target = timed_out ? duration : elapsed; + // An ending interval may be exactly 10 seconds; it needs no zero-length + // trailing event. A live boundary can be replaced by an edge at that time. + for (uint16_t step = 0; step < kCapacity; ++step) { + const CaptureEvent& previous = events_[event_count_ - 1]; + const uint32_t boundary = previous.at_us + kMaxHoldUs; + if (boundary > target || + (boundary == target && (ending || timed_out))) { + break; + } + CaptureEvent held = previous; + held.at_us = boundary; + if (!append(held)) { + return; + } + } + if (timed_out) { + finish(CaptureState::kTimedOut, duration); + } +} + +void ControllerMacroCapture::observe(uint8_t slot, uint32_t generation, + uint32_t now_us, + const ControllerState& state) { + if (state_ != CaptureState::kRecording || slot != slot_) { + return; + } + if (generation != generation_) { + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kDisconnected, now_us - started_us_); + } + return; + } + advance(now_us, false); + if (state_ != CaptureState::kRecording) { + return; + } + const CaptureEvent captured = snapshot(state, now_us - started_us_); + if (!same_channels(captured, events_[event_count_ - 1])) { + append(captured); + } +} + +void ControllerMacroCapture::stop(uint32_t now_us) { + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kStopped, now_us - started_us_); + } +} + +void ControllerMacroCapture::disconnect(uint8_t slot, uint32_t generation, + uint32_t now_us) { + if (state_ != CaptureState::kRecording || slot != slot_ || + generation != generation_) { + return; + } + advance(now_us, true); + if (state_ == CaptureState::kRecording) { + finish(CaptureState::kDisconnected, now_us - started_us_); + } +} + +void ControllerMacroCapture::tick(uint32_t now_us) { + advance(now_us, false); +} + +uint32_t ControllerMacroCapture::elapsed_us(uint32_t now_us) const { + if (state_ == CaptureState::kIdle) { + return 0; + } + if (state_ != CaptureState::kRecording) { + return ended_us_; + } + const uint32_t elapsed = now_us - started_us_; + const uint32_t duration = options_.max_duration_ms * 1000u; + return elapsed < duration ? elapsed : duration; +} + +bool ControllerMacroCapture::event(uint16_t index, CaptureEvent* output) const { + if (output == nullptr || index >= event_count_) { + return false; + } + *output = events_[index]; + return true; +} diff --git a/src/firmware/input/controller_macro_capture.h b/src/firmware/input/controller_macro_capture.h new file mode 100644 index 0000000..af9dbb6 --- /dev/null +++ b/src/firmware/input/controller_macro_capture.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include "core/controller_state.h" + +enum class CaptureState : uint8_t { + kIdle = 0, + kRecording = 1, + kStopped = 2, + kFull = 3, + kTimedOut = 4, + kDisconnected = 5, +}; + +struct CaptureOptions { + uint8_t channels = 1; + uint16_t axis_quantum = 512; + uint16_t trigger_quantum = 1024; + uint8_t max_events = 8; + uint32_t max_duration_ms = 10000; +}; + +struct CaptureEvent { + uint32_t at_us; + uint16_t buttons; + int16_t left_x; + int16_t left_y; + int16_t right_x; + int16_t right_y; + uint16_t left_trigger; + uint16_t right_trigger; +}; + +// Caller serializes access. Timestamps are a monotonically advancing uint32 +// microsecond clock, including wrap; unselected event channels are zero. +class ControllerMacroCapture { +public: + static constexpr uint16_t kCapacity = 128; + + bool start(uint8_t slot, uint32_t generation, + const CaptureOptions& options, uint32_t now_us, + const ControllerState& initial); + void observe(uint8_t slot, uint32_t generation, uint32_t now_us, + const ControllerState& state); + void stop(uint32_t now_us); + void disconnect(uint8_t slot, uint32_t generation, uint32_t now_us); + void tick(uint32_t now_us); + + CaptureState state() const { return state_; } + const CaptureOptions& options() const { return options_; } + uint8_t slot() const { return slot_; } + uint32_t generation() const { return generation_; } + uint32_t run_id() const { return run_id_; } + uint16_t event_count() const { return event_count_; } + uint32_t elapsed_us(uint32_t now_us) const; + bool event(uint16_t index, CaptureEvent* output) const; + +private: + CaptureEvent snapshot(const ControllerState& state, uint32_t at_us) const; + bool append(const CaptureEvent& event); + void advance(uint32_t now_us, bool ending); + void finish(CaptureState state, uint32_t at_us); + + CaptureOptions options_{}; + CaptureState state_ = CaptureState::kIdle; + uint8_t slot_ = 0; + uint32_t generation_ = 0; + uint32_t run_id_ = 0; + uint32_t started_us_ = 0; + uint32_t ended_us_ = 0; + uint16_t event_count_ = 0; + CaptureEvent events_[kCapacity]{}; +}; diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp index 661d5d6..73e0b28 100644 --- a/src/firmware/input/haptics_experiment.cpp +++ b/src/firmware/input/haptics_experiment.cpp @@ -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(packet) * kPacketNumeratorUs + + (static_cast(packet) * packet_numerator_us() + kPacketDenominator - 1) / kPacketDenominator; } @@ -337,7 +377,7 @@ void request_send() { } else { const uint32_t current = static_cast( ((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(g_diagnostics.sent_packets * 2); - report[8] = 0xd2; // Double-sized 0x12: genuinely two 64-byte blocks. + report[7] = static_cast(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(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( - ((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(submit_us); g_last_was_silence = !tone; + g_diagnostics.last_packet_nonzero = tone; if (tone && !g_first_tone_sent) { g_first_tone_sent = true; g_diagnostics.first_tone_sent_us = static_cast(submit_us); @@ -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; diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h index cc7bfa0..e040ede 100644 --- a/src/firmware/input/haptics_experiment.h +++ b/src/firmware/input/haptics_experiment.h @@ -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); diff --git a/src/firmware/input/haptics_transport_probe.cpp b/src/firmware/input/haptics_transport_probe.cpp index 590efba..839ec14 100644 --- a/src/firmware/input/haptics_transport_probe.cpp +++ b/src/firmware/input/haptics_transport_probe.cpp @@ -3,6 +3,9 @@ #include #include #include +#if PICO_ON_DEVICE +#include "platform/pico/system_clock.h" +#endif extern "C" { #include @@ -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); } } diff --git a/src/firmware/input/haptics_transport_probe.h b/src/firmware/input/haptics_transport_probe.h index 41e3951..e99f1cd 100644 --- a/src/firmware/input/haptics_transport_probe.h +++ b/src/firmware/input/haptics_transport_probe.h @@ -2,7 +2,7 @@ #include -// 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. diff --git a/src/firmware/input/switch_hd_rumble_synth.cpp b/src/firmware/input/switch_hd_rumble_synth.cpp index d518320..0cb6ef0 100644 --- a/src/firmware/input/switch_hd_rumble_synth.cpp +++ b/src/firmware/input/switch_hd_rumble_synth.cpp @@ -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(low_magnitude) * 32768u + 127u) / 255u; + command.frame.actuators[1].samples[0].high_amplitude_q15 = + (static_cast(high_magnitude) * 32768u + 127u) / 255u; + enqueue(command); + return true; +} + void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us, uint8_t low_magnitude, uint8_t high_magnitude) { @@ -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]); diff --git a/src/firmware/input/switch_hd_rumble_synth.h b/src/firmware/input/switch_hd_rumble_synth.h index a2a8a08..369f3fd 100644 --- a/src/firmware/input/switch_hd_rumble_synth.h +++ b/src/firmware/input/switch_hd_rumble_synth.h @@ -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; }; diff --git a/src/firmware/main.cpp b/src/firmware/main.cpp index bd3a2af..619f1eb 100644 --- a/src/firmware/main.cpp +++ b/src/firmware/main.cpp @@ -10,6 +10,7 @@ #include "adapter/adapter_mode_controller.h" #include "input/bluepad32_input_backend.h" #include "platform/pico/bootsel_pairing_button.h" +#include "platform/pico/system_clock.h" #include "profile/controller_profile_runtime.h" #endif @@ -210,6 +211,9 @@ static void log_usb_state() { } int main() { +#ifdef SWITCH_PICO_BLUEPAD32 + system_clock_initialize(); +#endif board_init(); stdio_init_all(); diff --git a/src/firmware/platform/pico/cyw43_packet_transport.c b/src/firmware/platform/pico/cyw43_packet_transport.c new file mode 100644 index 0000000..d52a06a --- /dev/null +++ b/src/firmware/platform/pico/cyw43_packet_transport.c @@ -0,0 +1,182 @@ +/* + * Opt-in GNU --wrap=cyw43_btbus_read / --wrap=cyw43_btbus_init transport. + * Requires the build-local cybt_shared_bus_driver.c status-propagation patch; + * the stock driver's memory-read helpers discard SPI errors. + */ +#include +#include +#include + +#include "cyw43_btbus.h" +#include "cyw43_config.h" +#include "cybt_shared_bus_driver.h" + +#define PACKET_HEADER_SIZE 4u +#define RING_MASK (BTSDIO_FWBUF_SIZE - 1u) +#define RING_CAPACITY (BTSDIO_FWBUF_SIZE - 4u) +#define WAKE_RETRIES 300u + +/* + * Preserve cybt_hci_read's overflow check, not its cached indices. This lower + * bound is reset with every controller initialization, under the driver lock. + * All actual indices and packet availability are fetched afresh on each call. + */ +static uint32_t minimum_available; + +int __real_cyw43_btbus_init(cyw43_ll_t *self); + +int __wrap_cyw43_btbus_init(cyw43_ll_t *self) { + CYW43_THREAD_ENTER + minimum_available = 0; + int result = __real_cyw43_btbus_init(self); + CYW43_THREAD_EXIT + return result; +} + +static cybt_result_t request_bus(void) { + cybt_result_t result = cybt_set_bt_awake(true); + if (result != CYBT_SUCCESS) { + return result; + } + /* Match the SDK's bounded 301 polls and 1 ms wake-handshake delay. */ + for (uint32_t remaining = WAKE_RETRIES;; --remaining) { + int awake = cybt_awake(); + if (awake == 1) { + return CYBT_SUCCESS; + } + if (awake != 0) { + return CYBT_ERR_HCI_READ_FAILED; + } + cyw43_delay_ms(1); + if (remaining == 0) { + return CYBT_ERR_TIMEOUT; + } + } +} + +/* All offsets, lengths and destinations here are word aligned. */ +static cybt_result_t read_ring(uint32_t offset, uint8_t *buffer, uint32_t length) { + if (length == 0) { + return CYBT_SUCCESS; + } + uint32_t first = BTSDIO_FWBUF_SIZE - offset; + if (first > length) { + first = length; + } + cybt_result_t result = cybt_mem_read_idx(B2H_BUF_ADDR_IDX, offset, buffer, first); + if (result != CYBT_SUCCESS || first == length) { + return result; + } + return cybt_mem_read_idx(B2H_BUF_ADDR_IDX, 0, buffer + first, length - first); +} + +static cybt_result_t read_packet(uint8_t *buffer, uint32_t capacity, uint32_t *size) { + cybt_fw_membuf_index_t indices; + cybt_result_t result = cybt_get_bt_buf_index(&indices); + if (result != CYBT_SUCCESS) { + return result; + } + /* Keep the SDK's four-register corruption checks effective in release. */ + if (indices.host2bt_in_val >= BTSDIO_FWBUF_SIZE || + indices.host2bt_out_val >= BTSDIO_FWBUF_SIZE || + indices.bt2host_in_val >= BTSDIO_FWBUF_SIZE || + indices.bt2host_out_val >= BTSDIO_FWBUF_SIZE || + (indices.bt2host_out_val & 3u) != 0) { + return CYBT_ERR_HCI_READ_FAILED; + } + uint32_t available = (indices.bt2host_in_val - indices.bt2host_out_val) & RING_MASK; + if (available > RING_CAPACITY) { + return CYBT_ERR_HCI_READ_FAILED; + } + if (available < minimum_available) { + panic("cyw43 buffer overflow"); + } + minimum_available = available; + if (available < PACKET_HEADER_SIZE) { + /* No partial-header consumption and no polling for more packet data. */ + return CYBT_SUCCESS; + } + + result = read_ring(indices.bt2host_out_val, buffer, PACKET_HEADER_SIZE); + if (result != CYBT_SUCCESS) { + return result; + } + uint32_t payload_length = (uint32_t)buffer[0] | + ((uint32_t)buffer[1] << 8) | + ((uint32_t)buffer[2] << 16); + if (payload_length > capacity - PACKET_HEADER_SIZE || + payload_length > RING_CAPACITY - PACKET_HEADER_SIZE) { + return CYBT_ERR_BADARG; + } + uint32_t payload_wire_length = (payload_length + 3u) & ~3u; + uint32_t wire_length = PACKET_HEADER_SIZE + payload_wire_length; + if (wire_length > available) { + /* The producer may finish later; retain both header and payload. */ + return CYBT_SUCCESS; + } + + uint32_t payload_offset = (indices.bt2host_out_val + PACKET_HEADER_SIZE) & RING_MASK; + if (wire_length <= capacity) { + result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, payload_wire_length); + } else { + /* A valid unpadded capacity must not permit a 1-3 byte buffer overrun. */ + uint32_t whole_length = payload_length & ~3u; + result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, whole_length); + if (result == CYBT_SUCCESS) { + uint32_t last_word; + result = read_ring((payload_offset + whole_length) & RING_MASK, + (uint8_t *)&last_word, sizeof(last_word)); + if (result == CYBT_SUCCESS) { + memcpy(buffer + PACKET_HEADER_SIZE + whole_length, &last_word, + payload_length - whole_length); + } + } + } + if (result != CYBT_SUCCESS) { + return result; + } + + /* + * The producer publishes only readable bytes and may not overwrite unread + * bytes (the same ring ownership rule as the SDK). One availability snapshot + * therefore covers header, payload and padding until this commit point. + */ + uint32_t next = (indices.bt2host_out_val + wire_length) & RING_MASK; + result = cybt_reg_write_idx(B2H_BUF_OUT_ADDR_IDX, next); + if (result != CYBT_SUCCESS) { + return result; + } + minimum_available = available - wire_length; + /* + * DATA_VALID signals changed host ring state, not an RX IRQ acknowledgement: + * cyw43_ll_bt_has_work separately clears SDIO_INT_STATUS/I_HMB_FC_CHANGE. + * Empty/partial reads change no ring state and need no DATA_VALID toggle. + * The SDK's notification helper always succeeds (its register write is void). + * If that contract changes, a failure here cannot safely roll back the + * consumer: the controller already owns the reclaimed bytes. + */ + result = cybt_toggle_bt_intr(); + if (result != CYBT_SUCCESS) { + return result; + } + *size = PACKET_HEADER_SIZE + payload_length; + return CYBT_SUCCESS; +} + +int __wrap_cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size) { + if (size == NULL) { + return -1; + } + *size = 0; + if (buffer == NULL || capacity < PACKET_HEADER_SIZE || ((uintptr_t)buffer & 3u) != 0) { + return -1; + } + + CYW43_THREAD_ENTER + cybt_result_t result = request_bus(); + if (result == CYBT_SUCCESS) { + result = read_packet(buffer, capacity, size); + } + CYW43_THREAD_EXIT + return result == CYBT_SUCCESS ? 0 : -1; +} diff --git a/src/firmware/platform/pico/system_clock.cpp b/src/firmware/platform/pico/system_clock.cpp new file mode 100644 index 0000000..6c9dce6 --- /dev/null +++ b/src/firmware/platform/pico/system_clock.cpp @@ -0,0 +1,73 @@ +#include "platform/pico/system_clock.h" + +#include "hardware/adc.h" +#include "hardware/clocks.h" +#include "hardware/powman.h" +#include "hardware/regs/clocks.h" +#include "hardware/regs/qmi.h" +#include "hardware/structs/qmi.h" +#include "hardware/vreg.h" +#include "pico/stdlib.h" + +namespace { +SystemClockStatus g_status{}; +} + +void system_clock_initialize() { + static_assert(SWITCH_PICO_SYS_CLOCK_MHZ == 150 || + SWITCH_PICO_SYS_CLOCK_MHZ == 300 || + SWITCH_PICO_SYS_CLOCK_MHZ == 400); + // Flash timing was established by boot stage 2. Do not raise clk_sys if + // another boot configuration failed to provide the required divider. + const uint32_t flash_divider = + (qmi_hw->m[0].timing & QMI_M0_TIMING_CLKDIV_BITS) >> + QMI_M0_TIMING_CLKDIV_LSB; + if (flash_divider < (SWITCH_PICO_SYS_CLOCK_MHZ + 74u) / 75u) { + panic("unsafe overclock flash divider"); + } +#if SWITCH_PICO_SYS_CLOCK_MHZ == 150 + constexpr auto voltage = VREG_VOLTAGE_1_10; +#elif SWITCH_PICO_OVERCLOCK_MV == 1400 + constexpr auto voltage = VREG_VOLTAGE_1_40; + vreg_disable_voltage_limit(); +#else + constexpr auto voltage = VREG_VOLTAGE_1_30; +#endif + if (vreg_get_voltage() != voltage) { + vreg_set_voltage(voltage); + sleep_us(1000); + } +#if SWITCH_PICO_SYS_CLOCK_MHZ != 400 || SWITCH_PICO_OVERCLOCK_MV != 1400 + // A warm reboot from the explicit 1.4 V image must not leave its lifted + // voltage limit behind. Lower voltage before reinstating the limit. + powman_clear_bits(&powman_hw->vreg_ctrl, + POWMAN_VREG_CTRL_DISABLE_VOLTAGE_LIMIT_BITS); +#endif +#if SWITCH_PICO_SYS_CLOCK_MHZ != 150 + set_sys_clock_khz(SWITCH_PICO_SYS_CLOCK_MHZ * 1000u, true); +#endif + g_status.requested_sys_khz = SWITCH_PICO_SYS_CLOCK_MHZ * 1000u; + g_status.measured_sys_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS); + g_status.measured_usb_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_USB); + // Regulator setting, not an externally calibrated voltage measurement. + g_status.core_voltage_mv = 550u + 50u * vreg_get_voltage(); + g_status.flash_clock_divider = flash_divider; + g_status.cyw43_pio_divider256 = + CYW43_PIO_CLOCK_DIV_INT * 256u + CYW43_PIO_CLOCK_DIV_FRAC8; + adc_init(); + adc_set_temp_sensor_enabled(true); + adc_select_input(4); +} + +SystemClockStatus system_clock_status() { + uint32_t sum = 0; + for (unsigned sample = 0; sample < 8; ++sample) sum += adc_read(); + // Datasheet nominal transfer: 27 C - (Vadc - 0.706 V) / 0.001721. + // The 3.3 V reference and sensor offset make this an estimate, not a + // calibrated thermal qualification. Use signed integer microvolts. + const int32_t microvolts = static_cast( + (uint64_t{sum} * 3300000u + 16384u) / 32768u); + g_status.temperature_millicelsius = + 27000 - static_cast((int64_t{microvolts - 706000} * 1000) / 1721); + return g_status; +} diff --git a/src/firmware/platform/pico/system_clock.h b/src/firmware/platform/pico/system_clock.h new file mode 100644 index 0000000..749c936 --- /dev/null +++ b/src/firmware/platform/pico/system_clock.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +struct SystemClockStatus { + uint32_t requested_sys_khz; + uint32_t measured_sys_khz; + uint32_t measured_usb_khz; + uint32_t core_voltage_mv; + uint32_t flash_clock_divider; + uint32_t cyw43_pio_divider256; + int32_t temperature_millicelsius; +}; + +// Core 0, before board/peripheral initialization and before launching core 1. +void system_clock_initialize(); +// Core 0 only; reads the dedicated on-chip temperature ADC channel. +SystemClockStatus system_clock_status(); diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index f00d804..df14959 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -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(profile.shift.mode) > + static_cast(ControllerProfileShiftMode::kToggle) || + (profile.shift.mode != ControllerProfileShiftMode::kOff && + profile.shift.modifier == CONTROLLER_PROFILE_NO_BUTTON) || + !valid_turbo_settings(profile.turbo_defaults)) { + return false; + } + uint16_t selectors = 0; + for (uint8_t selector : profile.shortcuts.selectors) { + if (selector == CONTROLLER_PROFILE_NO_BUTTON) { + continue; + } + if (!(selector < 4 || (selector >= 12 && selector < 16)) || + selector == profile.shortcuts.modifier || + profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON || + (selectors & (1u << selector)) != 0) { + return false; + } + selectors |= static_cast(1u << selector); + } + for (uint8_t output : profile.shift.button_map) { + if (!valid_button(output)) { + return false; + } + } + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0 && + !valid_turbo_settings(profile.turbo_overrides[button])) { + return false; + } + } for (const ControllerProfileStickConfiguration& stick : profile.sticks) { if (stick.inner_deadzone >= stick.outer_saturation || stick.outer_saturation > 32767 || stick.curve_q8_8 == 0) { @@ -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(macro.mode) > + static_cast(ControllerProfileMacroMode::kRepeat) || + macro.repeat_count == 0 || macro.first_step != expected_first_step || macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO || macro.step_count > @@ -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(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(mode) > - static_cast(ControllerProfileTurboMode::kAutoBurst)) { + static_cast(ControllerProfileTurboMode::kBurst)) { return false; } } @@ -449,6 +501,30 @@ bool controller_profile_encode(const ControllerProfile& profile, } descriptor[5] = static_cast(stream_offset - macro_start); + output[336 + macro_index * 2] = static_cast(macro.mode); + output[337 + macro_index * 2] = macro.repeat_count; + } + output[256] = profile.shortcuts.modifier; + memcpy(&output[257], profile.shortcuts.selectors, + CONTROLLER_PROFILE_COUNT); + output[265] = static_cast(profile.shift.mode); + output[266] = profile.shift.modifier; + memcpy(&output[267], profile.shift.button_map, + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + output[283] = profile.turbo_defaults.rate_hz; + output[284] = profile.turbo_defaults.duty_percent; + output[285] = profile.turbo_defaults.burst_count; + profile_write_u16(&output[286], profile.turbo_override_mask); + size_t settings_offset = 288; + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0) { + const ControllerProfileTurboSettings& settings = + profile.turbo_overrides[button]; + output[settings_offset++] = settings.rate_hz; + output[settings_offset++] = settings.duty_percent; + output[settings_offset++] = settings.burst_count; + } } return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } @@ -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( + 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(legacy_step_count - 1u); profile.macro_step_count = macro.step_count; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].first_step = profile.macro_step_count; + } for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { profile.turbo_modes[index] = static_cast(input[82 + index]); } } + if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + profile.shortcuts.modifier = input[256]; + memcpy(profile.shortcuts.selectors, &input[257], + CONTROLLER_PROFILE_COUNT); + profile.shift.mode = static_cast(input[265]); + profile.shift.modifier = input[266]; + memcpy(profile.shift.button_map, &input[267], + CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT); + profile.turbo_defaults = {input[283], input[284], input[285]}; + profile.turbo_override_mask = profile_read_u16(&input[286]); + size_t settings_offset = 288; + for (uint8_t button = 0; + button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { + if ((profile.turbo_override_mask & (1u << button)) != 0) { + profile.turbo_overrides[button] = { + input[settings_offset], input[settings_offset + 1], + input[settings_offset + 2]}; + settings_offset += 3; + } + } + if (!profile_bytes_are_zero( + &input[settings_offset], 336 - settings_offset) || + !profile_bytes_are_zero(&input[344], 40)) { + return false; + } + } else { + for (ControllerProfileTurboMode mode : profile.turbo_modes) { + if (static_cast(mode) > + static_cast(ControllerProfileTurboMode::kAutoBurst)) { + return false; + } + } + } if (!controller_profile_validate(profile)) { return false; } @@ -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; diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index d767a88..c745842 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -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 { diff --git a/src/firmware/profile/controller_profile_runtime.cpp b/src/firmware/profile/controller_profile_runtime.cpp index 72acf57..158aaac 100644 --- a/src/firmware/profile/controller_profile_runtime.cpp +++ b/src/firmware/profile/controller_profile_runtime.cpp @@ -8,6 +8,7 @@ namespace { struct ControllerProfileRuntimeContext { bool active = false; + bool connection_seen = false; uint32_t connection_generation = 0; ControllerIdentity identity{}; uint32_t database_generation = 0; @@ -18,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( @@ -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( - (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( - (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( + (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( diff --git a/src/firmware/profile/controller_profile_runtime.h b/src/firmware/profile/controller_profile_runtime.h index 21fa0d5..d39396a 100644 --- a/src/firmware/profile/controller_profile_runtime.h +++ b/src/firmware/profile/controller_profile_runtime.h @@ -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, diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index fb8cec0..97c67eb 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -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(1u << input)) == 0) { continue; } - const uint8_t output = profile.button_map[input]; + const uint8_t output = selected_map[input]; if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { output_button_mask |= static_cast(1u << output); } @@ -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(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[ diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 68a77a9..3cea832 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -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); diff --git a/src/firmware/profile/controller_synthetic_input.cpp b/src/firmware/profile/controller_synthetic_input.cpp index 2b0cfdd..f95ecf5 100644 --- a/src/firmware/profile/controller_synthetic_input.cpp +++ b/src/firmware/profile/controller_synthetic_input.cpp @@ -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(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(binding->phase_units) + - static_cast(elapsed_ms) * kTurboTransitionsPerSecond; - const uint64_t transition_count = - total_units / kTurboPhaseUnitsPerTransition; - if ((transition_count & 1u) != 0) { - binding->phase_on = !binding->phase_on; - } - binding->phase_units = static_cast( - total_units - - transition_count * kTurboPhaseUnitsPerTransition); + static_cast(elapsed_ms) * settings.rate_hz; + const uint64_t cycles = total_units / kTurboCycleUnits; + binding->phase_units = + static_cast(total_units % kTurboCycleUnits); + binding->phase_on = binding->phase_units < + static_cast(settings.duty_percent) * 10u; binding->last_update_ms = now_ms; -} - -bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) { - return now_ms - deadline_ms < (UINT32_MAX / 2u + 1u); + if (binding->burst_windows_remaining != 0) { + if (cycles >= binding->burst_windows_remaining) { + clear_binding(binding); + return; + } + binding->burst_windows_remaining = static_cast( + binding->burst_windows_remaining - cycles); + if (binding->burst_windows_remaining == 1 && !binding->phase_on) { + clear_binding(binding); + } + } } void stop_macro(ControllerSyntheticInputContext* context) { context->macro_active = false; context->macro_index = 0; context->macro_step_index = 0; - context->macro_deadline_ms = 0; + context->macro_cycle_duration_ms = 0; + context->macro_cycle_elapsed_ms = 0; + context->macro_last_update_ms = 0; + context->macro_cycles_remaining = 0; } bool start_macro(ControllerSyntheticInputContext* context, @@ -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(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(context->macro_cycle_elapsed_ms) + elapsed_ms; + const uint64_t cycles = total_ms / duration_ms; + if (context->macro_cycles_remaining != 0) { + if (cycles >= context->macro_cycles_remaining) { + stop_macro(context); + return; + } + context->macro_cycles_remaining = static_cast( + context->macro_cycles_remaining - cycles); + } + context->macro_cycle_elapsed_ms = + static_cast(total_ms % duration_ms); + context->macro_last_update_ms = now_ms; + const ControllerProfileMacro& macro = profile.macros[context->macro_index]; + uint32_t remaining_ms = context->macro_cycle_elapsed_ms; + for (uint8_t step = 0; + step < macro.step_count && + step < CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO; ++step) { + const uint16_t step_duration = + profile.macro_steps[macro.first_step + step].duration_ms; + if (remaining_ms < step_duration) { + context->macro_step_index = step; + return; + } + remaining_ms -= step_duration; + } + stop_macro(context); } void apply_macro_override(const ControllerProfileMacroStep& step, @@ -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; } diff --git a/src/firmware/profile/controller_synthetic_input.h b/src/firmware/profile/controller_synthetic_input.h index 1ddbbb9..b2f4fe5 100644 --- a/src/firmware/profile/controller_synthetic_input.h +++ b/src/firmware/profile/controller_synthetic_input.h @@ -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); diff --git a/src/firmware/profile/profile_storage.cpp b/src/firmware/profile/profile_storage.cpp index 9d0a16a..45180eb 100644 --- a/src/firmware/profile/profile_storage.cpp +++ b/src/firmware/profile/profile_storage.cpp @@ -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(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(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(record[6]); + const uint8_t profile_index = record[7]; + const size_t payload_size = read_u16(&record[12]); + const uint8_t *payload = &record[payload_offset]; + ControllerIdentity identity_value{}; + const bool known_type = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kResetAll || type == RecordType::kActivate || + type == RecordType::kAlias || type == RecordType::kProfileNames; + const bool indexed_profile = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kActivate; + const size_t expected_payload_size = + type == RecordType::kProfile + ? (version == kLegacyCatalogVersion + ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE + : CONTROLLER_PROFILE_ENCODED_SIZE) + : type == RecordType::kProfileNames + ? PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE + : type == RecordType::kAlias + ? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE + : 0; + if (!controller_identity_decode( + &record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || + !valid_identity(identity_value) || !known_type || + (indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) || + payload_size != expected_payload_size || + profile_storage_crc32(payload, payload_size) != read_u32(&record[16])) { + return false; + } + if (type == RecordType::kProfile) { + ControllerProfile decoded{}; + return read_u16(&record[14]) == read_u16(payload) && + controller_profile_decode(payload, payload_size, &decoded); + } + if (read_u16(&record[14]) != 0) { + return false; + } + if (type == RecordType::kAlias) { + return metadata_value_valid(payload, payload_size); + } + if (type == RecordType::kProfileNames) { + for (size_t name = 0; name < CONTROLLER_PROFILE_COUNT; ++name) { + if (!metadata_value_valid( + &payload[name * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE], + PROFILE_STORAGE_METADATA_PAYLOAD_SIZE)) { + return false; + } + } + } + return true; +} + +bool ProfileStorage::read_record_payload( + uint32_t record, RecordType type, uint8_t *output, size_t capacity, + size_t *size) const { + uint8_t header[kRecordHeaderCrcOffset + 4]{}; + if (record == PROFILE_STORAGE_NO_RECORD || + !io_.read(io_.context, record_arena(record), record_offset(record), + header, sizeof(header)) || + memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || + header[6] != static_cast(type) || + profile_storage_crc32(header, kRecordHeaderCrcOffset) != + read_u32(&header[kRecordHeaderCrcOffset])) { + return false; + } + const uint16_t version = read_u16(&header[4]); + const size_t payload_offset = version == kLegacyCatalogVersion + ? kLegacyRecordPayloadOffset + : kRecordPayloadOffset; + const size_t payload_size = read_u16(&header[12]); + if ((version != kCatalogVersion && version != kLegacyCatalogVersion) || + payload_size > capacity || + payload_size > PROFILE_STORAGE_RECORD_SIZE - payload_offset || + !io_.read(io_.context, record_arena(record), + record_offset(record) + payload_offset, output, payload_size) || + profile_storage_crc32(output, payload_size) != read_u32(&header[16])) { + return false; + } + if (size != nullptr) { + *size = payload_size; + } return true; } bool ProfileStorage::read_profile_record(uint32_t record, ControllerProfile *output) const { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - 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(type); - header[7] = profile_index; - write_u32(&header[8], generation); - write_u16(&header[12], static_cast(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(type); + record[7] = profile_index; + write_u32(&record[8], generation); + write_u16(&record[12], static_cast(payload_size)); + write_u16(&record[14], type == RecordType::kProfile ? read_u16(payload) : 0); + write_u32(&record[16], profile_storage_crc32(payload, payload_size)); + if (!controller_identity_encode(identity, &record[20], CONTROLLER_IDENTITY_ENCODED_SIZE)) { return false; } - write_u32(&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, diff --git a/src/firmware/profile/profile_storage.h b/src/firmware/profile/profile_storage.h index e480262..1342c9c 100644 --- a/src/firmware/profile/profile_storage.h +++ b/src/firmware/profile/profile_storage.h @@ -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; }; diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 88fdadf..ed6adcd 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -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(input[0]) | static_cast(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(capture.state); + payload[14] = capture.options.channels; + payload[15] = capture.event_count; + write_u16(payload + 16, capture.total_events); + write_u16(payload + 18, capture.first_index); + write_u16(payload + 20, capture.options.axis_quantum); + write_u16(payload + 22, capture.options.trigger_quantum); + write_u32(payload + 24, capture.options.max_duration_ms); + payload[28] = capture.options.max_events; + for (uint8_t index = 0; index < capture.event_count; ++index) { + const CaptureEvent& event = capture.events[index]; + uint8_t* encoded = payload + kMacroCaptureHeaderSize + + index * kMacroCaptureEventSize; + write_u32(encoded, event.at_us); + write_u16(encoded + 4, event.buttons); + write_u16(encoded + 6, static_cast(event.left_x)); + write_u16(encoded + 8, static_cast(event.left_y)); + write_u16(encoded + 10, static_cast(event.right_x)); + write_u16(encoded + 12, static_cast(event.right_y)); + write_u16(encoded + 14, event.left_trigger); + write_u16(encoded + 16, event.right_trigger); + } + return encode_response( + Operation::kMacroCapture, Status::kOk, 0, kMacroCaptureSchemaVersion, + capture.run_id, payload, + kMacroCaptureHeaderSize + capture.event_count * kMacroCaptureEventSize, + output, output_size); +} + size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) { #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT HapticsTransportProbe probe{}; @@ -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(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(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); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 5d1a3ac..e479646 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -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 { diff --git a/src/firmware/usb/usb_output_driver.cpp b/src/firmware/usb/usb_output_driver.cpp index ab77939..8083beb 100644 --- a/src/firmware/usb/usb_output_driver.cpp +++ b/src/firmware/usb/usb_output_driver.cpp @@ -56,6 +56,7 @@ bool switch_selected() { void usb_output_driver_init(AdapterUsbMode mode) { #ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); g_mode = mode; #else (void)mode; @@ -384,11 +385,21 @@ extern "C" void tud_mount_cb() { extern "C" void tud_umount_cb() { LOG_PRINTF("[USB] umount_cb\n"); +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); +#endif if (switch_selected()) { switch_pro_unmount(); } } +extern "C" void tud_suspend_cb(bool remote_wakeup_en) { + (void)remote_wakeup_en; +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) xinput_stop_rumble(); +#endif +} + extern "C" usbd_class_driver_t const* usbd_app_driver_get_cb( uint8_t* driver_count) { if (driver_count == nullptr) { diff --git a/src/firmware/usb/xinput/xinput_driver.cpp b/src/firmware/usb/xinput/xinput_driver.cpp index 6a3a5bd..d1d4957 100644 --- a/src/firmware/usb/xinput/xinput_driver.cpp +++ b/src/firmware/usb/xinput/xinput_driver.cpp @@ -20,6 +20,7 @@ struct XInputContext { uint8_t endpoint_in = 0; uint8_t endpoint_out = 0; bool configured = false; + bool host_rumble_active = false; }; XInputContext g_contexts[SWITCH_PICO_HID_INSTANCE_COUNT]{}; @@ -41,7 +42,18 @@ XInputContext *context_for_endpoint(uint8_t endpoint) { return nullptr; } +void stop_context_rumble(XInputContext& context) { + if (!context.host_rumble_active) return; + context.host_rumble_active = false; + if (context.rumble_callback != nullptr) { + context.rumble_callback(static_cast(&context - g_contexts), + ControllerRumbleOutput{}); + } +} + void reset_context(XInputContext &context) { + context.configured = false; + stop_context_rumble(context); const ControllerRumbleCallback callback = context.rumble_callback; context = {}; context.rumble_callback = callback; @@ -133,7 +145,7 @@ bool driver_control(uint8_t rhport, uint8_t stage, bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, uint32_t transferred) { XInputContext *context = context_for_endpoint(endpoint); - if (context == nullptr || result != XFER_RESULT_SUCCESS) { + if (context == nullptr || !context->configured || result != XFER_RESULT_SUCCESS) { return false; } if (endpoint == context->endpoint_out) { @@ -142,6 +154,8 @@ bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, &rumble) && context->rumble_callback != nullptr) { const uint8_t instance = static_cast(context - g_contexts); + context->host_rumble_active = rumble.low_frequency_magnitude != 0 || + rumble.high_frequency_magnitude != 0; context->rumble_callback(instance, rumble); } memset(context->output_report, 0, sizeof(context->output_report)); @@ -205,6 +219,10 @@ bool xinput_is_ready(uint8_t instance) { return context != nullptr && context->configured && tud_ready(); } +void xinput_stop_rumble() { + for (XInputContext& context : g_contexts) stop_context_rumble(context); +} + usbd_class_driver_t const* xinput_class_driver() { return &kDriver; } diff --git a/src/firmware/usb/xinput/xinput_driver.h b/src/firmware/usb/xinput/xinput_driver.h index e4fe683..b9bb0f3 100644 --- a/src/firmware/usb/xinput/xinput_driver.h +++ b/src/firmware/usb/xinput/xinput_driver.h @@ -12,5 +12,7 @@ void xinput_set_rumble_callback(uint8_t instance, void xinput_set_input(uint8_t instance, const ControllerState& state); bool xinput_task(uint8_t instance); bool xinput_is_ready(uint8_t instance); +// Stop held host output on USB reset, unmount, or suspend. +void xinput_stop_rumble(); usbd_class_driver_t const* xinput_class_driver(); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 436cb3a..4a42527 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -66,6 +66,16 @@ OP_PROFILE_METADATA_SET = 0x3B OP_PROFILE_IDENTIFY = 0x3C OP_HAPTICS_EXPERIMENT = 0x40 OP_HAPTICS_TRANSPORT_PROBE = 0x41 +OP_MACRO_CAPTURE = 0x42 +MACRO_CAPTURE_SCHEMA_VERSION = 1 +MACRO_CAPTURE_STATES = ( + "idle", + "recording", + "stopped", + "full", + "timed_out", + "disconnected", +) STATUS_OK = 0 STATUS_PENDING = 1 @@ -111,8 +121,10 @@ PROFILE_LEGACY_SCHEMA_VERSION = 1 PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 -PROFILE_SCHEMA_VERSION = 5 -PROFILE_SIZE = 256 +PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 +PROFILE_SCHEMA_VERSION = 6 +PROFILE_LEGACY_SIZE = 256 +PROFILE_SIZE = 384 PROFILE_CAPACITY = 8 PROFILE_IDENTITY_CAPACITY = 16 PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1 @@ -128,6 +140,14 @@ PROFILE_MACRO_STEP_SIZE = 19 PROFILE_MAXIMUM_WAIT_MS = 10000 PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934 +PROFILE_TURBO_RATE_MIN = 1 +PROFILE_TURBO_RATE_MAX = 30 +PROFILE_TURBO_DUTY_MIN = 1 +PROFILE_TURBO_DUTY_MAX = 99 +PROFILE_TURBO_BURST_MIN = 1 +PROFILE_TURBO_BURST_MAX = 255 +PROFILE_MACRO_REPEAT_MIN = 1 +PROFILE_MACRO_REPEAT_MAX = 255 PROFILE_PLAYTEST_SCHEMA_VERSION = 2 PROFILE_PLAYTEST_SIZE = 54 PROFILE_PLAYTEST_SLOT_COUNT = 4 @@ -135,14 +155,20 @@ PROFILE_METADATA_SCHEMA_VERSION = 1 PROFILE_METADATA_MAX_BYTES = 31 PROFILE_METADATA_VALUE_SIZE = 32 PROFILE_METADATA_SIZE = 288 -HAPTICS_EXPERIMENT_SCHEMA_VERSION = 3 +HAPTICS_EXPERIMENT_SCHEMA_VERSION = 5 HAPTICS_EXPERIMENT_SIZE = 84 HAPTICS_EXPERIMENT_SLOT_COUNT = 4 -HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION = 2 -HAPTICS_TRANSPORT_PROBE_SIZE = 128 +HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION = 3 +HAPTICS_TRANSPORT_PROBE_SIZE = 176 HAPTICS_EXPERIMENT_STATES = ( - "idle", "pending", "running", "completed", - "stopped", "disconnected", "unsupported", "error", + "idle", + "pending", + "running", + "completed", + "stopped", + "disconnected", + "unsupported", + "error", ) HAPTICS_EXPERIMENT_MODES = ("fixture", "gameplay") HAPTICS_EXPERIMENT_ERRORS = { @@ -171,15 +197,18 @@ HAPTICS_GAMEPLAY_EVIDENCE_NOTE = ( ) HAPTICS_GAMEPLAY_ARMING_NOTE = ( "PC-controlled gameplay arming does not persist across power cycles. " - "Firmware built with SWITCH_PICO_HD_RUMBLE=ON automatically arms only " - "slot 0 by default; use gameplay --slot to select another controller." + "Firmware built with SWITCH_PICO_HD_RUMBLE=ON automatically arms the first " + "eligible DualSense that becomes ready, in any slot. One native stream is " + "selected at a time; use gameplay --slot to select another controller." ) HAPTICS_GAMEPLAY_TIMING = { "sample_rate_hz": 3000, "stereo_frames_per_packet": 64, "lookback_us": 64000000 / 3000, - "command_window_us": 8000, - "watchdog_us": 50000, + "switch_command_window_us": 8000, + "switch_watchdog_us": 50000, + "xinput_command_policy": "held_until_changed_or_stopped", + "xinput_carrier_hz": {"left_low": 160, "right_high": 320}, "band_gains": {"low": 2.0, "high": 2.0}, "response_exponent": 0.8, } @@ -235,7 +264,10 @@ LOGICAL_BUTTONS = ( LOGICAL_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") -TURBO_MODES = ("off", "turbo", "auto_burst") +TURBO_MODES = ("off", "turbo", "auto_burst", "burst") +SHIFT_MODES = ("off", "hold", "toggle") +MACRO_PLAYBACK_MODES = ("once", "while_held", "toggle", "repeat") +SHORTCUT_SELECTOR_BUTTONS = LOGICAL_BUTTONS[:4] + LOGICAL_BUTTONS[12:] MACRO_STEP_TYPES = ("state", "end") MACRO_OVERRIDE_NAMES = ( "buttons", @@ -264,8 +296,7 @@ class UsbDevice(Protocol): index: int = 0, data_or_w_length: Any = None, timeout: int | None = None, - ) -> Any: - ... + ) -> Any: ... @dataclass(frozen=True) @@ -350,6 +381,8 @@ class HapticsExperimentDiagnostics: mode: int host_updates: int dropped_updates: int + packet_frames: int = 64 + last_packet_nonzero: bool = False @property def state_name(self) -> str: @@ -386,7 +419,11 @@ class HapticsExperimentDiagnostics: "first_tone_submission_delay_us": self.first_tone_submission_delay_us, } if self.mode == 1: - values["gameplay"] = HAPTICS_GAMEPLAY_TIMING + values["gameplay"] = { + **HAPTICS_GAMEPLAY_TIMING, + "stereo_frames_per_packet": self.packet_frames, + "lookback_us": self.packet_frames * 1000000 / 3000, + } values["evidence_note"] = HAPTICS_GAMEPLAY_EVIDENCE_NOTE values["arming_note"] = HAPTICS_GAMEPLAY_ARMING_NOTE else: @@ -395,6 +432,42 @@ class HapticsExperimentDiagnostics: return values +@dataclass(frozen=True) +class MacroCaptureEvent: + at_us: int + buttons: int + left_x: int + left_y: int + right_x: int + right_y: int + left_trigger: int + right_trigger: int + + +@dataclass(frozen=True) +class MacroCapturePage: + run_id: int + connection_generation: int + elapsed_us: int + slot: int + state: int + channels: int + total_events: int + first_index: int + axis_quantum: int + trigger_quantum: int + max_duration_ms: int + max_events: int + events: tuple[MacroCaptureEvent, ...] + + @property + def state_name(self) -> str: + return MACRO_CAPTURE_STATES[self.state] + + def to_json_object(self) -> dict[str, Any]: + return {**asdict(self), "state_name": self.state_name} + + @dataclass(frozen=True) class HapticsTransportProbe: run_id: int @@ -429,6 +502,18 @@ class HapticsTransportProbe: max_poll_gap_us: int controller_acl_packet_bytes: int controller_acl_packet_count: int + requested_sys_khz: int + measured_sys_khz: int + measured_usb_khz: int + core_voltage_mv: int + flash_clock_divider: int + cyw43_pio_divider256: int + temperature_millicelsius: int + host_completed_writes: int + acl_writes: int + other_writes: int + write_failures: int + packet_read_optimized: int def to_json_object(self) -> dict[str, Any]: return { @@ -478,9 +563,7 @@ class PairingRecord: 2: "public identity", 3: "random identity", } - suffix = address_types.get( - self.address_type, f"type {self.address_type}" - ) + suffix = address_types.get(self.address_type, f"type {self.address_type}") return f"BLE ({suffix})" return f"unknown transport {self.transport}" @@ -507,9 +590,7 @@ def _require_bool(value: Any, name: str) -> bool: return value -def _require_object( - value: Any, fields: Sequence[str], name: str -) -> dict[str, Any]: +def _require_object(value: Any, fields: Sequence[str], name: str) -> dict[str, Any]: if type(value) is not dict: raise ConfigManagerError(f"{name} must be a JSON object") expected = set(fields) @@ -528,9 +609,7 @@ def _require_object( def _require_enum(value: Any, choices: Sequence[str], name: str) -> int: if type(value) is not str or value not in choices: - raise ConfigManagerError( - f"{name} must be one of {', '.join(choices)}" - ) + raise ConfigManagerError(f"{name} must be one of {', '.join(choices)}") return choices.index(value) @@ -538,9 +617,7 @@ def _button_index(value: Any, name: str) -> int: if value is None: return PROFILE_NONE_BUTTON if type(value) is not str or value not in LOGICAL_BUTTONS: - raise ConfigManagerError( - f"{name} must be a logical button name or null" - ) + raise ConfigManagerError(f"{name} must be a logical button name or null") return LOGICAL_BUTTONS.index(value) @@ -549,6 +626,7 @@ def _button_name(value: int) -> str | None: return None return LOGICAL_BUTTONS[value] + def _control_index(value: Any, name: str) -> int: if value is None: return PROFILE_NONE_BUTTON @@ -564,7 +642,6 @@ def _control_name(value: int) -> str | None: return LOGICAL_CONTROLS[value] - def _button_mask_from_json(value: Any, name: str) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") @@ -579,6 +656,7 @@ def _button_mask_from_json(value: Any, name: str) -> int: mask |= bit return mask + def _control_mask_from_json(value: Any, name: str) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") @@ -595,19 +673,11 @@ def _control_mask_from_json(value: Any, name: str) -> int: def _control_mask_to_json(mask: int) -> list[str]: - return [ - name - for index, name in enumerate(LOGICAL_CONTROLS) - if mask & (1 << index) - ] + return [name for index, name in enumerate(LOGICAL_CONTROLS) if mask & (1 << index)] def _button_mask_to_json(mask: int) -> list[str]: - return [ - name - for index, name in enumerate(LOGICAL_BUTTONS) - if mask & (1 << index) - ] + return [name for index, name in enumerate(LOGICAL_BUTTONS) if mask & (1 << index)] @dataclass(frozen=True) @@ -713,9 +783,11 @@ class ProfileListEntry: 0, PROFILE_CAPACITY - 1, ) - if type(self.alias) is not str or "\x00" in self.alias or len( - self.alias.encode("utf-8") - ) > PROFILE_METADATA_MAX_BYTES: + if ( + type(self.alias) is not str + or "\x00" in self.alias + or len(self.alias.encode("utf-8")) > PROFILE_METADATA_MAX_BYTES + ): raise ConfigManagerError( "controller alias must contain at most 31 UTF-8 bytes" ) @@ -736,12 +808,12 @@ class ProfileMetadata: for index, name in enumerate(self.profile_names) ), ): - if type(value) is not str or "\x00" in value or len( - value.encode("utf-8") - ) > PROFILE_METADATA_MAX_BYTES: - raise ConfigManagerError( - f"{label} must contain at most 31 UTF-8 bytes" - ) + if ( + type(value) is not str + or "\x00" in value + or len(value.encode("utf-8")) > PROFILE_METADATA_MAX_BYTES + ): + raise ConfigManagerError(f"{label} must contain at most 31 UTF-8 bytes") @dataclass(frozen=True) @@ -789,14 +861,14 @@ class ProfilePlaytest: "right": self.triggers[1], }, "battery": ( - round((self.battery - 1) / 250 * 100) - if self.battery != 0 - else None + round((self.battery - 1) / 250 * 100) if self.battery != 0 else None ), "capabilities": [ - name for bit, name in enumerate( + name + for bit, name in enumerate( ("rumble", "lightbar", "player_leds", "motion") - ) if self.capabilities & (1 << bit) + ) + if self.capabilities & (1 << bit) ], "motion": ( { @@ -808,6 +880,7 @@ class ProfilePlaytest: ), } + @dataclass(frozen=True) class StickConfig: center_x: int @@ -821,12 +894,8 @@ class StickConfig: def __post_init__(self) -> None: _require_int(self.center_x, "stick center_x", -0x8000, 0x7FFF) _require_int(self.center_y, "stick center_y", -0x8000, 0x7FFF) - _require_int( - self.inner_deadzone, "stick inner_deadzone", 0, 0x7FFF - ) - _require_int( - self.outer_saturation, "stick outer_saturation", 1, 0x7FFF - ) + _require_int(self.inner_deadzone, "stick inner_deadzone", 0, 0x7FFF) + _require_int(self.outer_saturation, "stick outer_saturation", 1, 0x7FFF) if self.inner_deadzone >= self.outer_saturation: raise ConfigManagerError( "stick inner_deadzone must be below outer_saturation" @@ -904,9 +973,7 @@ class StickConfig: 1, 0x7FFF, ), - _require_int( - obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF - ), + _require_int(obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF), _require_bool(obj["invert_x"], f"{name}.invert_x"), _require_bool(obj["invert_y"], f"{name}.invert_y"), ) @@ -921,20 +988,14 @@ class TriggerConfig: output: int def __post_init__(self) -> None: - _require_int( - self.lower_deadzone, "trigger lower_deadzone", 0, 0xFFFF - ) - _require_int( - self.upper_saturation, "trigger upper_saturation", 1, 0xFFFF - ) + _require_int(self.lower_deadzone, "trigger lower_deadzone", 0, 0xFFFF) + _require_int(self.upper_saturation, "trigger upper_saturation", 1, 0xFFFF) if self.lower_deadzone >= self.upper_saturation: raise ConfigManagerError( "trigger lower_deadzone must be below upper_saturation" ) _require_int(self.curve_q8_8, "trigger curve_q8_8", 1, 0xFFFF) - _require_int( - self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF - ) + _require_int(self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF) if ( type(self.output) is not int or self.output != PROFILE_NONE_BUTTON @@ -952,22 +1013,15 @@ class TriggerConfig: ) -> TriggerConfig: if len(payload) != 10 or payload[9] != 0: raise ConfigManagerError("invalid trigger configuration encoding") - if ( - schema_version < PROFILE_CONTROL_MAPPING_SCHEMA_VERSION - and payload[8] != 0 - ): + if schema_version < PROFILE_CONTROL_MAPPING_SCHEMA_VERSION and payload[8] != 0: raise ConfigManagerError("invalid legacy trigger configuration") output = ( payload[8] if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION else len(LOGICAL_BUTTONS) + source_index ) - lower, upper, curve_q8_8, threshold = struct.unpack( - " bytes: return struct.pack( @@ -1025,9 +1079,7 @@ class TriggerConfig: 1, 0xFFFF, ), - _require_int( - obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF - ), + _require_int(obj["curve_q8_8"], f"{name}.curve_q8_8", 1, 0xFFFF), _require_int( obj["digital_threshold"], f"{name}.digital_threshold", @@ -1039,10 +1091,7 @@ class TriggerConfig: def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: - if ( - trigger.digital_threshold - != PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD - ): + if trigger.digital_threshold != PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD: return trigger return TriggerConfig( trigger.lower_deadzone, @@ -1077,18 +1126,14 @@ class MacroStep: 0, PROFILE_MAXIMUM_WAIT_MS, ) - _require_int( - self.output_button_mask, "macro output button mask", 0, 0xFFFF - ) + _require_int(self.output_button_mask, "macro output button mask", 0, 0xFFFF) for name in ( "left_stick_x", "left_stick_y", "right_stick_x", "right_stick_y", ): - _require_int( - getattr(self, name), f"macro {name}", -0x8000, 0x7FFF - ) + _require_int(getattr(self, name), f"macro {name}", -0x8000, 0x7FFF) _require_int(self.left_trigger, "macro left_trigger", 0, 0xFFFF) _require_int(self.right_trigger, "macro right_trigger", 0, 0xFFFF) if self.step_type == 1 and any( @@ -1106,23 +1151,16 @@ class MacroStep: ): raise ConfigManagerError("end macro step must otherwise be zero") if self.step_type == 0: - if ( - not self.override_flags & 1 - and self.output_button_mask != 0 - ): - raise ConfigManagerError( - "macro buttons require the buttons override" - ) - if ( - not self.override_flags & 2 - and (self.left_stick_x != 0 or self.left_stick_y != 0) + if not self.override_flags & 1 and self.output_button_mask != 0: + raise ConfigManagerError("macro buttons require the buttons override") + if not self.override_flags & 2 and ( + self.left_stick_x != 0 or self.left_stick_y != 0 ): raise ConfigManagerError( "macro left stick values require the left_stick override" ) - if ( - not self.override_flags & 4 - and (self.right_stick_x != 0 or self.right_stick_y != 0) + if not self.override_flags & 4 and ( + self.right_stick_x != 0 or self.right_stick_y != 0 ): raise ConfigManagerError( "macro right stick values require the right_stick override" @@ -1179,9 +1217,7 @@ class MacroStep: return bytes(payload) @classmethod - def from_sparse_bytes( - cls, payload: bytes, name: str - ) -> tuple[MacroStep, int]: + def from_sparse_bytes(cls, payload: bytes, name: str) -> tuple[MacroStep, int]: if len(payload) < 3: raise ConfigManagerError(f"{name} is truncated") flags, duration = struct.unpack_from(" None: + if type(self.modifier) is not int or ( + self.modifier != PROFILE_NONE_BUTTON + and not 0 <= self.modifier < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid shortcut modifier") + if type(self.profiles) is not tuple or len(self.profiles) != PROFILE_CAPACITY: + raise ConfigManagerError("shortcuts must contain eight profile selectors") + selected: set[int] = set() + for selector in self.profiles: + if type(selector) is not int: + raise ConfigManagerError("invalid shortcut selector") + if selector == PROFILE_NONE_BUTTON: + continue + if not (0 <= selector < 4 or 12 <= selector < 16): + raise ConfigManagerError( + "shortcut selector must be a face or D-pad button" + ) + if selector in selected or selector == self.modifier: + raise ConfigManagerError( + "shortcut selectors must be unique and differ from modifier" + ) + if self.modifier == PROFILE_NONE_BUTTON: + raise ConfigManagerError("enabled shortcuts require a modifier") + selected.add(selector) + + def to_json_object(self) -> dict[str, Any]: + return { + "modifier": _control_name(self.modifier), + "profiles": [_button_name(value) for value in self.profiles], + } + + @classmethod + def from_json_object(cls, value: Any) -> ProfileShortcuts: + obj = _require_object(value, ("modifier", "profiles"), "profile.shortcuts") + if type(obj["profiles"]) is not list: + raise ConfigManagerError("profile.shortcuts.profiles must be an array") + return cls( + _control_index(obj["modifier"], "profile.shortcuts.modifier"), + tuple( + _button_index(selector, f"profile.shortcuts.profiles[{index}]") + for index, selector in enumerate(obj["profiles"]) + ), + ) + + +@dataclass(frozen=True) +class ProfileShift: + mode: int = 0 + modifier: int = PROFILE_NONE_BUTTON + button_map: tuple[int, ...] = tuple(range(len(LOGICAL_BUTTONS))) + + def __post_init__(self) -> None: + _require_int(self.mode, "Shift mode", 0, len(SHIFT_MODES) - 1) + if type(self.modifier) is not int or ( + self.modifier != PROFILE_NONE_BUTTON + and not 0 <= self.modifier < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid Shift modifier") + if self.mode != 0 and self.modifier == PROFILE_NONE_BUTTON: + raise ConfigManagerError("enabled Shift requires a modifier") + if type(self.button_map) is not tuple or len(self.button_map) != len( + LOGICAL_BUTTONS + ): + raise ConfigManagerError("Shift button map must contain 16 mappings") + for output in self.button_map: + if type(output) is not int or ( + output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) + ): + raise ConfigManagerError("Shift outputs must be buttons or null") + + def to_json_object(self) -> dict[str, Any]: + return { + "mode": SHIFT_MODES[self.mode], + "modifier": _control_name(self.modifier), + "button_map": { + name: _button_name(self.button_map[index]) + for index, name in enumerate(LOGICAL_BUTTONS) + }, + } + + @classmethod + def from_json_object(cls, value: Any) -> ProfileShift: + obj = _require_object( + value, ("mode", "modifier", "button_map"), "profile.shift" + ) + mappings = _require_object( + obj["button_map"], LOGICAL_BUTTONS, "profile.shift.button_map" + ) + return cls( + _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), + _control_index(obj["modifier"], "profile.shift.modifier"), + tuple( + _button_index(mappings[name], f"profile.shift.button_map.{name}") + for name in LOGICAL_BUTTONS + ), + ) + + +@dataclass(frozen=True) +class TurboSettings: + rate_hz: int = 15 + duty_percent: int = 50 + burst_count: int = 3 + + def __post_init__(self) -> None: + _require_int( + self.rate_hz, "Turbo rate", PROFILE_TURBO_RATE_MIN, PROFILE_TURBO_RATE_MAX + ) + _require_int( + self.duty_percent, + "Turbo duty", + PROFILE_TURBO_DUTY_MIN, + PROFILE_TURBO_DUTY_MAX, + ) + _require_int( + self.burst_count, + "Turbo burst count", + PROFILE_TURBO_BURST_MIN, + PROFILE_TURBO_BURST_MAX, + ) + + def to_json_object(self) -> dict[str, Any]: + return { + "rate_hz": self.rate_hz, + "duty_percent": self.duty_percent, + "burst_count": self.burst_count, + } + + @classmethod + def from_json_object(cls, value: Any, name: str) -> TurboSettings: + obj = _require_object(value, ("rate_hz", "duty_percent", "burst_count"), name) + return cls(obj["rate_hz"], obj["duty_percent"], obj["burst_count"]) + + @dataclass(frozen=True) class ControllerMacro: trigger_mask: int cancel_control: int steps: tuple[MacroStep, ...] + playback: int = 0 + repeat_count: int = 1 def __post_init__(self) -> None: _require_int( @@ -1345,9 +1503,21 @@ class ControllerMacro: for step in self.steps ) ): - raise ConfigManagerError( - "macro must contain zero to eight state steps" - ) + raise ConfigManagerError("macro must contain zero to eight state steps") + _require_int(self.playback, "macro playback", 0, len(MACRO_PLAYBACK_MODES) - 1) + _require_int( + self.repeat_count, + "macro repeat count", + PROFILE_MACRO_REPEAT_MIN, + PROFILE_MACRO_REPEAT_MAX, + ) + if ( + self.trigger_mask + and self.steps + and self.playback != 0 + and not any(step.duration_ms for step in self.steps) + ): + raise ConfigManagerError("looping macros require a nonzero cycle duration") @classmethod def empty(cls) -> ControllerMacro: @@ -1358,18 +1528,21 @@ class ControllerMacro: "trigger": _control_mask_to_json(self.trigger_mask), "cancel": _control_name(self.cancel_control), "steps": [step.to_json_object() for step in self.steps], + "playback": MACRO_PLAYBACK_MODES[self.playback], + "repeat_count": self.repeat_count, } @classmethod - def from_json_object(cls, value: Any, name: str) -> ControllerMacro: - obj = _require_object( - value, ("trigger", "cancel", "steps"), name - ) + def from_json_object( + cls, value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ControllerMacro: + fields = ["trigger", "cancel", "steps"] + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.extend(("playback", "repeat_count")) + obj = _require_object(value, fields, name) steps = obj["steps"] if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO: - raise ConfigManagerError( - f"{name}.steps must contain zero to eight steps" - ) + raise ConfigManagerError(f"{name}.steps must contain zero to eight steps") return cls( _control_mask_from_json(obj["trigger"], f"{name}.trigger"), _control_index(obj["cancel"], f"{name}.cancel"), @@ -1377,6 +1550,12 @@ class ControllerMacro: MacroStep.from_json_object(step, f"{name}.steps[{index}]") for index, step in enumerate(steps) ), + ( + _require_enum(obj["playback"], MACRO_PLAYBACK_MODES, f"{name}.playback") + if schema_version >= PROFILE_SCHEMA_VERSION + else 0 + ), + obj["repeat_count"] if schema_version >= PROFILE_SCHEMA_VERSION else 1, ) @@ -1394,14 +1573,16 @@ class ControllerProfile: motion_toggle_chord: int macros: tuple[ControllerMacro, ...] turbo_modes: tuple[int, ...] + shortcuts: ProfileShortcuts = ProfileShortcuts() + shift: ProfileShift = ProfileShift() + turbo_defaults: TurboSettings = TurboSettings() + turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS) def __post_init__(self) -> None: if type(self.button_map) is not tuple or len(self.button_map) != len( LOGICAL_BUTTONS ): - raise ConfigManagerError( - "button map must contain 16 logical mappings" - ) + raise ConfigManagerError("button map must contain 16 logical mappings") for mapping in self.button_map: if type(mapping) is not int or ( mapping != PROFILE_NONE_BUTTON @@ -1415,9 +1596,7 @@ class ControllerProfile: if not isinstance(self.left_trigger, TriggerConfig) or not isinstance( self.right_trigger, TriggerConfig ): - raise ConfigManagerError( - "profile triggers must be TriggerConfig values" - ) + raise ConfigManagerError("profile triggers must be TriggerConfig values") routed_triggers = [ trigger.output for trigger in (self.left_trigger, self.right_trigger) @@ -1427,12 +1606,8 @@ class ControllerProfile: raise ConfigManagerError( "left and right trigger cannot target the same analog trigger" ) - _require_int( - self.weak_rumble_scale, "weak rumble scale", 0, 0xFF - ) - _require_int( - self.strong_rumble_scale, "strong rumble scale", 0, 0xFF - ) + _require_int(self.weak_rumble_scale, "weak rumble scale", 0, 0xFF) + _require_int(self.strong_rumble_scale, "strong rumble scale", 0, 0xFF) _require_int( self.confirmation_policy, "confirmation policy", @@ -1463,27 +1638,38 @@ class ControllerProfile: "profile macros exceed the sixteen-step shared pool" ) encoded_size = sum( - len(step.to_sparse_bytes()) - for macro in self.macros - for step in macro.steps + len(step.to_sparse_bytes()) for macro in self.macros for step in macro.steps ) if encoded_size > PROFILE_MACRO_STREAM_SIZE: - raise ConfigManagerError( - "profile macros exceed the 136-byte sparse stream" - ) + raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") triggers = [ - macro.trigger_mask - for macro in self.macros - if macro.trigger_mask != 0 + macro.trigger_mask for macro in self.macros if macro.trigger_mask != 0 ] if len(triggers) != len(set(triggers)): raise ConfigManagerError("macro trigger chords must be unique") - if type(self.turbo_modes) is not tuple or len( - self.turbo_modes - ) != len(LOGICAL_BUTTONS): + if type(self.turbo_modes) is not tuple or len(self.turbo_modes) != len( + LOGICAL_BUTTONS + ): raise ConfigManagerError("Turbo modes must contain 16 entries") for mode in self.turbo_modes: _require_int(mode, "Turbo mode", 0, len(TURBO_MODES) - 1) + if not isinstance(self.shortcuts, ProfileShortcuts): + raise ConfigManagerError("profile shortcuts must be ProfileShortcuts") + if not isinstance(self.shift, ProfileShift): + raise ConfigManagerError("profile Shift must be ProfileShift") + if not isinstance(self.turbo_defaults, TurboSettings): + raise ConfigManagerError("Turbo defaults must be TurboSettings") + if ( + type(self.turbo_overrides) is not tuple + or len(self.turbo_overrides) != len(LOGICAL_BUTTONS) + or not all( + settings is None or isinstance(settings, TurboSettings) + for settings in self.turbo_overrides + ) + ): + raise ConfigManagerError( + "Turbo overrides must contain 16 settings or null entries" + ) @classmethod def default(cls) -> ControllerProfile: @@ -1513,31 +1699,29 @@ class ControllerProfile: confirmation_policy=3, switching_chord=0, motion_toggle_chord=0, - macros=tuple( - ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT) - ), + macros=tuple(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT)), turbo_modes=(0,) * len(LOGICAL_BUTTONS), ) @classmethod def from_bytes(cls, payload: bytes) -> ControllerProfile: payload = bytes(payload) - if len(payload) != PROFILE_SIZE: + if len(payload) not in (PROFILE_LEGACY_SIZE, PROFILE_SIZE): raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from("= PROFILE_SCHEMA_VERSION else PROFILE_LEGACY_SIZE + ) if ( version < PROFILE_LEGACY_SCHEMA_VERSION or version > PROFILE_SCHEMA_VERSION - or size != PROFILE_SIZE + or size != expected_size + or len(payload) != expected_size ): raise ConfigManagerError("unsupported profile schema") - has_control_mapping = ( - version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION - ) - has_action_controls = ( - version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION - ) - sparse_macros = version == PROFILE_SCHEMA_VERSION + has_control_mapping = version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION + sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION if sparse_macros: if payload[75] & 0xCC: raise ConfigManagerError("profile action flags are invalid") @@ -1564,13 +1748,11 @@ class ControllerProfile: right_trigger = _migrate_legacy_trigger_threshold(right_trigger) if sparse_macros: - switching_chord = ( - struct.unpack_from("> 4) & 0x03) << 16) + motion_toggle_chord = struct.unpack_from("> 4) & 0x03) << 16 ) turbo_modes = tuple(payload[80:96]) macros: list[ControllerMacro] = [] @@ -1581,9 +1763,8 @@ class ControllerProfile: descriptor = payload[offset : offset + 6] if descriptor[2] & 0x80 or descriptor[3] != stream_offset: raise ConfigManagerError("invalid sparse macro descriptor") - trigger_mask = ( - struct.unpack_from("> 2) & 0x1F if cancel > len(LOGICAL_CONTROLS) - 1 and cancel != 0x1F: @@ -1601,8 +1782,9 @@ class ControllerProfile: for step_index in range(step_count): step, step_size = MacroStep.from_sparse_bytes( payload[ - 120 + stream_offset + consumed : - 120 + stream_offset + encoded_size + 120 + stream_offset + consumed : 120 + + stream_offset + + encoded_size ], f"profile.macros[{macro_index}].steps[{step_index}]", ) @@ -1615,20 +1797,24 @@ class ControllerProfile: trigger_mask, PROFILE_NONE_BUTTON if cancel == 0x1F else cancel, tuple(steps), + payload[336 + macro_index * 2] + if version >= PROFILE_SCHEMA_VERSION + else 0, + payload[337 + macro_index * 2] + if version >= PROFILE_SCHEMA_VERSION + else 1, ) ) stream_offset += consumed total_steps += step_count - if payload[120 + stream_offset :] != bytes( + if payload[120 + stream_offset : PROFILE_LEGACY_SIZE] != bytes( PROFILE_MACRO_STREAM_SIZE - stream_offset ): raise ConfigManagerError("nonzero sparse macro padding") else: switching_chord = struct.unpack_from("= PROFILE_SCHEMA_VERSION: + shortcuts = ProfileShortcuts(payload[256], tuple(payload[257:265])) + shift = ProfileShift(payload[265], payload[266], tuple(payload[267:283])) + turbo_defaults = TurboSettings(*payload[283:286]) + override_mask = struct.unpack_from(" 2 for mode in turbo_modes): + raise ConfigManagerError("invalid legacy Turbo mode") return cls( button_map=tuple(payload[4:20]), @@ -1685,12 +1893,15 @@ class ControllerProfile: motion_toggle_chord=motion_toggle_chord, macros=tuple(macros), turbo_modes=turbo_modes, + shortcuts=shortcuts, + shift=shift, + turbo_defaults=turbo_defaults, + turbo_overrides=tuple(turbo_overrides), ) + def to_bytes(self) -> bytes: payload = bytearray(PROFILE_SIZE) - struct.pack_into( - "> 16) & 0x03) - | (((self.motion_toggle_chord >> 16) & 0x03) << 4) + payload[75] = ((self.switching_chord >> 16) & 0x03) | ( + ((self.motion_toggle_chord >> 16) & 0x03) << 4 ) struct.pack_into("> 16) & 0x03) | (cancel << 2) + payload[descriptor_offset + 2] = ((macro.trigger_mask >> 16) & 0x03) | ( + cancel << 2 ) payload[descriptor_offset + 3] = len(stream) payload[descriptor_offset + 4] = len(macro.steps) payload[descriptor_offset + 5] = len(encoded_steps) stream.extend(encoded_steps) + payload[336 + macro_index * 2] = macro.playback + payload[337 + macro_index * 2] = macro.repeat_count if len(stream) > PROFILE_MACRO_STREAM_SIZE: - raise ConfigManagerError( - "profile macros exceed the 136-byte sparse stream" - ) + raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") payload[120 : 120 + len(stream)] = stream + payload[256] = self.shortcuts.modifier + payload[257:265] = bytes(self.shortcuts.profiles) + payload[265] = self.shift.mode + payload[266] = self.shift.modifier + payload[267:283] = bytes(self.shift.button_map) + payload[283:286] = bytes( + ( + self.turbo_defaults.rate_hz, + self.turbo_defaults.duty_percent, + self.turbo_defaults.burst_count, + ) + ) + override_mask = 0 + settings_offset = 288 + for button, settings in enumerate(self.turbo_overrides): + if settings is not None: + override_mask |= 1 << button + payload[settings_offset : settings_offset + 3] = bytes( + ( + settings.rate_hz, + settings.duty_percent, + settings.burst_count, + ) + ) + settings_offset += 3 + struct.pack_into(" dict[str, Any]: @@ -1758,21 +1992,25 @@ class ControllerProfile: "rumble": { "weak_scale": self.weak_rumble_scale, "strong_scale": self.strong_rumble_scale, - "confirmation_policy": RUMBLE_POLICIES[ - self.confirmation_policy - ], + "confirmation_policy": RUMBLE_POLICIES[self.confirmation_policy], }, "switching_chord": _control_mask_to_json(self.switching_chord), - "motion_toggle_chord": _control_mask_to_json( - self.motion_toggle_chord - ), - "macros": [ - macro.to_json_object() for macro in self.macros - ], + "motion_toggle_chord": _control_mask_to_json(self.motion_toggle_chord), + "macros": [macro.to_json_object() for macro in self.macros], "turbo": { name: TURBO_MODES[self.turbo_modes[index]] for index, name in enumerate(LOGICAL_BUTTONS) }, + "shortcuts": self.shortcuts.to_json_object(), + "shift": self.shift.to_json_object(), + "turbo_settings": { + "defaults": self.turbo_defaults.to_json_object(), + "overrides": { + LOGICAL_BUTTONS[index]: settings.to_json_object() + for index, settings in enumerate(self.turbo_overrides) + if settings is not None + }, + }, } def to_json(self) -> str: @@ -1806,17 +2044,24 @@ class ControllerProfile: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: fields.append("motion_toggle_chord") fields.append( - "macros" if schema_version == PROFILE_SCHEMA_VERSION else "macro" + "macros" + if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION + else "macro" ) + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.extend(("shortcuts", "shift", "turbo_settings")) obj = _require_object(value, fields, "profile") - if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE: + expected_size = ( + PROFILE_SIZE + if schema_version >= PROFILE_SCHEMA_VERSION + else PROFILE_LEGACY_SIZE + ) + if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != expected_size: raise ConfigManagerError("unsupported profile schema") button_map = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" ) - sticks = _require_object( - obj["sticks"], ("left", "right"), "profile.sticks" - ) + sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( obj["triggers"], ("left", "right"), "profile.triggers" ) @@ -1825,9 +2070,7 @@ class ControllerProfile: ("weak_scale", "strong_scale", "confirmation_policy"), "profile.rumble", ) - turbo = _require_object( - obj["turbo"], LOGICAL_BUTTONS, "profile.turbo" - ) + turbo = _require_object(obj["turbo"], LOGICAL_BUTTONS, "profile.turbo") left_trigger = TriggerConfig.from_json_object( triggers["left"], "profile.triggers.left", @@ -1849,9 +2092,7 @@ class ControllerProfile: if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_mask_from_json ) - switching_chord = mask_parser( - obj["switching_chord"], "profile.switching_chord" - ) + switching_chord = mask_parser(obj["switching_chord"], "profile.switching_chord") motion_toggle_chord = ( mask_parser( obj["motion_toggle_chord"], @@ -1860,13 +2101,16 @@ class ControllerProfile: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION else 0 ) - if schema_version == PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION: macro_values = obj["macros"] - if type(macro_values) is not list or len(macro_values) != PROFILE_MACRO_COUNT: + if ( + type(macro_values) is not list + or len(macro_values) != PROFILE_MACRO_COUNT + ): raise ConfigManagerError("profile.macros must contain four macros") macros = tuple( ControllerMacro.from_json_object( - macro, f"profile.macros[{index}]" + macro, f"profile.macros[{index}]", schema_version=schema_version ) for index, macro in enumerate(macro_values) ) @@ -1882,9 +2126,7 @@ class ControllerProfile: "profile.macro.steps must contain one to eight steps" ) decoded_steps = tuple( - MacroStep.from_json_object( - step, f"profile.macro.steps[{index}]" - ) + MacroStep.from_json_object(step, f"profile.macro.steps[{index}]") for index, step in enumerate(steps) ) if decoded_steps[-1] != MacroStep.end() or any( @@ -1894,35 +2136,50 @@ class ControllerProfile: "legacy macro must end with one canonical end step" ) if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: - trigger_mask = mask_parser( - macro["trigger"], "profile.macro.trigger" - ) + trigger_mask = mask_parser(macro["trigger"], "profile.macro.trigger") cancel_control = ( - _control_index( - macro["cancel"], "profile.macro.cancel" - ) + _control_index(macro["cancel"], "profile.macro.cancel") if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION - else _button_index( - macro["cancel"], "profile.macro.cancel" - ) + else _button_index(macro["cancel"], "profile.macro.cancel") ) else: - trigger = _button_index( - macro["trigger"], "profile.macro.trigger" - ) - trigger_mask = ( - 0 if trigger == PROFILE_NONE_BUTTON else 1 << trigger - ) - cancel_control = _button_index( - macro["cancel"], "profile.macro.cancel" - ) + trigger = _button_index(macro["trigger"], "profile.macro.trigger") + trigger_mask = 0 if trigger == PROFILE_NONE_BUTTON else 1 << trigger + cancel_control = _button_index(macro["cancel"], "profile.macro.cancel") macros = ( - ControllerMacro( - trigger_mask, cancel_control, decoded_steps[:-1] - ), + ControllerMacro(trigger_mask, cancel_control, decoded_steps[:-1]), *(ControllerMacro.empty() for _ in range(PROFILE_MACRO_COUNT - 1)), ) + shortcuts = ProfileShortcuts() + shift = ProfileShift() + turbo_defaults = TurboSettings() + turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) + if schema_version >= PROFILE_SCHEMA_VERSION: + shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"]) + shift = ProfileShift.from_json_object(obj["shift"]) + settings = _require_object( + obj["turbo_settings"], + ("defaults", "overrides"), + "profile.turbo_settings", + ) + turbo_defaults = TurboSettings.from_json_object( + settings["defaults"], "profile.turbo_settings.defaults" + ) + overrides = settings["overrides"] + if type(overrides) is not dict or any( + name not in LOGICAL_BUTTONS for name in overrides + ): + raise ConfigManagerError( + "Turbo overrides must map button names to settings" + ) + for name, override in overrides.items(): + turbo_overrides[LOGICAL_BUTTONS.index(name)] = ( + TurboSettings.from_json_object( + override, f"profile.turbo_settings.overrides.{name}" + ) + ) + return cls( button_map=tuple( ( @@ -1956,10 +2213,18 @@ class ControllerProfile: macros=macros, turbo_modes=tuple( _require_enum( - turbo[name], TURBO_MODES, f"profile.turbo.{name}" + turbo[name], + TURBO_MODES + if schema_version >= PROFILE_SCHEMA_VERSION + else TURBO_MODES[:3], + f"profile.turbo.{name}", ) for name in LOGICAL_BUTTONS ), + shortcuts=shortcuts, + shift=shift, + turbo_defaults=turbo_defaults, + turbo_overrides=tuple(turbo_overrides), ) @classmethod @@ -1991,17 +2256,20 @@ def _host_transaction_id() -> int: def encode_request(operation: int, payload: bytes = b"") -> bytes: if len(payload) + REQUEST_HEADER_SIZE > MAXIMUM_REQUEST_SIZE: raise ConfigManagerError("management request exceeds EP0 limit") - return struct.pack( - "<4sBBBBHHI", - b"SPMG", - PROTOCOL_VERSION, - operation, - 0, - 0, - len(payload), - 0, - _crc32(payload), - ) + payload + return ( + struct.pack( + "<4sBBBBHHI", + b"SPMG", + PROTOCOL_VERSION, + operation, + 0, + 0, + len(payload), + 0, + _crc32(payload), + ) + + payload + ) def parse_response(payload: bytes, expected_operation: int) -> Envelope: @@ -2021,13 +2289,9 @@ def parse_response(payload: bytes, expected_operation: int) -> Envelope: if magic != b"SPMG": raise ConfigManagerError("device does not implement switch-pico management") if version != PROTOCOL_VERSION: - raise ConfigManagerError( - f"unsupported management protocol version {version}" - ) + raise ConfigManagerError(f"unsupported management protocol version {version}") if operation != expected_operation: - raise ConfigManagerError( - f"unexpected management operation 0x{operation:02x}" - ) + raise ConfigManagerError(f"unexpected management operation 0x{operation:02x}") if len(payload) != RESPONSE_HEADER_SIZE + payload_size: raise ConfigManagerError("invalid management payload size") body = bytes(payload[RESPONSE_HEADER_SIZE:]) @@ -2052,9 +2316,7 @@ def _raise_status(envelope: Envelope, *, pending_ok: bool = False) -> None: if envelope.status == STATUS_PENDING: raise ConfigManagerError("device operation is still pending") raise ConfigManagerError( - STATUS_NAMES.get( - envelope.status, f"unknown device status {envelope.status}" - ) + STATUS_NAMES.get(envelope.status, f"unknown device status {envelope.status}") ) @@ -2070,9 +2332,7 @@ def _control_in(device: UsbDevice, operation: int) -> Envelope: return parse_response(bytes(payload), operation) -def _control_out( - device: UsbDevice, operation: int, payload: bytes = b"" -) -> None: +def _control_out(device: UsbDevice, operation: int, payload: bytes = b"") -> None: request = encode_request(operation, payload) device.ctrl_transfer( 0x40, @@ -2098,9 +2358,7 @@ def read_info(device: UsbDevice) -> DeviceInfo: f"unknown device capability flags 0x{capabilities:02x}" ) if capabilities != 0 and not capabilities & CAPABILITY_INPUT: - raise ConfigManagerError( - "device capability flags omit required input support" - ) + raise ConfigManagerError("device capability flags omit required input support") return DeviceInfo( firmware_version=( envelope.payload[0], @@ -2110,11 +2368,204 @@ def read_info(device: UsbDevice) -> DeviceInfo: board=envelope.payload[3], active_mode=active_mode, capabilities=capabilities, - maximum_configuration_size=struct.unpack_from( - " MacroCapturePage: + _require_int(run_id, "capture run ID", 0, 0xFFFFFFFF) + _require_int(first_index, "capture first index", 0, 128) + _control_out(device, OP_MACRO_CAPTURE, struct.pack("= len(MACRO_CAPTURE_STATES) + or slot >= 4 + or channels == 0 + or channels > 31 + or count > 32 + or total > limit + or not 1 <= limit <= 128 + or first != first_index + or first + count > total + or len(payload) != 32 + count * 20 + or not 1 <= axis <= 32767 + or not 1 <= trigger <= 65535 + or not 1 <= duration <= 80000 + or elapsed > duration * 1000 + or any(payload[29:32]) + ): + raise ConfigManagerError("invalid macro capture bounds or run identity") + events = [] + previous = -1 + for index in range(count): + offset = 32 + index * 20 + event = MacroCaptureEvent(*struct.unpack_from(" elapsed + or any(payload[offset + 18 : offset + 20]) + ): + raise ConfigManagerError("invalid macro capture event timeline") + previous = event.at_us + events.append(event) + return MacroCapturePage( + actual_run, + generation, + elapsed, + slot, + state, + channels, + total, + first, + axis, + trigger, + duration, + limit, + tuple(events), + ) + + +def start_macro_capture( + device: UsbDevice, + slot: int, + connection_generation: int, + *, + channels: int = 1, + max_events: int = 8, + axis_quantum: int = 512, + trigger_quantum: int = 1024, + max_duration_ms: int = 10000, +) -> MacroCapturePage: + for value, name, low, high in ( + (slot, "capture slot", 0, 3), + (connection_generation, "capture generation", 0, 0xFFFFFFFF), + (channels, "capture channels", 1, 31), + (max_events, "capture capacity", 1, 128), + (axis_quantum, "capture axis quantum", 1, 32767), + (trigger_quantum, "capture trigger quantum", 1, 65535), + (max_duration_ms, "capture duration", 1, 80000), + ): + _require_int(value, name, low, high) + _control_out( + device, + OP_MACRO_CAPTURE, + struct.pack( + " MacroCapturePage: + _require_int(run_id, "capture run ID", 1, 0xFFFFFFFF) + _control_out(device, OP_MACRO_CAPTURE, struct.pack(" MacroCapturePage: + page = read_macro_capture(device, run_id) + if page.state_name in ("idle", "recording"): + raise ConfigManagerError("stop recording before collecting macro steps") + events = list(page.events) + while len(events) < page.total_events: + following = read_macro_capture(device, run_id, len(events)) + if ( + following.run_id != page.run_id + or following.connection_generation != page.connection_generation + or following.elapsed_us != page.elapsed_us + or following.total_events != page.total_events + or following.state != page.state + or following.channels != page.channels + or following.slot != page.slot + or following.axis_quantum != page.axis_quantum + or following.trigger_quantum != page.trigger_quantum + or following.max_duration_ms != page.max_duration_ms + or following.max_events != page.max_events + or not following.events + ): + raise ConfigManagerError("macro capture changed while reading pages") + if events and following.events[0].at_us <= events[-1].at_us: + raise ConfigManagerError("macro capture pages overlap") + events.extend(following.events) + return MacroCapturePage( + page.run_id, + page.connection_generation, + page.elapsed_us, + page.slot, + page.state, + page.channels, + page.total_events, + 0, + page.axis_quantum, + page.trigger_quantum, + page.max_duration_ms, + page.max_events, + tuple(events), + ) + + +def capture_macro_steps(page: MacroCapturePage) -> tuple[MacroStep, ...]: + if ( + page.state_name in ("idle", "recording") + or page.first_index != 0 + or len(page.events) != page.total_events + or not page.events + or page.events[0].at_us != 0 + ): + raise ConfigManagerError("a complete stopped capture is required") + steps = [] + for index, event in enumerate(page.events): + end_us = ( + page.events[index + 1].at_us + if index + 1 < len(page.events) + else page.elapsed_us + ) + duration = (end_us + 500) // 1000 - (event.at_us + 500) // 1000 + steps.append( + MacroStep( + 0, + page.channels, + duration, + event.buttons, + event.left_x, + event.left_y, + event.right_x, + event.right_y, + event.left_trigger, + event.right_trigger, + ) + ) + return tuple(steps) + + def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS) _raise_status(envelope) @@ -2140,12 +2591,15 @@ def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics if len(envelope.payload) != HAPTICS_EXPERIMENT_SIZE: raise ConfigManagerError("invalid haptics experiment payload size") counters = struct.unpack_from("<17I", envelope.payload) - state, slot, last_error, reserved = struct.unpack_from( - "<4B", envelope.payload, 68 - ) + state, slot, last_error, reserved = struct.unpack_from("<4B", envelope.payload, 68) mode = envelope.payload[72] + packet_frames = envelope.payload[73] + if packet_frames not in (32, 64) or (mode == 0 and packet_frames != 64): + raise ConfigManagerError("invalid haptics packet size") host_updates, dropped_updates = struct.unpack_from("<2I", envelope.payload, 76) - if envelope.flags != 0 or reserved != 0 or any(envelope.payload[73:76]): + if envelope.payload[74] not in (0, 1): + raise ConfigManagerError("invalid haptics nonzero flag") + if envelope.flags != 0 or reserved != 0 or envelope.payload[75] != 0: raise ConfigManagerError("invalid haptics experiment reserved flags") if state >= len(HAPTICS_EXPERIMENT_STATES): raise ConfigManagerError(f"invalid haptics experiment state {state}") @@ -2156,9 +2610,15 @@ def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics ): raise ConfigManagerError(f"invalid haptics experiment slot {slot}") return HapticsExperimentDiagnostics( - *counters, state=state, slot=None if slot == 0xFF else slot, - last_error=last_error, mode=mode, host_updates=host_updates, + *counters, + state=state, + slot=None if slot == 0xFF else slot, + last_error=last_error, + mode=mode, + host_updates=host_updates, dropped_updates=dropped_updates, + packet_frames=packet_frames, + last_packet_nonzero=bool(envelope.payload[74]), ) @@ -2188,7 +2648,7 @@ def parse_haptics_transport_probe(envelope: Envelope) -> HapticsTransportProbe: raise ConfigManagerError("invalid haptics transport probe payload size") if envelope.flags != 0: raise ConfigManagerError("invalid haptics transport probe reserved flags") - fields = struct.unpack("<32I", envelope.payload) + fields = struct.unpack("<38Ii5I", envelope.payload) if fields[2] > 0xFFFF: raise ConfigManagerError("invalid haptics transport probe connection handle") if fields[25] not in (0, 1): @@ -2229,8 +2689,10 @@ def read_haptics_experiment_profile( def _print_haptics_experiment_profile( - snapshot: HapticsExperimentDiagnostics, transport: HapticsTransportProbe, - *, as_json: bool, + snapshot: HapticsExperimentDiagnostics, + transport: HapticsTransportProbe, + *, + as_json: bool, ) -> None: if as_json: values = snapshot.to_json_object() @@ -2245,18 +2707,42 @@ def _print_haptics_experiment_profile( f"handle=0x{transport.connection_handle:04x}; active={transport.active}" ) for label, calls, maximum, total in ( - ("timer lateness", transport.timer_wakes, - transport.max_timer_lateness_us, transport.total_timer_lateness_us), - ("permission wait", transport.permission_callbacks, - transport.max_permission_wait_us, transport.total_permission_wait_us), - ("l2cap_send", transport.send_calls, - transport.max_send_us, transport.total_send_us), - ("HCI write", transport.write_calls, - transport.max_write_us, transport.total_write_us), - ("HCI read", transport.read_calls, - transport.max_read_us, transport.total_read_us), - ("data-source poll", transport.poll_calls, - transport.max_poll_us, transport.total_poll_us), + ( + "timer lateness", + transport.timer_wakes, + transport.max_timer_lateness_us, + transport.total_timer_lateness_us, + ), + ( + "permission wait", + transport.permission_callbacks, + transport.max_permission_wait_us, + transport.total_permission_wait_us, + ), + ( + "l2cap_send", + transport.send_calls, + transport.max_send_us, + transport.total_send_us, + ), + ( + "HCI write", + transport.write_calls, + transport.max_write_us, + transport.total_write_us, + ), + ( + "HCI read", + transport.read_calls, + transport.max_read_us, + transport.total_read_us, + ), + ( + "data-source poll", + transport.poll_calls, + transport.max_poll_us, + transport.total_poll_us, + ), ): print(f" {label}: calls={calls}, max_us={maximum}, total_us={total}") print(f" read_packets: {transport.read_packets}") @@ -2296,7 +2782,9 @@ def _raise_haptics_experiment_failure( def _print_haptics_experiment( - snapshot: HapticsExperimentDiagnostics, *, as_json: bool, + snapshot: HapticsExperimentDiagnostics, + *, + as_json: bool, ) -> None: values = snapshot.to_json_object() if as_json: @@ -2342,8 +2830,11 @@ def _print_haptics_experiment( def _watch_haptics_experiment( - device: UsbDevice, snapshot: HapticsExperimentDiagnostics, - deadline: float, *, as_json: bool, + device: UsbDevice, + snapshot: HapticsExperimentDiagnostics, + deadline: float, + *, + as_json: bool, ) -> None: run_id = snapshot.run_id slot = snapshot.slot @@ -2376,7 +2867,8 @@ def _watch_haptics_experiment( def _run_haptics_experiment_command( - device: UsbDevice, args: argparse.Namespace, + device: UsbDevice, + args: argparse.Namespace, ) -> None: if args.haptics_command == "profile": snapshot, transport = read_haptics_experiment_profile(device) @@ -2390,9 +2882,7 @@ def _run_haptics_experiment_command( _print_haptics_experiment(before, as_json=args.json) print(HAPTICS_EXPERIMENT_ENABLE_HINT, file=sys.stderr) elif args.watch: - _watch_haptics_experiment( - device, before, deadline, as_json=args.json - ) + _watch_haptics_experiment(device, before, deadline, as_json=args.json) else: _print_haptics_experiment(before, as_json=args.json) _raise_haptics_experiment_failure(before) @@ -2420,17 +2910,17 @@ def _run_haptics_experiment_command( ) return _control_out( - device, OP_HAPTICS_EXPERIMENT, + device, + OP_HAPTICS_EXPERIMENT, bytes(({"start": 1, "gameplay": 2, "stop": 0}[action], args.slot)), ) print( f"{action.capitalize()} request accepted; pending firmware confirmation. " "USB ACK is not evidence of stream start, completion, or playback.", - file=sys.stderr if args.json else sys.stdout, flush=True, - ) - expected_run_id = ( - (before.run_id + 1) & 0xFFFFFFFF if starting else before.run_id + file=sys.stderr if args.json else sys.stdout, + flush=True, ) + expected_run_id = (before.run_id + 1) & 0xFFFFFFFF if starting else before.run_id expected_mode = (1 if action == "gameplay" else 0) if starting else before.mode observed_run = False while True: @@ -2449,9 +2939,7 @@ def _run_haptics_experiment_command( _print_haptics_experiment(snapshot, as_json=args.json) _raise_haptics_experiment_failure(snapshot) if starting and args.watch: - _watch_haptics_experiment( - device, snapshot, deadline, as_json=args.json - ) + _watch_haptics_experiment(device, snapshot, deadline, as_json=args.json) return if starting and ( snapshot.state_name == "running" @@ -2474,7 +2962,8 @@ def _run_haptics_experiment_command( f"state {snapshot.state_name}" ) elif ( - action == "stop" or observed_run + action == "stop" + or observed_run or (snapshot.run_id, snapshot.slot, snapshot.mode) != (before.run_id, before.slot, before.mode) ): @@ -2515,9 +3004,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: ): raise ConfigManagerError("invalid stored pairing-window duration") if requested_mode >= len(REQUESTED_MODE_NAMES): - raise ConfigManagerError( - f"invalid stored requested USB mode {requested_mode}" - ) + raise ConfigManagerError(f"invalid stored requested USB mode {requested_mode}") return AdapterConfiguration( pairing_window_seconds=pairing_window_seconds, generation=envelope.generation, @@ -2557,13 +3044,10 @@ def write_configuration( <= configuration.pairing_window_seconds <= PAIRING_WINDOW_SECONDS_MAX ): - raise ConfigManagerError( - "pairing window must be between 10 and 300 seconds" - ) - if ( - type(configuration.requested_mode) is not int - or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES) - ): + raise ConfigManagerError("pairing window must be between 10 and 300 seconds") + if type( + configuration.requested_mode + ) is not int or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES): raise ConfigManagerError("invalid requested USB mode") payload = struct.pack( " TransactionStatus: transaction_id = _host_transaction_id() - _control_out( - device, OP_CONFIGURATION_RESET, struct.pack(" None: - _require_int( - transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK - ) + _require_int(transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK) _control_out(device, OP_REBOOT, struct.pack(" None: _control_out(device, OP_BOOTSEL_REBOOT) @@ -2654,6 +3133,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, + PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -2686,9 +3166,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: except UnicodeDecodeError as exc: raise ConfigManagerError("invalid profile-list alias") from exc if index == 0 and not identity.is_global_fallback: - raise ConfigManagerError( - "profile list does not begin with global fallback" - ) + raise ConfigManagerError("profile list does not begin with global fallback") if index != 0 and identity.is_global_fallback: raise ConfigManagerError("duplicate global fallback profile entry") if identity in identities: @@ -2732,9 +3210,15 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, + PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") + if ( + len(envelope.payload) < 4 + or struct.unpack_from(" str: +def _decode_profile_metadata_value(payload: bytes, offset: int, label: str) -> str: size = payload[offset] encoded = payload[offset + 1 : offset + PROFILE_METADATA_VALUE_SIZE] if size > PROFILE_METADATA_MAX_BYTES or any(encoded[size:]): @@ -2767,9 +3249,7 @@ def parse_profile_metadata(envelope: Envelope) -> ProfileMetadata: or len(envelope.payload) != PROFILE_METADATA_SIZE ): raise ConfigManagerError("invalid profile metadata payload") - alias = _decode_profile_metadata_value( - envelope.payload, 0, "controller alias" - ) + alias = _decode_profile_metadata_value(envelope.payload, 0, "controller alias") names = tuple( _decode_profile_metadata_value( envelope.payload, @@ -2782,9 +3262,7 @@ def parse_profile_metadata(envelope: Envelope) -> ProfileMetadata: def read_selected_profile_metadata(device: UsbDevice) -> ProfileMetadata: - return parse_profile_metadata( - _control_in(device, OP_PROFILE_METADATA_READ) - ) + return parse_profile_metadata(_control_in(device, OP_PROFILE_METADATA_READ)) def read_profile_metadata( @@ -2809,9 +3287,7 @@ def set_profile_metadata( raise ConfigManagerError("profile metadata must be text") encoded = value.encode("utf-8") if len(encoded) > PROFILE_METADATA_MAX_BYTES: - raise ConfigManagerError( - "profile metadata must contain at most 31 UTF-8 bytes" - ) + raise ConfigManagerError("profile metadata must contain at most 31 UTF-8 bytes") transaction_id = _host_transaction_id() _control_out( device, @@ -2821,14 +3297,10 @@ def set_profile_metadata( + bytes((profile_index, len(encoded))) + encoded, ) - return _wait_for_profile_transaction( - device, transaction_id, timeout - ) + return _wait_for_profile_transaction(device, transaction_id, timeout) -def identify_controller( - device: UsbDevice, identity: ControllerIdentity -) -> None: +def identify_controller(device: UsbDevice, identity: ControllerIdentity) -> None: if identity.is_global_fallback: raise ConfigManagerError("default profile has no controller to identify") _control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes()) @@ -2852,8 +3324,18 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: if flags != 0 or payload[1] != 0xFF or any(payload[2:]): raise ConfigManagerError("invalid disconnected playtest payload") return ProfilePlaytest( - False, None, 0, 0, None, 0, - (0, 0), (0, 0), (0, 0), 0, 0, None, + False, + None, + 0, + 0, + None, + 0, + (0, 0), + (0, 0), + (0, 0), + 0, + 0, + None, ) if ( payload[1] >= PROFILE_PLAYTEST_SLOT_COUNT @@ -2862,8 +3344,8 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: ): raise ConfigManagerError("invalid connected playtest payload") identity = ControllerIdentity.from_bytes(payload[12:26]) - left_x, left_y, right_x, right_y, left_trigger, right_trigger = ( - struct.unpack_from(" TransactionStatus: envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS) _raise_status(envelope, pending_ok=True) if ( - envelope.schema_version != PROFILE_SCHEMA_VERSION + not PROFILE_LEGACY_SCHEMA_VERSION + <= envelope.schema_version + <= PROFILE_SCHEMA_VERSION or len(envelope.payload) != 20 ): raise ConfigManagerError("invalid profile transaction-status payload") @@ -2907,18 +3391,16 @@ def _wait_for_profile_transaction( while time.monotonic() < deadline: envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS) if ( - envelope.schema_version != PROFILE_SCHEMA_VERSION + not PROFILE_LEGACY_SCHEMA_VERSION + <= envelope.schema_version + <= PROFILE_SCHEMA_VERSION or len(envelope.payload) != 20 ): - raise ConfigManagerError( - "invalid profile transaction-status payload" - ) + raise ConfigManagerError("invalid profile transaction-status payload") values = struct.unpack(" PairingSnapshot: raise ConfigManagerError("short pairing snapshot") record_count = envelope.payload[0] required = 4 + record_count * PAIRING_RECORD_SIZE - if ( - record_count > PAIRING_RECORD_CAPACITY - or len(envelope.payload) != required - ): + if record_count > PAIRING_RECORD_CAPACITY or len(envelope.payload) != required: raise ConfigManagerError("invalid pairing record count") records: list[PairingRecord] = [] offset = 4 @@ -3066,17 +3543,13 @@ def _candidate_devices() -> Iterable[UsbDevice]: for vendor_id, product_id in USB_IDENTITIES: devices = cast( Iterable[UsbDevice] | None, - usb.core.find( - find_all=True, idVendor=vendor_id, idProduct=product_id - ), + usb.core.find(find_all=True, idVendor=vendor_id, idProduct=product_id), ) if devices is not None: yield from devices -def find_pico( - bus: int | None, address: int | None, timeout: float = 3.0 -) -> UsbDevice: +def find_pico(bus: int | None, address: int | None, timeout: float = 3.0) -> UsbDevice: deadline = time.monotonic() + timeout failures: list[Exception] = [] while True: @@ -3167,13 +3640,9 @@ def _capture_reenumeration_snapshot( if ( device is previous_device or ( - previous_enumeration is not None - and enumeration == previous_enumeration - ) - or ( - selected_location is not None - and location == selected_location + previous_enumeration is not None and enumeration == previous_enumeration ) + or (selected_location is not None and location == selected_location) ): continue other_count += 1 @@ -3239,8 +3708,7 @@ def _wait_for_reenumeration( while True: candidates = list(_candidate_devices()) if not disappeared and not any( - _is_previous_enumeration(device, snapshot) - for device in candidates + _is_previous_enumeration(device, snapshot) for device in candidates ): disappeared = True if disappeared: @@ -3284,8 +3752,7 @@ def _wait_for_reenumeration( ) from failures[-1] if snapshot.selected_location is not None: raise ConfigManagerError( - "Pico did not re-enumerate on its original physical USB port " - "after reboot" + "Pico did not re-enumerate on its original physical USB port after reboot" ) raise ConfigManagerError("Pico did not re-enumerate after reboot") @@ -3303,24 +3770,19 @@ def configure_mode( raise ConfigManagerError("requested USB mode is not available") before_info = read_info(device) before_configuration = read_configuration(device) - if ( - before_configuration.requested_mode == requested_mode - and _mode_is_active(requested_mode, before_info.active_mode) + if before_configuration.requested_mode == requested_mode and _mode_is_active( + requested_mode, before_info.active_mode ): return device, False reenumeration_snapshot = _capture_reenumeration_snapshot(device) transaction = set_mode(device, requested_mode, timeout) request_reboot(device, transaction.transaction_id) - reenumerated = _wait_for_reenumeration( - reenumeration_snapshot, timeout - ) + reenumerated = _wait_for_reenumeration(reenumeration_snapshot, timeout) after_info = read_info(reenumerated) after_configuration = read_configuration(reenumerated) if after_configuration.requested_mode != requested_mode: - raise ConfigManagerError( - "requested USB mode was not stored after reboot" - ) + raise ConfigManagerError("requested USB mode was not stored after reboot") if not _mode_is_active(requested_mode, after_info.active_mode): raise ConfigManagerError( f"device activated {after_info.mode_name()} instead of " @@ -3351,8 +3813,7 @@ def _print_profiles(entries: Sequence[ProfileListEntry]) -> None: f"VID:PID {identity.vendor_id:04X}:{identity.product_id:04X}" ) print( - f"{index}: {description} " - f"(active profile {entry.active_profile_index + 1})" + f"{index}: {description} (active profile {entry.active_profile_index + 1})" ) @@ -3371,9 +3832,7 @@ def _load_profile(path: Path) -> ControllerProfile: try: payload = path.read_text(encoding="utf-8") except OSError as exc: - raise ConfigManagerError( - f"could not read profile JSON {path}: {exc}" - ) from exc + raise ConfigManagerError(f"could not read profile JSON {path}: {exc}") from exc return ControllerProfile.from_json(payload) @@ -3381,9 +3840,7 @@ def _save_profile(path: Path, profile: ControllerProfile) -> None: try: path.write_text(profile.to_json(), encoding="utf-8") except OSError as exc: - raise ConfigManagerError( - f"could not write profile JSON {path}: {exc}" - ) from exc + raise ConfigManagerError(f"could not write profile JSON {path}: {exc}") from exc def _profile_number(value: str) -> int: @@ -3408,11 +3865,10 @@ def _identity_index(value: str) -> int: "identity must be a non-negative list index" ) from exc if index < 0: - raise argparse.ArgumentTypeError( - "identity must be a non-negative list index" - ) + raise argparse.ArgumentTypeError("identity must be a non-negative list index") return index + def _tcp_port(value: str) -> int: try: port = int(value) @@ -3421,13 +3877,10 @@ def _tcp_port(value: str) -> int: "port must be a number from 0 to 65535" ) from exc if not 0 <= port <= 0xFFFF: - raise argparse.ArgumentTypeError( - "port must be a number from 0 to 65535" - ) + raise argparse.ArgumentTypeError("port must be a number from 0 to 65535") return port - def _add_identity_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--identity", @@ -3465,9 +3918,7 @@ def build_parser() -> argparse.ArgumentParser: haptics = commands.add_parser( "haptics-experiment", help="control the opt-in DualSense PCM experiment" ) - haptics_commands = haptics.add_subparsers( - dest="haptics_command", required=True - ) + haptics_commands = haptics.add_subparsers(dest="haptics_command", required=True) for action, help_text in ( ("start", "run the finite PCM fixture"), ("gameplay", "arm continuous Nintendo HD-rumble PCM until stopped"), @@ -3477,29 +3928,33 @@ def build_parser() -> argparse.ArgumentParser: experiment = haptics_commands.add_parser(action, help=help_text) if action != "status": experiment.add_argument( - "--slot", type=int, choices=range(HAPTICS_EXPERIMENT_SLOT_COUNT), - default=0, help="connected controller slot (default: 0)", + "--slot", + type=int, + choices=range(HAPTICS_EXPERIMENT_SLOT_COUNT), + default=0, + help="connected controller slot (default: 0)", ) if action != "stop": experiment.add_argument( - "--watch", action="store_true", + "--watch", + action="store_true", help="capture 100 ms status samples until terminal or --timeout; " - "does not stop an armed gameplay stream", + "does not stop an armed gameplay stream", ) experiment.add_argument( - "--json", action="store_true", + "--json", + action="store_true", help="emit JSON diagnostics (one object per sample with --watch)", ) profile = haptics_commands.add_parser( "profile", help="read a run-correlated transport timing snapshot" ) profile.add_argument( - "--json", action="store_true", + "--json", + action="store_true", help="emit diagnostics with a nested transport profile object", ) - reboot = commands.add_parser( - "reboot", help="reboot into a firmware or ROM target" - ) + reboot = commands.add_parser("reboot", help="reboot into a firmware or ROM target") reboot.add_argument("target", choices=("bootsel",)) mode = commands.add_parser("mode", help="select the persistent USB mode") mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES) @@ -3521,9 +3976,7 @@ def build_parser() -> argparse.ArgumentParser: "profiles", help="open the editor or manage controller profiles as JSON", ) - profile_commands = profiles.add_subparsers( - dest="profile_command", required=True - ) + profile_commands = profiles.add_subparsers(dest="profile_command", required=True) profile_commands.add_parser( "list", help="list profile identities and active profiles" ) @@ -3577,9 +4030,7 @@ def build_parser() -> argparse.ArgumentParser: _add_identity_argument(profile_activate) pairings = commands.add_parser("pairings", help="list or clear pairings") - pairing_commands = pairings.add_subparsers( - dest="pairing_command", required=True - ) + pairing_commands = pairings.add_subparsers(dest="pairing_command", required=True) pairing_commands.add_parser("list", help="list stored pairings") pairing_clear = pairing_commands.add_parser("clear", help="clear pairings") pairing_clear.add_argument("--yes", action="store_true") @@ -3594,34 +4045,20 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "haptics-experiment" and not math.isfinite(args.timeout): print("error: --timeout must be finite", file=sys.stderr) return 2 - if ( - args.command == "config" - and args.config_command == "reset" - and not args.yes - ): + if args.command == "config" and args.config_command == "reset" and not args.yes: print("error: config reset requires --yes", file=sys.stderr) return 2 - if ( - args.command == "pairings" - and args.pairing_command == "clear" - and not args.yes - ): + if args.command == "pairings" and args.pairing_command == "clear" and not args.yes: print("error: pairings clear requires --yes", file=sys.stderr) return 2 - if ( - args.command == "profiles" - and args.profile_command == "reset" - and not args.yes - ): + if args.command == "profiles" and args.profile_command == "reset" and not args.yes: print("error: profiles reset requires --yes", file=sys.stderr) return 2 imported_profile: ControllerProfile | None = None try: if args.command == "profiles" and args.profile_command == "edit": - profile_web = importlib.import_module( - ".profile_web", __package__ - ) + profile_web = importlib.import_module(".profile_web", __package__) profile_web.run_profile_editor( bus=args.bus, address=args.address, @@ -3647,41 +4084,20 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Mode capabilities: {info.capability_summary()}") print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") - print( - "Pairing window: " - f"{configuration.pairing_window_seconds} seconds" - ) + print(f"Pairing window: {configuration.pairing_window_seconds} seconds") elif args.command == "diagnostics": diagnostics = read_runtime_diagnostics(device) print(f"Initialization stage: {diagnostics.initialization_stage}") print(f"Rumble timer ticks: {diagnostics.rumble_timer_ticks}") - print( - "Configuration timer ticks: " - f"{diagnostics.configuration_timer_ticks}" - ) + print(f"Configuration timer ticks: {diagnostics.configuration_timer_ticks}") print(f"Controller reports: {diagnostics.controller_reports}") - print( - "Host rumble requests: " - f"{diagnostics.host_rumble_requests}" - ) - print( - "Local feedback requests: " - f"{diagnostics.local_feedback_requests}" - ) + print(f"Host rumble requests: {diagnostics.host_rumble_requests}") + print(f"Local feedback requests: {diagnostics.local_feedback_requests}") print(f"Rumble dispatches: {diagnostics.rumble_dispatches}") print(f"Active slots: {diagnostics.active_slots}") - print( - "Rumble-capable slots: " - f"{diagnostics.rumble_capable_slots}" - ) - print( - "Feedback-pending slots: " - f"{diagnostics.feedback_pending_slots}" - ) - print( - "Rumble-pending slots: " - f"{diagnostics.rumble_pending_slots}" - ) + print(f"Rumble-capable slots: {diagnostics.rumble_capable_slots}") + print(f"Feedback-pending slots: {diagnostics.feedback_pending_slots}") + print(f"Rumble-pending slots: {diagnostics.rumble_pending_slots}") elif args.command == "haptics-experiment": _run_haptics_experiment_command(device, args) elif args.command == "reboot": @@ -3697,9 +4113,7 @@ def main(argv: Sequence[str] | None = None) -> int: elif args.command == "config": if args.config_command == "show": configuration = read_configuration(device) - print( - f"pairing_window_seconds={configuration.pairing_window_seconds}" - ) + print(f"pairing_window_seconds={configuration.pairing_window_seconds}") print( "requested_mode=" f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" @@ -3725,10 +4139,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) else: status = reset_configuration(device, args.timeout) - print( - "Reset configuration at generation " - f"{status.stored_generation}." - ) + print(f"Reset configuration at generation {status.stored_generation}.") elif args.command == "profiles": entries = list_profiles(device) if args.profile_command == "list": @@ -3736,9 +4147,7 @@ def main(argv: Sequence[str] | None = None) -> int: else: identity = _resolve_profile_identity(entries, args.identity) if args.profile_command == "export": - profile = read_profile( - device, identity, args.profile_index - ) + profile = read_profile(device, identity, args.profile_index) _save_profile(args.path, profile) print( f"Exported profile {args.profile_index + 1} " @@ -3759,9 +4168,7 @@ def main(argv: Sequence[str] | None = None) -> int: f"(CRC {status.stored_crc:08x})." ) elif args.profile_command == "reset": - reset_profile( - device, identity, args.profile_index, args.timeout - ) + reset_profile(device, identity, args.profile_index, args.timeout) target = ( "all profiles" if args.profile_index is None @@ -3769,9 +4176,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) print(f"Reset {target} for identity {args.identity}.") else: - activate_profile( - device, identity, args.profile_index, args.timeout - ) + activate_profile(device, identity, args.profile_index, args.timeout) print( f"Activated profile {args.profile_index + 1} " f"for identity {args.identity}." diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index ca7121b..57e3ffa 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -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", diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index e6b59c7..b1c9705 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -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; } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 6e6dae6..260ec7a 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -85,6 +85,8 @@